mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
Boarding, ticketing, class fare edit updates
This commit is contained in:
@@ -68,16 +68,44 @@ export class FareEngineService {
|
||||
let ratePerKmMinor: number;
|
||||
let fareSource: string;
|
||||
|
||||
if (fareRule) {
|
||||
// Flat fare from FareRule — distance is informational only
|
||||
// 1. Segment override: exact origin→destination stop pair on this route
|
||||
const segmentOverride = await this.prisma.segmentFareRule.findFirst({
|
||||
where: {
|
||||
routeId: route.id,
|
||||
seatClassId: dto.seatClassId,
|
||||
originStopSequence: originStop.sequence,
|
||||
destinationStopSequence: destStop.sequence,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
nationality: dto.nationality ?? null,
|
||||
},
|
||||
}) ?? await this.prisma.segmentFareRule.findFirst({
|
||||
where: {
|
||||
routeId: route.id,
|
||||
seatClassId: dto.seatClassId,
|
||||
originStopSequence: originStop.sequence,
|
||||
destinationStopSequence: destStop.sequence,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
nationality: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (segmentOverride) {
|
||||
// Flat override for this exact segment — baseFareMinor is the total base, not a per-km rate
|
||||
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
|
||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||
fareSource = 'SEGMENT_FARE_RULE';
|
||||
} else if (fareRule?.tripId) {
|
||||
// Schedule-scoped flat override
|
||||
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||
fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE';
|
||||
fareSource = 'SCHEDULE_FARE_RULE';
|
||||
} else {
|
||||
// Distance × rate fallback
|
||||
// Default: distance-based using live SeatClass rate
|
||||
ratePerKmMinor = seatClass.baseFareMinor;
|
||||
baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||
fareSource = 'DISTANCE_RATE';
|
||||
baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm);
|
||||
fareSource = 'SEAT_CLASS_BASE_FARE';
|
||||
}
|
||||
|
||||
// Premium and insurance fees applied per passenger
|
||||
@@ -146,6 +174,7 @@ export class FareEngineService {
|
||||
routeCode: route.code,
|
||||
originName: originStation?.name ?? dto.originStationId,
|
||||
destinationName: destStation?.name ?? dto.destinationStationId,
|
||||
seatClassId: seatClass.id,
|
||||
seatClassName: seatClass.name,
|
||||
totalDistanceKm,
|
||||
ratePerKmMinor,
|
||||
|
||||
@@ -79,11 +79,10 @@ export class UpdateClassDto {
|
||||
@ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string;
|
||||
@ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
|
||||
@ApiPropertyOptional({ example: 500 }) @IsOptional() @IsInt() baseFareMinor?: number;
|
||||
@ApiPropertyOptional({ example: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
@ApiPropertyOptional({ example: 50 }) @IsOptional() @IsInt() baseFareMinor?: number;
|
||||
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number;
|
||||
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number;
|
||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean;
|
||||
}
|
||||
|
||||
export class GenerateSeatMapDto {
|
||||
|
||||
@@ -258,6 +258,8 @@ export class FleetService {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
baseFareMinor: dto.baseFareMinor,
|
||||
premiumMinor: dto.premiumMinor,
|
||||
insuranceFeeMinor: dto.insuranceFeeMinor,
|
||||
};
|
||||
|
||||
if (dto.isActive !== undefined) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
@@ -132,6 +132,23 @@ export class SchedulesController {
|
||||
@Body() dto: UpdateStopTimeDto,
|
||||
) { return this.service.updateStop(id, sequence, dto); }
|
||||
|
||||
@Put(':scheduleId/fares/:seatClassId')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Override fare for a specific seat class on a schedule',
|
||||
description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.',
|
||||
})
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiParam({ name: 'seatClassId', description: 'SeatClass UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Fare rule upserted' })
|
||||
upsertScheduleFare(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Param('seatClassId') seatClassId: string,
|
||||
@Body() dto: { baseFareMinor: number; validFrom?: string; validUntil?: string },
|
||||
) {
|
||||
return this.service.upsertScheduleFare(scheduleId, seatClassId, dto);
|
||||
}
|
||||
|
||||
@Get(':scheduleId/fares/stored')
|
||||
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
|
||||
@@ -402,6 +402,34 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
async upsertScheduleFare(
|
||||
scheduleId: string,
|
||||
seatClassId: string,
|
||||
dto: { baseFareMinor: number; validFrom?: string; validUntil?: string },
|
||||
) {
|
||||
const [schedule, seatClass] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }),
|
||||
this.prisma.seatClass.findUnique({ where: { id: seatClassId } }),
|
||||
]);
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
|
||||
const now = new Date();
|
||||
const validFrom = dto.validFrom ? parseEthiopianTime(dto.validFrom) : now;
|
||||
const validUntil = dto.validUntil ? parseEthiopianTime(dto.validUntil) : null;
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.fareRule.updateMany({
|
||||
where: { tripId: scheduleId, seatClassId, validUntil: null },
|
||||
data: { validUntil: now },
|
||||
});
|
||||
return tx.fareRule.create({
|
||||
data: { tripId: scheduleId, seatClassId, baseFareMinor: dto.baseFareMinor, currency: 'ETB', validFrom, validUntil },
|
||||
include: { seatClass: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createFareRule(dto: CreateFareRuleDto) {
|
||||
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
|
||||
return this.prisma.fareRule.create({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,21 +19,31 @@ export class SeatClassesService {
|
||||
return sc;
|
||||
}
|
||||
|
||||
async updateSeatClass(id: string, dto: any) {
|
||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||
const { basePrice, ...rest } = dto;
|
||||
const data = {
|
||||
...rest,
|
||||
...(basePrice !== undefined && { baseFareMinor: basePrice }),
|
||||
};
|
||||
return this.prisma.seatClass.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async createSeatClass(dto: any) {
|
||||
try {
|
||||
return await this.prisma.seatClass.create({ data: dto });
|
||||
const { basePrice, ...rest } = dto;
|
||||
const data = {
|
||||
...rest,
|
||||
...(basePrice !== undefined && { baseFareMinor: basePrice }),
|
||||
};
|
||||
return await this.prisma.seatClass.create({ data });
|
||||
} catch (e: any) {
|
||||
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async updateSeatClass(id: string, dto: any) {
|
||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||
return this.prisma.seatClass.update({ where: { id }, data: dto });
|
||||
}
|
||||
|
||||
async deleteSeatClass(id: string) {
|
||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||
|
||||
@@ -159,6 +159,8 @@ export class TicketsService {
|
||||
} : null,
|
||||
status: t.status,
|
||||
validatedAt: t.validatedAt,
|
||||
boardedAt: t.validatedAt,
|
||||
qrCode: t.qrPayload ?? null,
|
||||
createdAt: t.issuedAt,
|
||||
};
|
||||
}),
|
||||
@@ -247,9 +249,12 @@ export class TicketsService {
|
||||
// Use first seat for primary data
|
||||
const primarySeat = passengerSeats[0];
|
||||
|
||||
// Build passenger QR data with all legs included
|
||||
const qrData = JSON.stringify({
|
||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
|
||||
|
||||
// Re-encode QR with ticketNumber included
|
||||
const qrDataWithTicket = JSON.stringify({
|
||||
ref: booking.bookingRef,
|
||||
ticketNumber: barcodePayload,
|
||||
type: booking.bookingType,
|
||||
passenger: passengerName,
|
||||
seats: passengerSeats.map(ps => ({
|
||||
@@ -259,8 +264,7 @@ export class TicketsService {
|
||||
scheduleId: ps.scheduleId || booking.scheduleId,
|
||||
})),
|
||||
});
|
||||
const qrPayload = await QRCode.toDataURL(qrData);
|
||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
|
||||
const qrPayloadFinal = await QRCode.toDataURL(qrDataWithTicket);
|
||||
|
||||
const ticket = await this.prisma.ticket.create({
|
||||
data: {
|
||||
@@ -270,7 +274,7 @@ export class TicketsService {
|
||||
seatId: primarySeat.seatId,
|
||||
leg: primarySeat.leg || 1,
|
||||
scheduleId: primarySeat.scheduleId || booking.scheduleId,
|
||||
qrPayload,
|
||||
qrPayload: qrPayloadFinal,
|
||||
barcodePayload,
|
||||
} as any,
|
||||
});
|
||||
@@ -371,15 +375,13 @@ export class TicketsService {
|
||||
let bookingRef = qrCodeOrRef;
|
||||
try {
|
||||
const qrData = JSON.parse(qrCodeOrRef);
|
||||
if (qrData.ref) {
|
||||
bookingRef = qrData.ref;
|
||||
}
|
||||
if (qrData.ref) bookingRef = qrData.ref;
|
||||
} catch {
|
||||
// Not JSON, treat as booking reference
|
||||
// Not JSON, treat as booking reference or ticket number
|
||||
}
|
||||
|
||||
// Get booking and ticket info
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
let booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
@@ -389,6 +391,23 @@ export class TicketsService {
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
// Input may be a ticket number (barcodePayload) — look it up
|
||||
const ticket = await this.prisma.ticket.findFirst({ where: { barcodePayload: bookingRef } });
|
||||
if (ticket) {
|
||||
booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef: ticket.bookingRef },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
tickets: true,
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
},
|
||||
});
|
||||
if (booking) bookingRef = (booking as any).bookingRef;
|
||||
}
|
||||
}
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException('Ticket not found');
|
||||
}
|
||||
@@ -443,6 +462,7 @@ export class TicketsService {
|
||||
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
|
||||
boarding: {
|
||||
ticketId: ticket.id,
|
||||
ticketNumber: ticket.barcodePayload,
|
||||
bookingRef: booking.bookingRef,
|
||||
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
|
||||
route: `${(booking as any).schedule?.originStation?.name || 'N/A'} → ${(booking as any).schedule?.destinationStation?.name || 'N/A'}`,
|
||||
|
||||
Reference in New Issue
Block a user