Boarding, ticketing, class fare edit updates

This commit is contained in:
Stephanos A
2026-07-02 16:20:15 +03:00
parent b511da2821
commit 9494d57343
15 changed files with 370 additions and 325 deletions

View File

@@ -418,6 +418,7 @@ export class SearchService {
},
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
@@ -426,56 +427,25 @@ export class SearchService {
}
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
if (!seatClass) throw new NotFoundException(`Seat class '${dto.seatClassName}' not found`);
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const now = new Date();
const nationality = dto.nationality;
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId: seatClass?.id,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
seatClassId: seatClass.id,
nationality: dto.nationality,
scheduleId: dto.scheduleId,
adultCount: dto.adultCount,
childCount: dto.childCount ?? 0,
promoCode: dto.promoCode,
});
const bestMatch = this.selectBestFareRule(
candidates,
dto.scheduleId,
segmentRoute,
fullRoute,
nationality,
);
const baseFareMinor = bestMatch?.baseFareMinor
?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName);
const adultCount = dto.adultCount;
const childCount = dto.childCount ?? 0;
const adultFareMinor = baseFareMinor * adultCount;
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 > now) {
discountMinor = promo.percentOff
? Math.round(totalBaseFareMinor * promo.percentOff / 100)
: (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const displayCurrency = dto.displayCurrency ?? (fare.billingCurrency as Currency);
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
@@ -487,13 +457,23 @@ export class SearchService {
segmentRoute,
seatClassName: dto.seatClassName,
nationality: dto.nationality,
adultCount, childCount,
baseFareMinor, adultFareMinor, childFareMinor,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount, totalBaseFareMinor,
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor, totalMinor,
currency: 'ETB', displayCurrency, displayTotalMinor,
adultCount: fare.adultCount,
childCount: fare.childCount,
baseFareMinor: fare.baseFarePerPassengerMinor,
adultFareMinor: fare.adultCount * fare.farePerPassengerMinor,
childFareMinor: fare.paidChildrenCount * fare.farePerPassengerMinor,
freeChildrenCount: fare.freeChildrenCount,
paidChildrenCount: fare.paidChildrenCount,
premiumMinor: fare.premiumPerPassenger,
insuranceFeeMinor: fare.insurancePerPassenger,
totalBaseFareMinor: fare.subtotalMinor,
discountMinor: fare.discountMinor,
taxesFeesMinor: fare.taxMinor,
loyaltyRedemptionMinor: loyaltyMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
};
}
@@ -505,15 +485,19 @@ export class SearchService {
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
const displayCurrency = resolveCurrencyFromNationality(nationality);
// Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany
const seatClassMap = new Map<string, any>();
// Collect seat class IDs from the schedule include for the ID set,
// but fetch fresh records from DB so updated baseFareMinor is always current
const seatClassIdSet = new Set<string>();
for (const a of schedule.coachAssignments) {
for (const sc of (a.coach.coachType?.seatClasses ?? [])) {
if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc);
if (sc.isActive) seatClassIdSet.add(sc.id);
}
}
const seatClasses = Array.from(seatClassMap.values())
.sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor);
const freshSeatClasses = await this.prisma.seatClass.findMany({
where: { id: { in: Array.from(seatClassIdSet) }, isActive: true },
});
const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc]));
const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
if (seatClasses.length === 0) return [];
@@ -531,9 +515,9 @@ export class SearchService {
});
return {
seatClassName: fare.seatClassName,
baseFareMinor: fare.baseFarePerPassengerMinor,
baseFareMinor: fare.totalMinor,
displayCurrency: fare.billingCurrency as Currency,
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
displayAmountMinor: fare.totalInBillingCurrency,
};
} catch {
return null;
@@ -568,12 +552,16 @@ export class SearchService {
if (fareRules.length > 0) {
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
return fareRules.map(rule => ({
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
baseFareMinor: rule.baseFareMinor,
displayCurrency,
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
}));
const TAX_RATE = 0.05;
return fareRules.map(rule => {
const totalMinor = rule.baseFareMinor + Math.round(rule.baseFareMinor * TAX_RATE);
return {
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
baseFareMinor: totalMinor,
displayCurrency,
displayAmountMinor: Math.round(totalMinor * exchangeRate),
};
});
}
}
@@ -644,54 +632,4 @@ export class SearchService {
});
}
private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise<number> {
if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`);
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId,
});
return fare.baseFarePerPassengerMinor;
}
private selectBestFareRule(
candidates: any[],
scheduleId: string,
segmentRoute: string,
fullRoute: string,
nationality?: string,
): any | null {
const priorities = [
{ tripId: scheduleId, route: segmentRoute, nationality },
{ tripId: scheduleId, route: segmentRoute, nationality: null },
{ tripId: scheduleId, route: fullRoute, nationality },
{ tripId: scheduleId, route: fullRoute, nationality: null },
{ tripId: scheduleId, route: null, nationality },
{ tripId: scheduleId, route: null, nationality: null },
{ tripId: null, route: segmentRoute, nationality },
{ tripId: null, route: segmentRoute, nationality: null },
{ tripId: null, route: fullRoute, nationality },
{ tripId: null, route: fullRoute, nationality: null },
{ tripId: null, route: null, nationality },
{ tripId: null, route: null, nationality: null },
];
for (const priority of priorities) {
const match = candidates.find(
(c) =>
c.tripId === priority.tripId &&
c.route === priority.route &&
c.nationality === priority.nationality,
);
if (match) return match;
}
return null;
}
}