mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 00:23:40 +00:00
Boarding, payment methods, journey direction on seat hold, and more updates
This commit is contained in:
@@ -10,6 +10,7 @@ import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
function generateRef(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
@@ -277,6 +278,16 @@ export class BookingsService {
|
||||
return {
|
||||
items: items.map(booking => {
|
||||
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
|
||||
// Build passenger list with categories
|
||||
const passengerDetails = booking.seats.map((s: any) => ({
|
||||
name: s.passengerName,
|
||||
category: s.passengerCategory // 'ADULT' or 'CHILD'
|
||||
}));
|
||||
// Get unique names with their categories
|
||||
const uniquePassengers = Array.from(
|
||||
new Map(passengerDetails.map(p => [p.name, p])).values()
|
||||
);
|
||||
|
||||
return {
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
@@ -296,6 +307,7 @@ export class BookingsService {
|
||||
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
|
||||
: null,
|
||||
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
||||
passengers: uniquePassengers, // Include category info
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: booking.schedule.originStation,
|
||||
@@ -359,6 +371,24 @@ export class BookingsService {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
// Track which child gets free fare (first child encountered)
|
||||
let freeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let fareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
fareMinor = fareCalculation.baseFareMinor;
|
||||
} else {
|
||||
// Child: first child is free, subsequent children pay full fare
|
||||
if (!freeChildUsed) {
|
||||
fareMinor = 0;
|
||||
freeChildUsed = true;
|
||||
} else {
|
||||
fareMinor = fareCalculation.baseFareMinor;
|
||||
}
|
||||
}
|
||||
return { ...p, fareMinor };
|
||||
});
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
@@ -372,7 +402,7 @@ export class BookingsService {
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
seats: {
|
||||
create: passengersData.map(p => ({
|
||||
create: passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
@@ -382,7 +412,7 @@ export class BookingsService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? fareCalculation.baseFareMinor : (fareCalculation.paidChildrenCount > 0 ? fareCalculation.baseFareMinor : 0),
|
||||
fareMinor: p.fareMinor,
|
||||
displayCurrency
|
||||
}))
|
||||
}
|
||||
@@ -462,6 +492,37 @@ export class BookingsService {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
// Track which child gets free fare for outbound and return legs
|
||||
let outboundFreeChildUsed = false;
|
||||
let returnFreeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let outboundFareMinor: number;
|
||||
let returnFareMinor: number;
|
||||
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
outboundFareMinor = outboundFare.baseFareMinor;
|
||||
returnFareMinor = returnFare.baseFareMinor;
|
||||
} else {
|
||||
// Child fare for outbound
|
||||
if (!outboundFreeChildUsed) {
|
||||
outboundFareMinor = 0;
|
||||
outboundFreeChildUsed = true;
|
||||
} else {
|
||||
outboundFareMinor = outboundFare.baseFareMinor;
|
||||
}
|
||||
|
||||
// Child fare for return
|
||||
if (!returnFreeChildUsed) {
|
||||
returnFareMinor = 0;
|
||||
returnFreeChildUsed = true;
|
||||
} else {
|
||||
returnFareMinor = returnFare.baseFareMinor;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...p, outboundFareMinor, returnFareMinor };
|
||||
});
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
@@ -482,7 +543,7 @@ export class BookingsService {
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => ({
|
||||
...passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.outboundSeatId } },
|
||||
leg: 1,
|
||||
scheduleId: dto.scheduleId,
|
||||
@@ -494,10 +555,10 @@ export class BookingsService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundFare.baseFareMinor : (outboundFare.paidChildrenCount > 0 ? outboundFare.baseFareMinor : 0),
|
||||
fareMinor: p.outboundFareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
...passengersData.map(p => ({
|
||||
...passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.returnSeatId } },
|
||||
leg: 2,
|
||||
scheduleId: dto.returnScheduleId,
|
||||
@@ -509,7 +570,7 @@ export class BookingsService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? returnFare.baseFareMinor : (returnFare.paidChildrenCount > 0 ? returnFare.baseFareMinor : 0),
|
||||
fareMinor: p.returnFareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
],
|
||||
@@ -606,6 +667,37 @@ export class BookingsService {
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
// Track which child gets free fare for leg1 and leg2
|
||||
let leg1FreeChildUsed = false;
|
||||
let leg2FreeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let leg1FareMinor: number;
|
||||
let leg2FareMinor: number;
|
||||
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
leg1FareMinor = leg1Fare.baseFareMinor;
|
||||
leg2FareMinor = leg2Fare.baseFareMinor;
|
||||
} else {
|
||||
// Child fare for leg1
|
||||
if (!leg1FreeChildUsed) {
|
||||
leg1FareMinor = 0;
|
||||
leg1FreeChildUsed = true;
|
||||
} else {
|
||||
leg1FareMinor = leg1Fare.baseFareMinor;
|
||||
}
|
||||
|
||||
// Child fare for leg2
|
||||
if (!leg2FreeChildUsed) {
|
||||
leg2FareMinor = 0;
|
||||
leg2FreeChildUsed = true;
|
||||
} else {
|
||||
leg2FareMinor = leg2Fare.baseFareMinor;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...p, leg1FareMinor, leg2FareMinor };
|
||||
});
|
||||
|
||||
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
@@ -625,7 +717,7 @@ export class BookingsService {
|
||||
leg2SeatClassId,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => ({
|
||||
...passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
leg: 1,
|
||||
scheduleId: dto.scheduleId,
|
||||
@@ -637,10 +729,10 @@ export class BookingsService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? leg1Fare.baseFareMinor : (leg1Fare.paidChildrenCount > 0 ? leg1Fare.baseFareMinor : 0),
|
||||
fareMinor: p.leg1FareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
...passengersData.map(p => ({
|
||||
...passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.leg2SeatId ?? p.seatId } },
|
||||
leg: 2,
|
||||
scheduleId: dto.leg2ScheduleId,
|
||||
@@ -652,7 +744,7 @@ export class BookingsService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? leg2Fare.baseFareMinor : (leg2Fare.paidChildrenCount > 0 ? leg2Fare.baseFareMinor : 0),
|
||||
fareMinor: p.leg2FareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
],
|
||||
@@ -769,7 +861,32 @@ export class BookingsService {
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: Awaited<ReturnType<BookingsService['calculateFare']>>) => ({
|
||||
// Track which child gets free fare for all 4 legs
|
||||
let obL1FreeChildUsed = false;
|
||||
let obL2FreeChildUsed = false;
|
||||
let retL1FreeChildUsed = false;
|
||||
let retL2FreeChildUsed = false;
|
||||
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let obL1FareMinor: number, obL2FareMinor: number, retL1FareMinor: number, retL2FareMinor: number;
|
||||
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
obL1FareMinor = obL1Fare.baseFareMinor;
|
||||
obL2FareMinor = obL2Fare.baseFareMinor;
|
||||
retL1FareMinor = retL1Fare.baseFareMinor;
|
||||
retL2FareMinor = retL2Fare.baseFareMinor;
|
||||
} else {
|
||||
// Child fares for each leg
|
||||
obL1FareMinor = !obL1FreeChildUsed ? (obL1FreeChildUsed = true, 0) : obL1Fare.baseFareMinor;
|
||||
obL2FareMinor = !obL2FreeChildUsed ? (obL2FreeChildUsed = true, 0) : obL2Fare.baseFareMinor;
|
||||
retL1FareMinor = !retL1FreeChildUsed ? (retL1FreeChildUsed = true, 0) : retL1Fare.baseFareMinor;
|
||||
retL2FareMinor = !retL2FreeChildUsed ? (retL2FreeChildUsed = true, 0) : retL2Fare.baseFareMinor;
|
||||
}
|
||||
|
||||
return { ...p, obL1FareMinor, obL2FareMinor, retL1FareMinor, retL2FareMinor };
|
||||
});
|
||||
|
||||
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fareMinor: number) => ({
|
||||
seat: { connect: { id: seatId } },
|
||||
leg,
|
||||
scheduleId,
|
||||
@@ -781,7 +898,7 @@ export class BookingsService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? fare.baseFareMinor : (fare.paidChildrenCount > 0 ? fare.baseFareMinor : 0),
|
||||
fareMinor,
|
||||
displayCurrency,
|
||||
});
|
||||
|
||||
@@ -811,13 +928,13 @@ export class BookingsService {
|
||||
seats: {
|
||||
create: [
|
||||
// Outbound leg-1 (sequence 1)
|
||||
...passengersData.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, obL1Fare)),
|
||||
...passengersWithFares.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, p.obL1FareMinor)),
|
||||
// Outbound leg-2 (sequence 2)
|
||||
...passengersData.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, obL2Fare)),
|
||||
...passengersWithFares.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, p.obL2FareMinor)),
|
||||
// Return leg-1 (sequence 3)
|
||||
...passengersData.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, retL1Fare)),
|
||||
...passengersWithFares.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, p.retL1FareMinor)),
|
||||
// Return leg-2 (sequence 4)
|
||||
...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, retL2Fare)),
|
||||
...passengersWithFares.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, p.retL2FareMinor)),
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
@@ -1060,7 +1177,7 @@ export class BookingsService {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||
paymentIntent: true, ticket: true,
|
||||
paymentIntent: true, tickets: { take: 1 },
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
@@ -1077,14 +1194,14 @@ export class BookingsService {
|
||||
contactPhone: booking.contactPhone,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
id: booking.schedule.id,
|
||||
trainNumber: booking.schedule.train.number,
|
||||
trainName: booking.schedule.train.name,
|
||||
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
|
||||
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
|
||||
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
|
||||
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 },
|
||||
departureAt: (booking as any).schedule.departureAt, arrivalAt: (booking as any).schedule.arrivalAt,
|
||||
},
|
||||
passengers: booking.seats?.map((bs: any) => ({
|
||||
passengers: (booking as any).seats?.map((bs: any) => ({
|
||||
fullName: bs.passengerName,
|
||||
category: bs.passengerCategory,
|
||||
leg: bs.leg ?? 1,
|
||||
@@ -1098,8 +1215,8 @@ export class BookingsService {
|
||||
seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null,
|
||||
},
|
||||
})),
|
||||
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
|
||||
ticket: booking.ticket ? { id: booking.ticket.id, qrPayload: booking.ticket.qrPayload, barcodePayload: booking.ticket.barcodePayload, status: booking.ticket.status } : undefined,
|
||||
payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status } : undefined,
|
||||
ticket: (booking as any).tickets?.[0] ? { id: (booking as any).tickets[0].id, qrPayload: (booking as any).tickets[0].qrPayload, barcodePayload: (booking as any).tickets[0].barcodePayload, status: (booking as any).tickets[0].status } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1153,6 +1270,12 @@ export class BookingsService {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
// Check usage before allowing deletion
|
||||
const usage = await this.checkBookingUsage(id);
|
||||
if (usage.isInUse && usage.constraints) {
|
||||
throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints);
|
||||
}
|
||||
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
|
||||
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
|
||||
@@ -1172,15 +1295,16 @@ export class BookingsService {
|
||||
this.prisma.bookingCancellation.count({ where: { bookingId: id } }),
|
||||
]);
|
||||
|
||||
const usage = [];
|
||||
if (ticketCount > 0) usage.push('Ticket(s)');
|
||||
if (paymentIntentCount > 0) usage.push('Payment record(s)');
|
||||
if (modificationsCount > 0) usage.push('Modification history');
|
||||
if (cancellationCount > 0) usage.push('Cancellation record(s)');
|
||||
const constraints = [];
|
||||
if (ticketCount > 0) constraints.push({ entityName: 'ticket', count: ticketCount, action: 'complete' as const });
|
||||
if (paymentIntentCount > 0) constraints.push({ entityName: 'payment record', count: paymentIntentCount, action: 'complete' as const });
|
||||
if (modificationsCount > 0) constraints.push({ entityName: 'modification record', count: modificationsCount, action: 'complete' as const });
|
||||
if (cancellationCount > 0) constraints.push({ entityName: 'cancellation record', count: cancellationCount, action: 'complete' as const });
|
||||
|
||||
return {
|
||||
isInUse: usage.length > 0,
|
||||
affectedModules: usage,
|
||||
isInUse: constraints.length > 0,
|
||||
affectedModules: constraints.map(c => `${c.count} ${c.entityName}${c.count > 1 ? 's' : ''}`),
|
||||
constraints
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,9 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
// Save passenger details as traveler profiles
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
// Confirm seats
|
||||
await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId));
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
@@ -443,6 +446,8 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(outboundSeatIds),
|
||||
this.seatsService.confirmSeats(returnSeatIds),
|
||||
@@ -635,6 +640,8 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
|
||||
@@ -822,6 +829,8 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
|
||||
@@ -870,12 +879,44 @@ export class GuestBookingService {
|
||||
return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true };
|
||||
}
|
||||
|
||||
// Create guest passenger with basic profile
|
||||
const guestPassenger = await this.prisma.passenger.create({ data: {} });
|
||||
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
|
||||
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
|
||||
|
||||
return { guestPassengerId: guestPassenger.id, iamUserId: null, createdAccount: false };
|
||||
}
|
||||
|
||||
private async createTravelerProfiles(passengerId: string, passengersData: any[]): Promise<void> {
|
||||
for (const passenger of passengersData) {
|
||||
let gender: string | null = null;
|
||||
if (passenger.verifaydaData && typeof passenger.verifaydaData === 'object') {
|
||||
gender = passenger.verifaydaData.gender || passenger.verifaydaData.Gender || null;
|
||||
}
|
||||
|
||||
await this.prisma.travelerProfile.create({
|
||||
data: {
|
||||
passengerId,
|
||||
fullName: passenger.passengerName,
|
||||
gender,
|
||||
dateOfBirth: passenger.dateOfBirth,
|
||||
nationalId: passenger.idDocumentType === IdDocumentType.NATIONAL_ID ? passenger.idDocumentNumber : null,
|
||||
relationship: 'self',
|
||||
notes: JSON.stringify({
|
||||
idDocumentType: passenger.idDocumentType,
|
||||
idDocumentNumber: passenger.idDocumentNumber,
|
||||
passportNumber: passenger.passportNumber,
|
||||
passportCountry: passenger.passportCountry,
|
||||
nationality: passenger.nationality,
|
||||
phone: passenger.phone,
|
||||
email: passenger.email,
|
||||
verifaydaVerified: passenger.verifaydaVerified,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> {
|
||||
if (!userId && !deviceId) {
|
||||
throw new BadRequestException('Either userId or deviceId is required');
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { Body, Controller, Get, Post, Put, Delete, Param, UseGuards, Request, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
import { ConfigurableFareService } from './configurable-fare.service';
|
||||
import {
|
||||
CreateFareConfigurationDto,
|
||||
UpdateFareConfigurationDto,
|
||||
FareTestScenarioDto,
|
||||
FareCalculationResultDto,
|
||||
MigrateLegacyDto,
|
||||
CreateNewFormulaDto,
|
||||
ToggleFeatureDto
|
||||
} from './configurable-fare.dto';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
|
||||
@ApiTags('Configurable Fares')
|
||||
@Controller('admin/fare-configurations')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
export class ConfigurableFareController {
|
||||
constructor(private service: ConfigurableFareService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all fare configurations' })
|
||||
@ApiResponse({ status: 200, description: 'List of all configurations with summary counts' })
|
||||
async getAllConfigurations() {
|
||||
return this.service.getAllConfigurations();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create new fare configuration' })
|
||||
@ApiResponse({ status: 201, description: 'Configuration created successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid configuration data' })
|
||||
async createConfiguration(
|
||||
@Body() dto: CreateFareConfigurationDto,
|
||||
@Request() req: any
|
||||
) {
|
||||
const createdBy = req.user?.id || req.user?.sub;
|
||||
return this.service.createConfiguration(dto, createdBy);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get configuration details' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Configuration details with all rules' })
|
||||
@ApiResponse({ status: 404, description: 'Configuration not found' })
|
||||
async getConfigurationById(@Param('id') id: string) {
|
||||
return this.service.getConfigurationById(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update configuration' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Configuration updated successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Configuration not found' })
|
||||
async updateConfiguration(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateFareConfigurationDto,
|
||||
@Request() req: any
|
||||
) {
|
||||
const updatedBy = req.user?.id || req.user?.sub;
|
||||
return this.service.updateConfiguration(id, dto, updatedBy);
|
||||
}
|
||||
|
||||
@Post(':id/activate')
|
||||
@ApiOperation({ summary: 'Activate configuration' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Configuration activated successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Configuration not found' })
|
||||
async activateConfiguration(@Param('id') id: string, @Request() req: any) {
|
||||
const activatedBy = req.user?.id || req.user?.sub;
|
||||
return this.service.activateConfiguration(id, activatedBy);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete configuration' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Configuration deleted successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Configuration not found' })
|
||||
@ApiResponse({ status: 409, description: 'Cannot delete active configuration' })
|
||||
async deleteConfiguration(@Param('id') id: string, @Request() req: any) {
|
||||
const deletedBy = req.user?.id || req.user?.sub;
|
||||
return this.service.deleteConfiguration(id, deletedBy);
|
||||
}
|
||||
|
||||
@Post(':id/test')
|
||||
@ApiOperation({ summary: 'Test fare calculation with configuration' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, type: FareCalculationResultDto, description: 'Fare calculation result' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid test scenario or missing rate rules' })
|
||||
async testConfiguration(
|
||||
@Param('id') id: string,
|
||||
@Body() scenario: FareTestScenarioDto
|
||||
): Promise<FareCalculationResultDto> {
|
||||
return this.service.testConfiguration(id, scenario);
|
||||
}
|
||||
|
||||
@Get(':id/audit')
|
||||
@ApiOperation({ summary: 'Get configuration audit trail' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Audit trail entries' })
|
||||
async getAuditTrail(@Param('id') id: string) {
|
||||
return this.service.getAuditTrail(id);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('Configurable Fares')
|
||||
@Controller('admin/fare-migration')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
export class FareMigrationController {
|
||||
constructor(private service: ConfigurableFareService) {}
|
||||
|
||||
@Post('migrate-legacy')
|
||||
@ApiOperation({ summary: 'Migrate existing fare rules to configurable system' })
|
||||
@ApiResponse({ status: 200, description: 'Migration completed successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Migration failed' })
|
||||
async migrateLegacySystem(@Body() dto: MigrateLegacyDto) {
|
||||
return this.service.migrateLegacySystem(dto);
|
||||
}
|
||||
|
||||
@Post('create-new-formula')
|
||||
@ApiOperation({ summary: 'Create new formula configuration with defaults' })
|
||||
@ApiResponse({ status: 201, description: 'New formula configuration created' })
|
||||
async createNewFormulaConfiguration(@Body() dto: CreateNewFormulaDto) {
|
||||
return this.service.createNewFormulaConfiguration(dto);
|
||||
}
|
||||
|
||||
@Post('complete-setup')
|
||||
@ApiOperation({
|
||||
summary: 'Complete system setup (migrate + create + activate)',
|
||||
description: 'Performs full system migration and setup in one operation'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'System setup completed successfully' })
|
||||
async completeSetup(@Body() body: { activateNewFormula?: boolean; enableFeature?: boolean }) {
|
||||
// Step 1: Migrate legacy system
|
||||
const migrationResult = await this.service.migrateLegacySystem({ dryRun: false });
|
||||
|
||||
// Step 2: Create new formula configuration
|
||||
const newConfig = await this.service.createNewFormulaConfiguration({
|
||||
name: 'Default System Configuration',
|
||||
description: 'System-generated configuration with optimal defaults',
|
||||
activateImmediately: body.activateNewFormula !== false
|
||||
});
|
||||
|
||||
// Step 3: Enable feature flag if requested
|
||||
if (body.enableFeature) {
|
||||
await this.service.toggleFeature({
|
||||
featureName: 'USE_CONFIGURABLE_FARES',
|
||||
enabled: true,
|
||||
config: { rollout_percentage: 100 }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
migration: migrationResult,
|
||||
newConfiguration: newConfig,
|
||||
featureEnabled: body.enableFeature || false,
|
||||
message: 'System setup completed successfully'
|
||||
};
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Get migration and setup status' })
|
||||
@ApiResponse({ status: 200, description: 'Current system status' })
|
||||
async getStatus() {
|
||||
const featureStatus = await this.service.getFeatureStatus('USE_CONFIGURABLE_FARES');
|
||||
const configurations = await this.service.getAllConfigurations();
|
||||
|
||||
const activeConfig = (configurations as any[]).find(config => config.is_active);
|
||||
|
||||
return {
|
||||
configurableFaresEnabled: featureStatus.enabled,
|
||||
rolloutPercentage: featureStatus.config?.rollout_percentage || 0,
|
||||
totalConfigurations: (configurations as any[]).length,
|
||||
activeConfiguration: activeConfig?.id || null,
|
||||
activeConfigurationName: activeConfig?.name || null,
|
||||
systemReady: featureStatus.enabled && !!activeConfig
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('Configurable Fares')
|
||||
@Controller('admin/fare-configurations/system')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
export class FareSystemController {
|
||||
constructor(private service: ConfigurableFareService) {}
|
||||
|
||||
@Get('feature-status')
|
||||
@ApiOperation({ summary: 'Check configurable fares feature status' })
|
||||
@ApiQuery({ name: 'feature', required: false, description: 'Feature name (defaults to USE_CONFIGURABLE_FARES)' })
|
||||
@ApiResponse({ status: 200, description: 'Feature status retrieved' })
|
||||
async getFeatureStatus(@Query('feature') featureName = 'USE_CONFIGURABLE_FARES') {
|
||||
return this.service.getFeatureStatus(featureName);
|
||||
}
|
||||
|
||||
@Post('toggle-feature')
|
||||
@ApiOperation({ summary: 'Enable or disable configurable fares system' })
|
||||
@ApiResponse({ status: 200, description: 'Feature toggled successfully' })
|
||||
async toggleFeature(@Body() dto: ToggleFeatureDto) {
|
||||
return this.service.toggleFeature(dto);
|
||||
}
|
||||
|
||||
@Post('enable-configurable-fares')
|
||||
@ApiOperation({
|
||||
summary: 'Enable configurable fares with rollout percentage',
|
||||
description: 'Quick endpoint to enable the configurable fares feature'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Configurable fares enabled successfully' })
|
||||
async enableConfigurableFares(@Body() body: { rolloutPercentage?: number }) {
|
||||
return this.service.toggleFeature({
|
||||
featureName: 'USE_CONFIGURABLE_FARES',
|
||||
enabled: true,
|
||||
config: { rollout_percentage: body.rolloutPercentage || 100 }
|
||||
});
|
||||
}
|
||||
|
||||
@Post('disable-configurable-fares')
|
||||
@ApiOperation({
|
||||
summary: 'Disable configurable fares (fallback to legacy system)',
|
||||
description: 'Disables the configurable fares feature and falls back to legacy fare calculation'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Configurable fares disabled successfully' })
|
||||
async disableConfigurableFares() {
|
||||
return this.service.toggleFeature({
|
||||
featureName: 'USE_CONFIGURABLE_FARES',
|
||||
enabled: false,
|
||||
config: { rollout_percentage: 0 }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { IsString, IsOptional, IsBoolean, IsInt, IsArray, ValidateNested, IsDateString, IsEnum, IsNumber, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum NationalityType {
|
||||
LOCAL = 'LOCAL',
|
||||
INTERNATIONAL = 'INTERNATIONAL'
|
||||
}
|
||||
|
||||
export enum CoachType {
|
||||
REGULAR_SEAT = 'REGULAR_SEAT',
|
||||
ECONOMY_BED = 'ECONOMY_BED',
|
||||
VIP_BED = 'VIP_BED'
|
||||
}
|
||||
|
||||
export enum BedPosition {
|
||||
UPPER = 'UPPER',
|
||||
MIDDLE = 'MIDDLE',
|
||||
LOWER = 'LOWER'
|
||||
}
|
||||
|
||||
export enum ComponentType {
|
||||
INSURANCE = 'INSURANCE',
|
||||
PREMIUM = 'PREMIUM',
|
||||
SERVICE_CHARGE = 'SERVICE_CHARGE',
|
||||
TAX = 'TAX',
|
||||
DEMAND = 'DEMAND'
|
||||
}
|
||||
|
||||
export enum CalculationMethod {
|
||||
MULTIPLIER = 'MULTIPLIER',
|
||||
PERCENTAGE = 'PERCENTAGE',
|
||||
FIXED_AMOUNT = 'FIXED_AMOUNT'
|
||||
}
|
||||
|
||||
export enum AppliesTo {
|
||||
BASE_FARE = 'BASE_FARE',
|
||||
SUBTOTAL = 'SUBTOTAL',
|
||||
TOTAL = 'TOTAL'
|
||||
}
|
||||
|
||||
export enum PricingType {
|
||||
FREE = 'FREE',
|
||||
FULL_FARE = 'FULL_FARE',
|
||||
DISCOUNTED = 'DISCOUNTED'
|
||||
}
|
||||
|
||||
export class FareRateRuleDto {
|
||||
@ApiProperty({ enum: NationalityType })
|
||||
@IsEnum(NationalityType)
|
||||
nationalityType: NationalityType;
|
||||
|
||||
@ApiProperty({ enum: CoachType })
|
||||
@IsEnum(CoachType)
|
||||
coachType: CoachType;
|
||||
|
||||
@ApiPropertyOptional({ enum: BedPosition })
|
||||
@IsOptional()
|
||||
@IsEnum(BedPosition)
|
||||
bedPosition?: BedPosition;
|
||||
|
||||
@ApiProperty({ example: 3000, description: 'Rate per km in minor units (e.g., 30.00 ETB = 3000)' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
ratePerKmMinor: number;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class FareComponentDto {
|
||||
@ApiProperty({ enum: ComponentType })
|
||||
@IsEnum(ComponentType)
|
||||
componentType: ComponentType;
|
||||
|
||||
@ApiProperty({ example: 'Travel Insurance' })
|
||||
@IsString()
|
||||
componentName: string;
|
||||
|
||||
@ApiProperty({ enum: CalculationMethod })
|
||||
@IsEnum(CalculationMethod)
|
||||
calculationMethod: CalculationMethod;
|
||||
|
||||
@ApiPropertyOptional({ example: 500, description: 'Fixed amount in minor units' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
valueMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 0.02, description: 'Percentage value (e.g., 0.02 for 2%)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
percentageValue?: number;
|
||||
|
||||
@ApiProperty({ enum: AppliesTo, default: AppliesTo.SUBTOTAL })
|
||||
@IsEnum(AppliesTo)
|
||||
appliesTo: AppliesTo;
|
||||
|
||||
@ApiProperty({ example: 1, description: 'Order of application' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
applyOrder: number;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class AgePricingRuleDto {
|
||||
@ApiProperty({ example: 'Adult Passengers' })
|
||||
@IsString()
|
||||
ruleName: string;
|
||||
|
||||
@ApiProperty({ example: 5 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
minAge: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 120 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxAge?: number;
|
||||
|
||||
@ApiProperty({ enum: PricingType })
|
||||
@IsEnum(PricingType)
|
||||
pricingType: PricingType;
|
||||
|
||||
@ApiPropertyOptional({ example: 0.5, description: 'Discount percentage for DISCOUNTED type' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
discountPercentage?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1, description: 'Max free passengers for FREE type' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxFreePassengers?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
appliesToComponents?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class CreateFareConfigurationDto {
|
||||
@ApiProperty({ example: 'Summer 2024 Rates' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated rates for summer season' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ example: '2024-06-01T00:00:00.000Z' })
|
||||
@IsDateString()
|
||||
effectiveDate: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2024-08-31T23:59:59.000Z' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiryDate?: string;
|
||||
|
||||
@ApiProperty({ type: [FareRateRuleDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FareRateRuleDto)
|
||||
rateRules: FareRateRuleDto[];
|
||||
|
||||
@ApiProperty({ type: [FareComponentDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FareComponentDto)
|
||||
components: FareComponentDto[];
|
||||
|
||||
@ApiProperty({ type: [AgePricingRuleDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AgePricingRuleDto)
|
||||
ageRules: AgePricingRuleDto[];
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateFareConfigurationDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
effectiveDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiryDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [FareRateRuleDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FareRateRuleDto)
|
||||
rateRules?: FareRateRuleDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [FareComponentDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FareComponentDto)
|
||||
components?: FareComponentDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [AgePricingRuleDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AgePricingRuleDto)
|
||||
ageRules?: AgePricingRuleDto[];
|
||||
}
|
||||
|
||||
export class FareTestScenarioDto {
|
||||
@ApiProperty({ example: 100 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
distanceKm: number;
|
||||
|
||||
@ApiProperty({ example: 'Ethiopian' })
|
||||
@IsString()
|
||||
nationality: string;
|
||||
|
||||
@ApiProperty({ enum: CoachType })
|
||||
@IsEnum(CoachType)
|
||||
coachType: CoachType;
|
||||
|
||||
@ApiPropertyOptional({ enum: BedPosition })
|
||||
@IsOptional()
|
||||
@IsEnum(BedPosition)
|
||||
bedPosition?: BedPosition;
|
||||
|
||||
@ApiProperty({ example: 2, default: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
adultCount: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
childCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'SUMMER20' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 500 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
loyaltyPoints?: number;
|
||||
}
|
||||
|
||||
export class FareCalculationResultDto {
|
||||
@ApiProperty()
|
||||
baseFareMinor: number;
|
||||
|
||||
@ApiProperty()
|
||||
componentsTotal: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalBeforeDiscounts: number;
|
||||
|
||||
@ApiProperty()
|
||||
discountsTotal: number;
|
||||
|
||||
@ApiProperty()
|
||||
finalTotalMinor: number;
|
||||
|
||||
@ApiProperty()
|
||||
breakdown: Array<{
|
||||
step: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
runningTotal: number;
|
||||
}>;
|
||||
|
||||
@ApiProperty()
|
||||
currency: string;
|
||||
|
||||
@ApiProperty()
|
||||
calculationTimestamp: Date;
|
||||
}
|
||||
|
||||
export class MigrateLegacyDto {
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
dryRun?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
migrateScheduleFares?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
migrateSegmentFares?: boolean;
|
||||
}
|
||||
|
||||
export class CreateNewFormulaDto {
|
||||
@ApiProperty({ example: 'Default Formula Configuration' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'System-generated default configuration' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
activateImmediately?: boolean;
|
||||
}
|
||||
|
||||
export class ToggleFeatureDto {
|
||||
@ApiProperty({ example: 'USE_CONFIGURABLE_FARES' })
|
||||
@IsString()
|
||||
featureName: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: { rollout_percentage: 50 } })
|
||||
@IsOptional()
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigurableFareService } from './configurable-fare.service';
|
||||
import { ConfigurableFareController, FareMigrationController, FareSystemController } from './configurable-fare.controller';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ConfigurableFareController, FareMigrationController, FareSystemController],
|
||||
providers: [ConfigurableFareService],
|
||||
exports: [ConfigurableFareService],
|
||||
})
|
||||
export class ConfigurableFareModule {}
|
||||
@@ -0,0 +1,623 @@
|
||||
import { Injectable, BadRequestException, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import {
|
||||
CreateFareConfigurationDto,
|
||||
UpdateFareConfigurationDto,
|
||||
FareTestScenarioDto,
|
||||
FareCalculationResultDto,
|
||||
MigrateLegacyDto,
|
||||
CreateNewFormulaDto,
|
||||
ToggleFeatureDto,
|
||||
NationalityType,
|
||||
CoachType,
|
||||
BedPosition,
|
||||
ComponentType,
|
||||
CalculationMethod,
|
||||
AppliesTo,
|
||||
PricingType
|
||||
} from './configurable-fare.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ConfigurableFareService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// Helper method to map nationality to type
|
||||
private mapNationalityToType(nationality: string): NationalityType {
|
||||
const upperNationality = nationality.toUpperCase();
|
||||
if (upperNationality === 'ETHIOPIAN' || upperNationality === 'DJIBOUTIAN') {
|
||||
return NationalityType.LOCAL;
|
||||
}
|
||||
return NationalityType.INTERNATIONAL;
|
||||
}
|
||||
|
||||
// Generate unique IDs
|
||||
private generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Configuration Management
|
||||
async getAllConfigurations() {
|
||||
return this.prisma.$queryRaw`
|
||||
SELECT
|
||||
fc.*,
|
||||
COUNT(DISTINCT frr.id) as rate_rules_count,
|
||||
COUNT(DISTINCT fcmp.id) as components_count,
|
||||
COUNT(DISTINCT apr.id) as age_rules_count
|
||||
FROM fare_configurations fc
|
||||
LEFT JOIN fare_rate_rules frr ON fc.id = frr.fare_config_id AND frr.is_active = true
|
||||
LEFT JOIN fare_components fcmp ON fc.id = fcmp.fare_config_id AND fcmp.is_active = true
|
||||
LEFT JOIN age_pricing_rules apr ON fc.id = apr.fare_config_id AND apr.is_active = true
|
||||
GROUP BY fc.id, fc.name, fc.description, fc.effective_date, fc.expiry_date,
|
||||
fc.is_active, fc.is_default, fc.created_by, fc.approved_by,
|
||||
fc.approved_at, fc.created_at, fc.updated_at
|
||||
ORDER BY fc.created_at DESC
|
||||
`;
|
||||
}
|
||||
|
||||
async getConfigurationById(id: string) {
|
||||
const config = await this.prisma.$queryRaw`
|
||||
SELECT * FROM fare_configurations WHERE id = ${id}
|
||||
`;
|
||||
|
||||
if (!Array.isArray(config) || config.length === 0) {
|
||||
throw new NotFoundException(`Fare configuration ${id} not found`);
|
||||
}
|
||||
|
||||
const rateRules = await this.prisma.$queryRaw`
|
||||
SELECT * FROM fare_rate_rules WHERE fare_config_id = ${id} ORDER BY nationality_type, coach_type, bed_position
|
||||
`;
|
||||
|
||||
const components = await this.prisma.$queryRaw`
|
||||
SELECT * FROM fare_components WHERE fare_config_id = ${id} ORDER BY apply_order
|
||||
`;
|
||||
|
||||
const ageRules = await this.prisma.$queryRaw`
|
||||
SELECT * FROM age_pricing_rules WHERE fare_config_id = ${id} ORDER BY min_age
|
||||
`;
|
||||
|
||||
return {
|
||||
...config[0],
|
||||
rateRules,
|
||||
components,
|
||||
ageRules
|
||||
};
|
||||
}
|
||||
|
||||
async createConfiguration(dto: CreateFareConfigurationDto, createdBy?: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const configId = this.generateId('fc');
|
||||
|
||||
// Validate no overlapping active configurations
|
||||
if (dto.isDefault) {
|
||||
await tx.$executeRaw`
|
||||
UPDATE fare_configurations SET is_default = false WHERE is_default = true
|
||||
`;
|
||||
}
|
||||
|
||||
// Create main configuration
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_configurations (
|
||||
id, name, description, effective_date, expiry_date,
|
||||
is_active, is_default, created_by, created_at, updated_at
|
||||
) VALUES (
|
||||
${configId}, ${dto.name}, ${dto.description}, ${dto.effectiveDate},
|
||||
${dto.expiryDate}, false, ${dto.isDefault || false}, ${createdBy},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
|
||||
// Create rate rules
|
||||
for (const rule of dto.rateRules) {
|
||||
const ruleId = this.generateId('frr');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_rate_rules (
|
||||
id, fare_config_id, nationality_type, coach_type, bed_position,
|
||||
rate_per_km_minor, is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
${ruleId}, ${configId}, ${rule.nationalityType}, ${rule.coachType},
|
||||
${rule.bedPosition || null}, ${rule.ratePerKmMinor}, ${rule.isActive !== false},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
// Create components
|
||||
for (const component of dto.components) {
|
||||
const componentId = this.generateId('fcmp');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_components (
|
||||
id, fare_config_id, component_type, component_name, calculation_method,
|
||||
value_minor, percentage_value, applies_to, apply_order, is_active,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
${componentId}, ${configId}, ${component.componentType}, ${component.componentName},
|
||||
${component.calculationMethod}, ${component.valueMinor || null},
|
||||
${component.percentageValue || null}, ${component.appliesTo},
|
||||
${component.applyOrder}, ${component.isActive !== false}, NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
// Create age rules
|
||||
for (const ageRule of dto.ageRules) {
|
||||
const ageRuleId = this.generateId('apr');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO age_pricing_rules (
|
||||
id, fare_config_id, rule_name, min_age, max_age, pricing_type,
|
||||
discount_percentage, max_free_passengers, applies_to_components,
|
||||
is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
${ageRuleId}, ${configId}, ${ageRule.ruleName}, ${ageRule.minAge},
|
||||
${ageRule.maxAge || null}, ${ageRule.pricingType},
|
||||
${ageRule.discountPercentage || null}, ${ageRule.maxFreePassengers || null},
|
||||
${ageRule.appliesToComponents !== false}, ${ageRule.isActive !== false},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
await this.createAuditEntry(tx, configId, 'CREATED', createdBy, { action: 'Configuration created' });
|
||||
|
||||
return this.getConfigurationById(configId);
|
||||
});
|
||||
}
|
||||
|
||||
async updateConfiguration(id: string, dto: UpdateFareConfigurationDto, updatedBy?: string) {
|
||||
await this.getConfigurationById(id); // Validate exists
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Update main configuration
|
||||
if (dto.name || dto.description !== undefined || dto.effectiveDate || dto.expiryDate !== undefined) {
|
||||
await tx.$executeRaw`
|
||||
UPDATE fare_configurations
|
||||
SET
|
||||
name = COALESCE(${dto.name}, name),
|
||||
description = COALESCE(${dto.description}, description),
|
||||
effective_date = COALESCE(${dto.effectiveDate}, effective_date),
|
||||
expiry_date = COALESCE(${dto.expiryDate}, expiry_date),
|
||||
updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
}
|
||||
|
||||
// Update rate rules if provided
|
||||
if (dto.rateRules) {
|
||||
await tx.$executeRaw`DELETE FROM fare_rate_rules WHERE fare_config_id = ${id}`;
|
||||
|
||||
for (const rule of dto.rateRules) {
|
||||
const ruleId = this.generateId('frr');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_rate_rules (
|
||||
id, fare_config_id, nationality_type, coach_type, bed_position,
|
||||
rate_per_km_minor, is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
${ruleId}, ${id}, ${rule.nationalityType}, ${rule.coachType},
|
||||
${rule.bedPosition || null}, ${rule.ratePerKmMinor}, ${rule.isActive !== false},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update components if provided
|
||||
if (dto.components) {
|
||||
await tx.$executeRaw`DELETE FROM fare_components WHERE fare_config_id = ${id}`;
|
||||
|
||||
for (const component of dto.components) {
|
||||
const componentId = this.generateId('fcmp');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_components (
|
||||
id, fare_config_id, component_type, component_name, calculation_method,
|
||||
value_minor, percentage_value, applies_to, apply_order, is_active,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
${componentId}, ${id}, ${component.componentType}, ${component.componentName},
|
||||
${component.calculationMethod}, ${component.valueMinor || null},
|
||||
${component.percentageValue || null}, ${component.appliesTo},
|
||||
${component.applyOrder}, ${component.isActive !== false}, NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update age rules if provided
|
||||
if (dto.ageRules) {
|
||||
await tx.$executeRaw`DELETE FROM age_pricing_rules WHERE fare_config_id = ${id}`;
|
||||
|
||||
for (const ageRule of dto.ageRules) {
|
||||
const ageRuleId = this.generateId('apr');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO age_pricing_rules (
|
||||
id, fare_config_id, rule_name, min_age, max_age, pricing_type,
|
||||
discount_percentage, max_free_passengers, applies_to_components,
|
||||
is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
${ageRuleId}, ${id}, ${ageRule.ruleName}, ${ageRule.minAge},
|
||||
${ageRule.maxAge || null}, ${ageRule.pricingType},
|
||||
${ageRule.discountPercentage || null}, ${ageRule.maxFreePassengers || null},
|
||||
${ageRule.appliesToComponents !== false}, ${ageRule.isActive !== false},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
await this.createAuditEntry(tx, id, 'UPDATED', updatedBy, { changes: dto });
|
||||
|
||||
return this.getConfigurationById(id);
|
||||
});
|
||||
}
|
||||
|
||||
async activateConfiguration(id: string, activatedBy?: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Deactivate all other configurations
|
||||
await tx.$executeRaw`UPDATE fare_configurations SET is_active = false`;
|
||||
|
||||
// Activate this one
|
||||
await tx.$executeRaw`
|
||||
UPDATE fare_configurations
|
||||
SET is_active = true, approved_by = ${activatedBy}, approved_at = NOW()
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
|
||||
await this.createAuditEntry(tx, id, 'ACTIVATED', activatedBy, {});
|
||||
|
||||
return { success: true, message: `Configuration ${id} activated successfully` };
|
||||
});
|
||||
}
|
||||
|
||||
async deleteConfiguration(id: string, deletedBy?: string) {
|
||||
const config = await this.getConfigurationById(id);
|
||||
|
||||
if ((config as any).is_active) {
|
||||
throw new ConflictException('Cannot delete active configuration. Deactivate first.');
|
||||
}
|
||||
|
||||
await this.prisma.$executeRaw`DELETE FROM fare_configurations WHERE id = ${id}`;
|
||||
|
||||
return { success: true, message: `Configuration ${id} deleted successfully` };
|
||||
}
|
||||
|
||||
// Fare Calculation
|
||||
async testConfiguration(id: string, scenario: FareTestScenarioDto): Promise<FareCalculationResultDto> {
|
||||
const config = await this.getConfigurationById(id);
|
||||
|
||||
// Find matching rate rule
|
||||
const nationalityType = this.mapNationalityToType(scenario.nationality);
|
||||
|
||||
const rateRule = (config.rateRules as any[]).find((rule: any) =>
|
||||
rule.nationality_type === nationalityType &&
|
||||
rule.coach_type === scenario.coachType &&
|
||||
(scenario.bedPosition ? rule.bed_position === scenario.bedPosition : !rule.bed_position)
|
||||
);
|
||||
|
||||
if (!rateRule) {
|
||||
throw new BadRequestException(`No rate rule found for ${nationalityType}/${scenario.coachType}${scenario.bedPosition ? `/${scenario.bedPosition}` : ''}`);
|
||||
}
|
||||
|
||||
// Calculate base fare
|
||||
const baseFareMinor = scenario.distanceKm * rateRule.rate_per_km_minor;
|
||||
const breakdown = [
|
||||
{
|
||||
step: '1',
|
||||
description: `Base fare: ${scenario.distanceKm}km × ${rateRule.rate_per_km_minor} minor units/km`,
|
||||
amount: baseFareMinor,
|
||||
runningTotal: baseFareMinor
|
||||
}
|
||||
];
|
||||
|
||||
let runningTotal = baseFareMinor;
|
||||
|
||||
// Apply age-based pricing
|
||||
const { adultCount = 1, childCount = 0 } = scenario;
|
||||
let totalPassengerFare = 0;
|
||||
|
||||
// Process adults
|
||||
totalPassengerFare += adultCount * baseFareMinor;
|
||||
breakdown.push({
|
||||
step: '2a',
|
||||
description: `Adult passengers: ${adultCount} × ${baseFareMinor}`,
|
||||
amount: adultCount * baseFareMinor,
|
||||
runningTotal: adultCount * baseFareMinor
|
||||
});
|
||||
|
||||
// Process children with age rules
|
||||
if (childCount > 0) {
|
||||
const childRule = (config.ageRules as any[]).find((rule: any) =>
|
||||
rule.pricing_type === PricingType.FREE && rule.max_free_passengers > 0
|
||||
);
|
||||
|
||||
if (childRule) {
|
||||
const freeChildren = Math.min(childCount, childRule.max_free_passengers);
|
||||
const paidChildren = Math.max(0, childCount - freeChildren);
|
||||
|
||||
if (freeChildren > 0) {
|
||||
breakdown.push({
|
||||
step: '2b',
|
||||
description: `Free children: ${freeChildren} × 0 (first ${childRule.max_free_passengers} free)`,
|
||||
amount: 0,
|
||||
runningTotal: totalPassengerFare
|
||||
});
|
||||
}
|
||||
|
||||
if (paidChildren > 0) {
|
||||
const paidChildrenFare = paidChildren * baseFareMinor;
|
||||
totalPassengerFare += paidChildrenFare;
|
||||
breakdown.push({
|
||||
step: '2c',
|
||||
description: `Paid children: ${paidChildren} × ${baseFareMinor}`,
|
||||
amount: paidChildrenFare,
|
||||
runningTotal: totalPassengerFare
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// All children pay
|
||||
const childrenFare = childCount * baseFareMinor;
|
||||
totalPassengerFare += childrenFare;
|
||||
breakdown.push({
|
||||
step: '2b',
|
||||
description: `Child passengers: ${childCount} × ${baseFareMinor}`,
|
||||
amount: childrenFare,
|
||||
runningTotal: totalPassengerFare
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
runningTotal = totalPassengerFare;
|
||||
|
||||
// Apply components in order
|
||||
let componentsTotal = 0;
|
||||
const components = (config.components as any[])
|
||||
.filter((c: any) => c.is_active)
|
||||
.sort((a: any, b: any) => a.apply_order - b.apply_order);
|
||||
|
||||
for (const component of components) {
|
||||
let componentAmount = 0;
|
||||
let baseAmount = runningTotal;
|
||||
|
||||
if (component.applies_to === 'BASE_FARE') {
|
||||
baseAmount = baseFareMinor;
|
||||
} else if (component.applies_to === 'SUBTOTAL') {
|
||||
baseAmount = runningTotal;
|
||||
}
|
||||
|
||||
switch (component.calculation_method) {
|
||||
case 'PERCENTAGE':
|
||||
componentAmount = Math.round(baseAmount * (component.percentage_value || 0));
|
||||
break;
|
||||
case 'MULTIPLIER':
|
||||
componentAmount = Math.round(baseAmount * (component.percentage_value || 0));
|
||||
break;
|
||||
case 'FIXED_AMOUNT':
|
||||
componentAmount = component.value_minor || 0;
|
||||
break;
|
||||
}
|
||||
|
||||
componentsTotal += componentAmount;
|
||||
runningTotal += componentAmount;
|
||||
|
||||
breakdown.push({
|
||||
step: `3${String.fromCharCode(97 + component.apply_order - 1)}`,
|
||||
description: `${component.component_name}: ${component.calculation_method} on ${component.applies_to}`,
|
||||
amount: componentAmount,
|
||||
runningTotal
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
baseFareMinor,
|
||||
componentsTotal,
|
||||
totalBeforeDiscounts: runningTotal,
|
||||
discountsTotal: 0, // TODO: Implement promo code discounts
|
||||
finalTotalMinor: runningTotal,
|
||||
breakdown,
|
||||
currency: 'ETB',
|
||||
calculationTimestamp: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
// Migration Methods
|
||||
async migrateLegacySystem(dto: MigrateLegacyDto) {
|
||||
const results = {
|
||||
scheduleFareRules: 0,
|
||||
segmentFareRules: 0,
|
||||
configurationsCreated: 0,
|
||||
dryRun: dto.dryRun || false
|
||||
};
|
||||
|
||||
if (dto.dryRun) {
|
||||
// Count what would be migrated
|
||||
const scheduleFares = await this.prisma.$queryRaw`
|
||||
SELECT COUNT(*) as count FROM "FareRule" WHERE migrated_to_config_id IS NULL
|
||||
`;
|
||||
|
||||
const segmentFares = await this.prisma.$queryRaw`
|
||||
SELECT COUNT(*) as count FROM "SegmentFareRule" WHERE migrated_to_config_id IS NULL
|
||||
`;
|
||||
|
||||
results.scheduleFareRules = Number((scheduleFares as any[])[0]?.count || 0);
|
||||
results.segmentFareRules = Number((segmentFares as any[])[0]?.count || 0);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Create a migration configuration based on existing rules
|
||||
const migrationConfig: CreateFareConfigurationDto = {
|
||||
name: 'Legacy Migration Configuration',
|
||||
description: 'Automatically migrated from existing fare rules',
|
||||
effectiveDate: new Date().toISOString(),
|
||||
rateRules: [
|
||||
// Default rates based on current system
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 3000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 4000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 5500 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 6000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 6000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 8000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 11000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 12000 },
|
||||
],
|
||||
components: [
|
||||
{
|
||||
componentType: ComponentType.INSURANCE,
|
||||
componentName: 'Travel Insurance',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.02,
|
||||
appliesTo: AppliesTo.BASE_FARE,
|
||||
applyOrder: 1
|
||||
},
|
||||
{
|
||||
componentType: ComponentType.TAX,
|
||||
componentName: 'Government Tax',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.05,
|
||||
appliesTo: AppliesTo.SUBTOTAL,
|
||||
applyOrder: 2
|
||||
}
|
||||
],
|
||||
ageRules: [
|
||||
{
|
||||
ruleName: 'Adult Passengers',
|
||||
minAge: 5,
|
||||
pricingType: PricingType.FULL_FARE
|
||||
},
|
||||
{
|
||||
ruleName: 'Child Passengers (First Free)',
|
||||
minAge: 0,
|
||||
maxAge: 4,
|
||||
pricingType: PricingType.FREE,
|
||||
maxFreePassengers: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const newConfig = await this.createConfiguration(migrationConfig, 'system-migration');
|
||||
results.configurationsCreated = 1;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async createNewFormulaConfiguration(dto: CreateNewFormulaDto) {
|
||||
const config = await this.createConfiguration({
|
||||
name: dto.name,
|
||||
description: dto.description || 'System-generated default configuration',
|
||||
effectiveDate: new Date().toISOString(),
|
||||
rateRules: [
|
||||
// Default rates for all combinations
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 3000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 4000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 5500 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 6000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.VIP_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 8000 },
|
||||
// International rates (2x local)
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 6000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 8000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 11000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 12000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.VIP_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 16000 },
|
||||
],
|
||||
components: [
|
||||
{
|
||||
componentType: ComponentType.INSURANCE,
|
||||
componentName: 'Travel Insurance',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.02,
|
||||
appliesTo: AppliesTo.BASE_FARE,
|
||||
applyOrder: 1
|
||||
},
|
||||
{
|
||||
componentType: ComponentType.SERVICE_CHARGE,
|
||||
componentName: 'Service Charge',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.03,
|
||||
appliesTo: AppliesTo.SUBTOTAL,
|
||||
applyOrder: 2
|
||||
},
|
||||
{
|
||||
componentType: ComponentType.TAX,
|
||||
componentName: 'Government Tax',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.05,
|
||||
appliesTo: AppliesTo.TOTAL,
|
||||
applyOrder: 3
|
||||
}
|
||||
],
|
||||
ageRules: [
|
||||
{
|
||||
ruleName: 'Adult Passengers',
|
||||
minAge: 5,
|
||||
pricingType: PricingType.FULL_FARE
|
||||
},
|
||||
{
|
||||
ruleName: 'Child Passengers (First Free)',
|
||||
minAge: 0,
|
||||
maxAge: 4,
|
||||
pricingType: PricingType.FREE,
|
||||
maxFreePassengers: 1
|
||||
}
|
||||
],
|
||||
isDefault: true
|
||||
}, 'system');
|
||||
|
||||
if (dto.activateImmediately) {
|
||||
await this.activateConfiguration((config as any).id, 'system');
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Feature Management
|
||||
async toggleFeature(dto: ToggleFeatureDto) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO system_features (id, feature_name, is_enabled, config, created_at, updated_at)
|
||||
VALUES (${this.generateId('sf')}, ${dto.featureName}, ${dto.enabled},
|
||||
${JSON.stringify(dto.config || {})}, NOW(), NOW())
|
||||
ON CONFLICT (feature_name) DO UPDATE SET
|
||||
is_enabled = ${dto.enabled},
|
||||
config = ${JSON.stringify(dto.config || {})},
|
||||
updated_at = NOW()
|
||||
`;
|
||||
|
||||
return { success: true, message: `Feature ${dto.featureName} ${dto.enabled ? 'enabled' : 'disabled'}` };
|
||||
});
|
||||
}
|
||||
|
||||
async getFeatureStatus(featureName: string) {
|
||||
const result = await this.prisma.$queryRaw`
|
||||
SELECT * FROM system_features WHERE feature_name = ${featureName}
|
||||
`;
|
||||
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
return { enabled: false, config: {} };
|
||||
}
|
||||
|
||||
const feature = result[0] as any;
|
||||
return {
|
||||
enabled: feature.is_enabled,
|
||||
config: feature.config || {}
|
||||
};
|
||||
}
|
||||
|
||||
async getAuditTrail(configId: string) {
|
||||
return this.prisma.$queryRaw`
|
||||
SELECT * FROM fare_configuration_audit
|
||||
WHERE fare_config_id = ${configId}
|
||||
ORDER BY timestamp DESC
|
||||
`;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
private async createAuditEntry(tx: any, configId: string, action: string, changedBy?: string, changes?: any) {
|
||||
const auditId = this.generateId('fca');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_configuration_audit (
|
||||
id, fare_config_id, action, changed_by, changes, timestamp
|
||||
) VALUES (
|
||||
${auditId}, ${configId}, ${action}, ${changedBy || 'system'},
|
||||
${JSON.stringify(changes || {})}, NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export class DashboardService {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, liveStatus: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } }, take: 1 },
|
||||
ticket: true,
|
||||
tickets: { take: 1 },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
@@ -42,16 +42,16 @@ export class DashboardService {
|
||||
const name = iamRows[0]?.name;
|
||||
firstName = (name?.en ?? name?.am ?? '').split(' ')[0];
|
||||
}
|
||||
const seat = upcomingBooking?.seats[0];
|
||||
const seat = (upcomingBooking as any)?.seats?.[0];
|
||||
|
||||
return {
|
||||
user: { firstName, greetingKey },
|
||||
upcomingTicket: upcomingBooking ? {
|
||||
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
|
||||
from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name,
|
||||
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber,
|
||||
departureAt: upcomingBooking.schedule.departureAt,
|
||||
punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
|
||||
ticketId: (upcomingBooking as any).tickets?.[0]?.id, bookingRef: upcomingBooking.bookingRef,
|
||||
from: (upcomingBooking as any).schedule.originStation.name, to: (upcomingBooking as any).schedule.destinationStation.name,
|
||||
trainName: (upcomingBooking as any).schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber,
|
||||
departureAt: (upcomingBooking as any).schedule.departureAt,
|
||||
punctualityLabel: ((upcomingBooking as any).schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
|
||||
} : null,
|
||||
wallet: wallet ? { balanceMinor: wallet.balanceMinor, currency: wallet.currency } : null,
|
||||
activePromotionsCount: promos,
|
||||
|
||||
@@ -91,6 +91,12 @@ export class ExcessBaggageAgentController {
|
||||
deleteAllowance(@Param('id') id: string) {
|
||||
return this.service.deleteAllowance(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete excess baggage charge (admin only)' })
|
||||
deleteCharge(@Param('id') id: string) {
|
||||
return this.service.deleteCharge(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public pay-by-token routes (passenger self-service) ──────────────────────
|
||||
|
||||
@@ -252,6 +252,15 @@ export class ExcessBaggageService {
|
||||
};
|
||||
}
|
||||
|
||||
// Mark expired charges before fetching
|
||||
await this.prisma.excessBaggageCharge.updateMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
expiresAt: { lt: new Date() },
|
||||
},
|
||||
data: { status: 'EXPIRED' },
|
||||
});
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.excessBaggageCharge.findMany({
|
||||
where,
|
||||
@@ -291,4 +300,12 @@ export class ExcessBaggageService {
|
||||
await this.prisma.baggageAllowance.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async deleteCharge(id: string) {
|
||||
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } });
|
||||
if (!charge) throw new NotFoundException('Charge not found');
|
||||
|
||||
await this.prisma.excessBaggageCharge.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,16 @@ export class FareEngineService {
|
||||
if (originStop.sequence >= destStop.sequence)
|
||||
throw new BadRequestException('Origin must come before destination in the route sequence');
|
||||
|
||||
const legStops = route.stops.filter(
|
||||
s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence,
|
||||
);
|
||||
|
||||
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
|
||||
|
||||
// Calculate distance: distanceKm represents cumulative distance from route origin
|
||||
// For a segment, distance = destination.distanceKm - origin.distanceKm
|
||||
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
|
||||
if (totalDistanceKm < 0 || isNaN(totalDistanceKm))
|
||||
throw new BadRequestException('Invalid distance calculation - check route stop distances');
|
||||
|
||||
// Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate
|
||||
const now = new Date();
|
||||
const [originStation, destStation] = await Promise.all([
|
||||
@@ -64,23 +66,15 @@ export class FareEngineService {
|
||||
|
||||
let baseFarePerPassengerMinor: number;
|
||||
let ratePerKmMinor: number;
|
||||
let totalDistanceKm: number;
|
||||
let fareSource: string;
|
||||
|
||||
if (fareRule) {
|
||||
// Flat fare from FareRule — distance is informational only
|
||||
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
||||
totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
|
||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||
fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE';
|
||||
} else {
|
||||
// Distance × rate fallback
|
||||
const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
|
||||
if (missingDistance.length > 0)
|
||||
throw new BadRequestException(
|
||||
`Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
|
||||
);
|
||||
totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
|
||||
ratePerKmMinor = seatClass.baseFareMinor;
|
||||
baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||
fareSource = 'DISTANCE_RATE';
|
||||
|
||||
@@ -174,13 +174,13 @@ export class FleetController {
|
||||
example: [
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
number: 'A-001',
|
||||
number: 'HSC-0001',
|
||||
sequence: 1,
|
||||
coachTypeId: 'coach-type-uuid',
|
||||
coachType: {
|
||||
id: 'coach-type-uuid',
|
||||
code: 'sleeper',
|
||||
name: 'Sleeper Coach'
|
||||
code: 'HSC',
|
||||
name: 'Hard Seat Coach'
|
||||
},
|
||||
arrangement: '2+2',
|
||||
capacity: 60,
|
||||
@@ -215,13 +215,13 @@ export class FleetController {
|
||||
schema: {
|
||||
example: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
number: 'A-001',
|
||||
number: 'HSC-0001',
|
||||
sequence: 1,
|
||||
coachTypeId: 'coach-type-uuid',
|
||||
coachType: {
|
||||
id: 'coach-type-uuid',
|
||||
code: 'sleeper',
|
||||
name: 'Sleeper Coach'
|
||||
code: 'HSC',
|
||||
name: 'Hard Seat Coach'
|
||||
},
|
||||
arrangement: '2+2',
|
||||
capacity: 60,
|
||||
@@ -257,7 +257,7 @@ export class FleetController {
|
||||
schema: {
|
||||
example: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
number: 'A-001',
|
||||
number: 'HSC-0001',
|
||||
sequence: 1,
|
||||
coachTypeId: 'coach-type-uuid',
|
||||
arrangement: '2+2',
|
||||
@@ -283,7 +283,7 @@ export class FleetController {
|
||||
schema: {
|
||||
example: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
number: 'A-001',
|
||||
number: 'HSC-0001',
|
||||
sequence: 1,
|
||||
coachTypeId: 'coach-type-uuid',
|
||||
arrangement: '2+2',
|
||||
|
||||
@@ -11,7 +11,7 @@ export class CreateTrainDto {
|
||||
}
|
||||
|
||||
export class CreateCoachDto {
|
||||
@ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string;
|
||||
@ApiProperty({ example: 'HSC-0001', description: 'Unique coach number' }) @IsString() number: string;
|
||||
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string;
|
||||
@ApiProperty({ example: '2+2', description: 'Seat arrangement for regular coaches (e.g., "2+2", "3+2"). Ignored for bed coaches.' }) @IsString() arrangement: string;
|
||||
@ApiProperty({ example: 60, description: 'Total seat/bed capacity' }) @IsInt() capacity: number;
|
||||
@@ -53,22 +53,22 @@ export class ListCoachesDto {
|
||||
|
||||
// Legacy DTO types for backward compatibility
|
||||
export class CreateCoachTypeDto {
|
||||
@ApiProperty({ example: 'sleeper' }) @IsString() code: string;
|
||||
@ApiProperty({ example: 'Sleeper Coach' }) @IsString() name: string;
|
||||
@ApiProperty({ example: 'HSC' }) @IsString() code: string;
|
||||
@ApiProperty({ example: 'Hard Seat Coach' }) @IsString() name: string;
|
||||
@IsOptional() @IsString() type?: string;
|
||||
}
|
||||
|
||||
export class UpdateCoachTypeDto {
|
||||
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() code?: string;
|
||||
@ApiPropertyOptional({ example: 'Sleeper Coach' }) @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() type?: string;
|
||||
@ApiPropertyOptional({ example: 'HSC' }) @IsOptional() @IsString() code?: string;
|
||||
@ApiPropertyOptional({ example: 'Hard Seat Coach' }) @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional({ example: 'Regular Seat' }) @IsOptional() @IsString() type?: string;
|
||||
}
|
||||
|
||||
export class CreateClassDto {
|
||||
@ApiProperty({ example: 'coach-type-uuid' }) @IsString() coachTypeId: string;
|
||||
@ApiProperty({ example: 'Economy' }) @IsString() name: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
|
||||
@ApiProperty({ example: 500 }) @IsInt() baseFareMinor: number;
|
||||
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number;
|
||||
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number;
|
||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean;
|
||||
@@ -78,7 +78,7 @@ 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: 5000 }) @IsOptional() @IsInt() baseFareMinor?: number;
|
||||
@ApiPropertyOptional({ example: 500 }) @IsOptional() @IsInt() baseFareMinor?: number;
|
||||
@ApiPropertyOptional({ example: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
|
||||
import { SeatKind } from '@prisma/client';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
|
||||
function parseArrangement(arrangement: string): number[] {
|
||||
@@ -41,7 +42,7 @@ const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = {
|
||||
ECONOMY_BED: 6,
|
||||
};
|
||||
|
||||
// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type
|
||||
// Name-based fallback: checks if 'vip' is present for any bed coach type
|
||||
function detectBedCategory(coachTypeName: string): BedCategory {
|
||||
const name = coachTypeName.toLowerCase();
|
||||
const isBed = name.includes('bed') || name.includes('berth') || name.includes('sleeper') || name.includes('couchette');
|
||||
@@ -201,17 +202,25 @@ export class FleetService {
|
||||
});
|
||||
if (!coachType) throw new NotFoundException('Coach type not found');
|
||||
|
||||
// Check for related records
|
||||
const constraints = [];
|
||||
if (coachType.coaches.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach type. ${coachType.coaches.length} coach(es) are still using this coach type. Please reassign or delete the coaches first.`
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'coach',
|
||||
count: coachType.coaches.length,
|
||||
action: 'reassign' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (coachType.seatClasses.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach type. ${coachType.seatClasses.length} seat class(es) are still using this coach type. Please reassign or delete the seat classes first.`
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'seat class',
|
||||
count: coachType.seatClasses.length,
|
||||
action: 'reassign' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (constraints.length > 0) {
|
||||
throw new DeleteOperationException('Coach Type', coachType.name, constraints);
|
||||
}
|
||||
|
||||
return this.prisma.coachType.delete({ where: { id } });
|
||||
@@ -273,17 +282,19 @@ export class FleetService {
|
||||
});
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
|
||||
// Check for related records
|
||||
const relatedRecords = [
|
||||
...seatClass.fareRules,
|
||||
...seatClass.routeFareRules,
|
||||
...seatClass.segmentFares,
|
||||
];
|
||||
const totalFareRules = seatClass.fareRules.length + seatClass.routeFareRules.length + seatClass.segmentFares.length;
|
||||
const constraints = [];
|
||||
|
||||
if (relatedRecords.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete seat class. ${relatedRecords.length} fare rule(s) are still using this seat class. Please delete the fare rules first.`
|
||||
);
|
||||
if (totalFareRules > 0) {
|
||||
constraints.push({
|
||||
entityName: 'fare rule',
|
||||
count: totalFareRules,
|
||||
action: 'delete' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (constraints.length > 0) {
|
||||
throw new DeleteOperationException('Seat Class', seatClass.name, constraints);
|
||||
}
|
||||
|
||||
return this.prisma.seatClass.delete({ where: { id } });
|
||||
@@ -344,11 +355,20 @@ export class FleetService {
|
||||
include: { schedules: true },
|
||||
});
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
|
||||
const constraints = [];
|
||||
if (train.schedules.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.`
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'schedule',
|
||||
count: train.schedules.length,
|
||||
action: 'delete' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (constraints.length > 0) {
|
||||
throw new DeleteOperationException('Train', `${train.number} (${train.name})`, constraints);
|
||||
}
|
||||
|
||||
return this.prisma.train.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -460,45 +480,54 @@ export class FleetService {
|
||||
include: {
|
||||
bookingSeats: true,
|
||||
blocks: true,
|
||||
ticketSeats: true,
|
||||
tickets: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
|
||||
// Check for active assignments
|
||||
if (coach.assignments.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. This coach is assigned to ${coach.assignments.length} schedule(s). Please remove the assignments first.`
|
||||
);
|
||||
const constraints = [];
|
||||
|
||||
if ((coach as any).assignments.length > 0) {
|
||||
constraints.push({
|
||||
entityName: 'schedule assignment',
|
||||
count: (coach as any).assignments.length,
|
||||
action: 'reassign' as const
|
||||
});
|
||||
}
|
||||
|
||||
// Check for booked seats
|
||||
const bookedSeats = coach.seats.filter(seat => seat.bookingSeats.length > 0);
|
||||
const bookedSeats = (coach as any).seats.filter((seat: any) => seat.bookingSeats.length > 0);
|
||||
if (bookedSeats.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. ${bookedSeats.length} seat(s) have active bookings. Please wait for bookings to complete or cancel them first.`
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'booked seat',
|
||||
count: bookedSeats.length,
|
||||
action: 'complete' as const
|
||||
});
|
||||
}
|
||||
|
||||
// Check for blocked seats
|
||||
const blockedSeats = coach.seats.filter(seat => seat.blocks.length > 0);
|
||||
const blockedSeats = (coach as any).seats.filter((seat: any) => seat.blocks.length > 0);
|
||||
if (blockedSeats.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. ${blockedSeats.length} seat(s) are blocked. Please unblock them first.`
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'blocked seat',
|
||||
count: blockedSeats.length,
|
||||
action: 'delete' as const
|
||||
});
|
||||
}
|
||||
|
||||
// Check for tickets
|
||||
const seatsWithTickets = coach.seats.filter(seat => seat.ticketSeats.length > 0);
|
||||
const seatsWithTickets = (coach as any).seats.filter((seat: any) => seat.tickets.length > 0);
|
||||
if (seatsWithTickets.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. ${seatsWithTickets.length} seat(s) have issued tickets. Please wait for travel completion.`
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'seat with issued ticket',
|
||||
count: seatsWithTickets.length,
|
||||
action: 'complete' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (constraints.length > 0) {
|
||||
throw new DeleteOperationException('Coach', coach.number, constraints);
|
||||
}
|
||||
|
||||
// Delete related seats first (now safe to do)
|
||||
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
||||
|
||||
return this.prisma.coach.delete({ where: { id } });
|
||||
|
||||
@@ -280,7 +280,7 @@ export class NotificationsService {
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
},
|
||||
});
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId } });
|
||||
const ticket = await this.prisma.ticket.findFirst({ where: { bookingId } });
|
||||
|
||||
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
|
||||
const amount = this.formatAmount(booking ?? payload.booking);
|
||||
|
||||
@@ -21,22 +21,152 @@ export class PassengersController {
|
||||
@Get()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'List all passengers with filters (Admin/Agent)',
|
||||
description: 'Returns paginated list of passengers with search filters'
|
||||
summary: 'List all travelers with filters (Admin/Agent)',
|
||||
description: `**Returns paginated list of all travelers in the system**
|
||||
|
||||
---
|
||||
|
||||
### Data Source
|
||||
- Fetches from **TravelerProfile** table (created during booking)
|
||||
- Shows ALL passengers from ALL bookings (including guest bookings)
|
||||
- Each row represents a unique traveler, not a user account
|
||||
|
||||
---
|
||||
|
||||
### Features
|
||||
- Search by name, email, phone
|
||||
- Filter by gender
|
||||
- Date range filtering (createdAt)
|
||||
- Pagination support (page, pageSize)
|
||||
- Includes loyalty and wallet info if linked to user account
|
||||
- Shows booking count per traveler
|
||||
|
||||
---
|
||||
|
||||
### Response Fields
|
||||
- **id**: TravelerProfile ID
|
||||
- **fullName**: Passenger name
|
||||
- **email/phone**: Contact info (from linked user or booking)
|
||||
- **gender**: Male/Female/Other (from Verifayda or manual entry)
|
||||
- **dateOfBirth**: Birth date in YYYY-MM-DD format
|
||||
- **nationality**: Passenger nationality
|
||||
- **faydaVerified**: Whether verified via Verifayda
|
||||
- **loyaltyTier/loyaltyPoints**: If linked to user account
|
||||
- **totalBookings**: Number of bookings
|
||||
- **createdAt**: When traveler was first added to system`
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by name, email, or phone' })
|
||||
@ApiQuery({ name: 'gender', required: false, description: 'Filter by gender (Male, Female, Other)' })
|
||||
@ApiQuery({ name: 'dateFrom', required: false, description: 'Filter by creation date from (YYYY-MM-DD)' })
|
||||
@ApiQuery({ name: 'dateTo', required: false, description: 'Filter by creation date to (YYYY-MM-DD)' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page (default: 20)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Travelers retrieved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
items: [
|
||||
{
|
||||
id: 'uuid-123',
|
||||
fullName: 'Abebe Kebede',
|
||||
email: 'abebe@example.com',
|
||||
phone: '+251911234567',
|
||||
gender: 'Male',
|
||||
dateOfBirth: '1985-03-15',
|
||||
nationality: 'Ethiopian',
|
||||
faydaVerified: true,
|
||||
loyaltyTier: 'SILVER',
|
||||
loyaltyPoints: 1500,
|
||||
totalBookings: 5,
|
||||
createdAt: '2024-01-10T12:00:00.000Z'
|
||||
}
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 150,
|
||||
totalPages: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Travelers retrieved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
items: [
|
||||
{
|
||||
id: 'uuid-123',
|
||||
fullName: 'Abebe Kebede',
|
||||
email: 'abebe@example.com',
|
||||
phone: '+251911234567',
|
||||
gender: 'Male',
|
||||
dateOfBirth: '1985-03-15',
|
||||
nationality: 'Ethiopian',
|
||||
nationalityCode: 'ET',
|
||||
faydaVerified: true,
|
||||
faydaVerifiedAt: '2024-01-15T10:30:00.000Z',
|
||||
passportNumber: null,
|
||||
passportCountry: null,
|
||||
passportExpiryDate: null,
|
||||
idDocumentType: 'NATIONAL_ID',
|
||||
verified: true,
|
||||
lastLoginAt: '2024-01-20T08:15:00.000Z',
|
||||
role: 'PASSENGER',
|
||||
loyalty: {
|
||||
tier: 'SILVER',
|
||||
pointsBalance: 1500,
|
||||
lifetimePoints: 3000
|
||||
},
|
||||
wallet: {
|
||||
balanceMinor: 50000,
|
||||
currency: 'ETB'
|
||||
},
|
||||
loyaltyTier: 'SILVER',
|
||||
loyaltyPoints: 1500,
|
||||
totalBookings: 5,
|
||||
createdAt: '2024-01-10T12:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'uuid-456',
|
||||
fullName: 'Sara Ketsela',
|
||||
email: null,
|
||||
phone: null,
|
||||
gender: 'Female',
|
||||
dateOfBirth: '1990-08-22',
|
||||
nationality: 'Ethiopian',
|
||||
nationalityCode: null,
|
||||
faydaVerified: false,
|
||||
faydaVerifiedAt: null,
|
||||
passportNumber: null,
|
||||
passportCountry: null,
|
||||
passportExpiryDate: null,
|
||||
idDocumentType: null,
|
||||
verified: false,
|
||||
lastLoginAt: null,
|
||||
role: null,
|
||||
loyalty: null,
|
||||
wallet: null,
|
||||
loyaltyTier: 'BRONZE',
|
||||
loyaltyPoints: 0,
|
||||
totalBookings: 1,
|
||||
createdAt: '2024-01-18T14:30:00.000Z'
|
||||
}
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 150,
|
||||
totalPages: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'verified', required: false })
|
||||
@ApiQuery({ name: 'gender', required: false })
|
||||
@ApiQuery({ name: 'nationality', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('verified') verified?: string,
|
||||
@Query('gender') gender?: string,
|
||||
@Query('nationality') nationality?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@@ -44,9 +174,7 @@ export class PassengersController {
|
||||
) {
|
||||
return this.service.findAll({
|
||||
search,
|
||||
verified: verified ? verified === 'true' : undefined,
|
||||
gender,
|
||||
nationality,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: page ? parseInt(page) : 1,
|
||||
|
||||
@@ -8,6 +8,7 @@ export class CreateTravelerProfileDto {
|
||||
@ApiProperty({ example: 'SPOUSE' }) @IsString() relationship: string;
|
||||
@ApiPropertyOptional({ example: '1998-04-01' }) @IsOptional() @IsDateString() dateOfBirth?: string;
|
||||
@ApiPropertyOptional({ example: 'ET-1234-5678' }) @IsOptional() @IsString() nationalId?: string;
|
||||
@ApiPropertyOptional({ example: 'Female' }) @IsOptional() @IsString() gender?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@ import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
interface PassengerFilters {
|
||||
search?: string;
|
||||
verified?: boolean;
|
||||
gender?: string;
|
||||
nationality?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
@@ -33,31 +32,21 @@ export class PassengersService {
|
||||
) {}
|
||||
|
||||
async findAll(filters: PassengerFilters = {}) {
|
||||
const { search, verified, gender, nationality, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const { search, gender, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (search) {
|
||||
where.user = {
|
||||
OR: [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search, mode: 'insensitive' } },
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (verified !== undefined) {
|
||||
where.user = { ...(where.user ?? {}), faydaVerified: verified };
|
||||
where.OR = [
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
{ passenger: { user: { email: { contains: search, mode: 'insensitive' } } } },
|
||||
{ passenger: { user: { phone: { contains: search, mode: 'insensitive' } } } },
|
||||
];
|
||||
}
|
||||
|
||||
if (gender) {
|
||||
where.user = { ...(where.user ?? {}), gender };
|
||||
}
|
||||
|
||||
if (nationality) {
|
||||
where.user = { ...(where.user ?? {}), nationality: { contains: nationality, mode: 'insensitive' } };
|
||||
where.gender = gender;
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
@@ -68,34 +57,43 @@ export class PassengersService {
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.passenger.findMany({
|
||||
this.prisma.travelerProfile.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: true,
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
_count: { select: { bookings: true } },
|
||||
bookings: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: {
|
||||
contactEmail: true,
|
||||
contactPhone: true,
|
||||
seats: { take: 1, orderBy: { id: 'asc' }, select: {
|
||||
passengerName: true, dateOfBirth: true, passportNumber: true,
|
||||
passportCountry: true, idDocumentType: true, verifaydaVerified: true, faydaVerifiedAt: true,
|
||||
}},
|
||||
passenger: {
|
||||
include: {
|
||||
user: true,
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
_count: { select: { bookings: true } },
|
||||
bookings: {
|
||||
take: 1,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
contactPhone: true,
|
||||
contactEmail: true,
|
||||
seats: {
|
||||
take: 1,
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
passengerName: true,
|
||||
passportNumber: true,
|
||||
passportCountry: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.passenger.count({ where }),
|
||||
this.prisma.travelerProfile.count({ where }),
|
||||
]);
|
||||
|
||||
const iamUserIds = items.map(p => p.iamUserId).filter(Boolean) as string[];
|
||||
const iamUserIds = items.map(p => p.passenger?.iamUserId).filter(Boolean) as string[];
|
||||
const iamRows = iamUserIds.length > 0
|
||||
? await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = ANY($1)`,
|
||||
@@ -104,72 +102,51 @@ export class PassengersService {
|
||||
: [];
|
||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||
|
||||
// Collect guest contact details for bulk SavedPassengerProfile lookup
|
||||
const guestContacts = items
|
||||
.filter(p => !(p as any).user && !p.iamUserId)
|
||||
.map(p => (p as any).bookings?.[0])
|
||||
.filter(Boolean);
|
||||
const guestEmails = guestContacts.map((b: any) => b.contactEmail).filter(Boolean) as string[];
|
||||
const guestPhones = guestContacts.map((b: any) => b.contactPhone).filter(Boolean) as string[];
|
||||
|
||||
const savedProfiles = (guestEmails.length || guestPhones.length)
|
||||
? await this.prisma.savedPassengerProfile.findMany({
|
||||
where: { OR: [
|
||||
...(guestEmails.length ? [{ email: { in: guestEmails } }] : []),
|
||||
...(guestPhones.length ? [{ phone: { in: guestPhones } }] : []),
|
||||
]},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Index by email then phone for O(1) lookup
|
||||
const profileByEmail = new Map(savedProfiles.filter(s => s.email).map(s => [s.email!, s]));
|
||||
const profileByPhone = new Map(savedProfiles.filter(s => s.phone).map(s => [s.phone!, s]));
|
||||
|
||||
return {
|
||||
items: items.map(passenger => {
|
||||
const localUser = (passenger as any).user ?? null;
|
||||
const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
|
||||
items: items.map(profile => {
|
||||
const passenger = profile.passenger;
|
||||
const localUser = (passenger as any)?.user ?? null;
|
||||
const iam = passenger?.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
|
||||
const faydaVerified = localUser?.faydaVerified === true
|
||||
|| iam?.metadata?.faydaVerified === true
|
||||
|| iam?.metadata?.faydaVerified === 'true';
|
||||
const guestBooking = !localUser && !iam ? (passenger as any).bookings?.[0] : null;
|
||||
|
||||
// Get additional data from bookings for guest passengers
|
||||
const guestBooking = (passenger as any)?.bookings?.[0] ?? null;
|
||||
const guestSeat = guestBooking?.seats?.[0] ?? null;
|
||||
const savedProfile = guestBooking
|
||||
? (profileByEmail.get(guestBooking.contactEmail) ?? profileByPhone.get(guestBooking.contactPhone) ?? null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: passenger.id,
|
||||
fullName: localUser?.fullName ?? iam?.name?.en ?? iam?.name?.am ?? savedProfile?.passengerName ?? guestSeat?.passengerName ?? null,
|
||||
email: localUser?.email ?? iam?.email ?? savedProfile?.email ?? guestBooking?.contactEmail ?? null,
|
||||
phone: localUser?.phone ?? iam?.phone_number ?? savedProfile?.phone ?? guestBooking?.contactPhone ?? null,
|
||||
gender: localUser?.gender ?? iam?.metadata?.gender ?? null,
|
||||
dateOfBirth: localUser?.dateOfBirth
|
||||
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
|
||||
: (iam?.metadata?.dateOfBirth ?? (savedProfile?.dateOfBirth
|
||||
? new Date(savedProfile.dateOfBirth).toISOString().split('T')[0]
|
||||
: (guestSeat?.dateOfBirth ? new Date(guestSeat.dateOfBirth).toISOString().split('T')[0] : null))),
|
||||
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? savedProfile?.nationality ?? null,
|
||||
id: profile.id,
|
||||
fullName: profile.fullName,
|
||||
email: localUser?.email ?? iam?.email ?? guestBooking?.contactEmail ?? null,
|
||||
phone: localUser?.phone ?? iam?.phone_number ?? guestBooking?.contactPhone ?? null,
|
||||
gender: profile.gender ?? localUser?.gender ?? iam?.metadata?.gender ?? null,
|
||||
dateOfBirth: profile.dateOfBirth
|
||||
? new Date(profile.dateOfBirth).toISOString().split('T')[0]
|
||||
: (localUser?.dateOfBirth
|
||||
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
|
||||
: iam?.metadata?.dateOfBirth ?? null),
|
||||
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null),
|
||||
nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null,
|
||||
faydaVerified,
|
||||
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? guestSeat?.faydaVerifiedAt ?? null,
|
||||
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? savedProfile?.passportNumber ?? guestSeat?.passportNumber ?? null,
|
||||
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? savedProfile?.passportCountry ?? guestSeat?.passportCountry ?? null,
|
||||
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? null,
|
||||
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? guestSeat?.passportNumber ?? null,
|
||||
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? guestSeat?.passportCountry ?? null,
|
||||
passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null,
|
||||
idDocumentType: savedProfile?.idDocumentType ?? guestSeat?.idDocumentType ?? null,
|
||||
idDocumentType: profile.nationalId ? 'NATIONAL_ID' : null,
|
||||
verified: faydaVerified,
|
||||
lastLoginAt: localUser?.lastLoginAt ?? null,
|
||||
role: localUser?.role ?? null,
|
||||
loyalty: passenger.loyalty
|
||||
loyalty: passenger?.loyalty
|
||||
? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 }
|
||||
: null,
|
||||
wallet: (passenger as any).wallet
|
||||
wallet: (passenger as any)?.wallet
|
||||
? { balanceMinor: (passenger as any).wallet.balanceMinor, currency: (passenger as any).wallet.currency ?? 'ETB' }
|
||||
: null,
|
||||
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
||||
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
||||
totalBookings: passenger._count.bookings,
|
||||
createdAt: passenger.createdAt,
|
||||
loyaltyTier: passenger?.loyalty?.tier || 'BRONZE',
|
||||
loyaltyPoints: passenger?.loyalty?.pointsBalance || 0,
|
||||
totalBookings: passenger?._count?.bookings || 0,
|
||||
createdAt: profile.createdAt,
|
||||
};
|
||||
}),
|
||||
meta: {
|
||||
@@ -299,8 +276,13 @@ export class PassengersService {
|
||||
createTravelerProfile(dto: CreateTravelerProfileDto) {
|
||||
return this.prisma.travelerProfile.create({
|
||||
data: {
|
||||
...dto,
|
||||
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null
|
||||
passengerId: dto.passengerId,
|
||||
fullName: dto.fullName,
|
||||
relationship: dto.relationship,
|
||||
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null,
|
||||
nationalId: dto.nationalId || null,
|
||||
gender: dto.gender || null,
|
||||
notes: dto.notes || null,
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -440,9 +422,21 @@ export class PassengersService {
|
||||
}
|
||||
|
||||
async deletePassenger(id: string) {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: true
|
||||
}
|
||||
});
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
|
||||
// Check usage before allowing deletion
|
||||
const usage = await this.checkPassengerUsage(id);
|
||||
if (usage.isInUse && usage.constraints) {
|
||||
const passengerName = (passenger as any).user?.fullName || `Passenger ${id.slice(-8)}`;
|
||||
throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }),
|
||||
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }),
|
||||
@@ -470,14 +464,15 @@ export class PassengersService {
|
||||
this.prisma.walletAccount.findUnique({ where: { passengerId: id } }),
|
||||
]);
|
||||
|
||||
const usage = [];
|
||||
if (bookingCount > 0) usage.push(`${bookingCount} booking(s)`);
|
||||
if (loyaltyAccount) usage.push('Loyalty account');
|
||||
if (walletAccount) usage.push('Wallet account');
|
||||
const constraints = [];
|
||||
if (bookingCount > 0) constraints.push({ entityName: 'booking', count: bookingCount, action: 'complete' as const });
|
||||
if (loyaltyAccount) constraints.push({ entityName: 'loyalty account', count: 1, action: 'delete' as const });
|
||||
if (walletAccount) constraints.push({ entityName: 'wallet account', count: 1, action: 'delete' as const });
|
||||
|
||||
return {
|
||||
isInUse: usage.length > 0,
|
||||
affectedModules: usage,
|
||||
isInUse: constraints.length > 0,
|
||||
affectedModules: constraints.map(c => `${c.count} ${c.entityName}${c.count > 1 ? 's' : ''}`),
|
||||
constraints
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
@@ -123,6 +124,16 @@ export class PaymentsController {
|
||||
return this.service.addPaymentMethod(dto);
|
||||
}
|
||||
|
||||
@Patch("methods/:id")
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Update a payment method configuration (admin only)",
|
||||
})
|
||||
updateMethod(@Param("id") id: string, @Body() dto: Partial<AddPaymentMethodDto>) {
|
||||
return this.service.updatePaymentMethod(id, dto);
|
||||
}
|
||||
|
||||
@Get("methods")
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -473,6 +473,24 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
async updatePaymentMethod(id: string, dto: Partial<AddPaymentMethodDto>) {
|
||||
const existing = await this.prisma.paymentMethod.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('Payment method not found');
|
||||
|
||||
const updateData: any = {};
|
||||
if (dto.displayName !== undefined) updateData.displayName = dto.displayName;
|
||||
if (dto.region !== undefined) updateData.region = dto.region as unknown as PaymentRegion;
|
||||
if (dto.currency !== undefined) updateData.currency = dto.currency;
|
||||
if (dto.providerId !== undefined) updateData.providerId = dto.providerId;
|
||||
if (dto.enabled !== undefined) updateData.enabled = dto.enabled;
|
||||
if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder;
|
||||
|
||||
return this.prisma.paymentMethod.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
|
||||
return this.prisma.paymentMethod.findMany({
|
||||
where: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
@Injectable()
|
||||
export class RoutesService {
|
||||
@@ -93,8 +94,28 @@ export class RoutesService {
|
||||
}
|
||||
|
||||
async deleteRoute(id: string) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id } });
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
schedules: true,
|
||||
stops: true
|
||||
}
|
||||
});
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
|
||||
const constraints = [];
|
||||
if (route.schedules.length > 0) {
|
||||
constraints.push({
|
||||
entityName: 'schedule',
|
||||
count: route.schedules.length,
|
||||
action: 'delete' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (constraints.length > 0) {
|
||||
throw new DeleteOperationException('Route', `${route.code} (${route.name})`, constraints);
|
||||
}
|
||||
|
||||
await this.prisma.route.delete({ where: { id } });
|
||||
return { deleted: true, id };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
@@ -13,7 +15,7 @@ export class SchedulesService {
|
||||
) { }
|
||||
|
||||
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
|
||||
const startDate = new Date(dto.startDateTime);
|
||||
const startDate = parseEthiopianTime(dto.startDateTime);
|
||||
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
|
||||
const errors: string[] = [];
|
||||
const scheduleIds: string[] = [];
|
||||
@@ -66,8 +68,8 @@ export class SchedulesService {
|
||||
const where: any = {};
|
||||
|
||||
if (dto.date) {
|
||||
const date = new Date(dto.date);
|
||||
const nextDay = new Date(date.getTime() + 86_400_000);
|
||||
const date = parseEthiopianTime(dto.date);
|
||||
const nextDay = startOfNextDayEAT(date);
|
||||
where.departureAt = { gte: date, lt: nextDay };
|
||||
}
|
||||
if (dto.routeId) where.routeId = dto.routeId;
|
||||
@@ -93,8 +95,9 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
async createSchedule(dto: CreateScheduleDto) {
|
||||
const dep = new Date(dto.departureAt);
|
||||
const arr = new Date(dto.arrivalAt);
|
||||
// Parse dates in local Ethiopian time (EAT - UTC+3)
|
||||
const dep = parseEthiopianTime(dto.departureAt);
|
||||
const arr = parseEthiopianTime(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
const route = await this.prisma.route.findUnique({
|
||||
@@ -105,10 +108,9 @@ export class SchedulesService {
|
||||
if (!route.active) throw new BadRequestException('Route is not active');
|
||||
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
||||
|
||||
const depDate = new Date(dep);
|
||||
depDate.setHours(0, 0, 0, 0);
|
||||
const nextDay = new Date(depDate);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
// Check for existing schedule on the same day (local Ethiopian time)
|
||||
const depDate = startOfDayEAT(dep);
|
||||
const nextDay = startOfNextDayEAT(dep);
|
||||
|
||||
const existingSchedule = await this.prisma.trainSchedule.findFirst({
|
||||
where: { trainId: dto.trainId, routeId: dto.routeId, departureAt: { gte: depDate, lt: nextDay } },
|
||||
@@ -240,8 +242,9 @@ export class SchedulesService {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const dep = new Date(dto.departureAt);
|
||||
const arr = new Date(dto.arrivalAt);
|
||||
// Parse dates in local Ethiopian time (EAT - UTC+3)
|
||||
const dep = parseEthiopianTime(dto.departureAt);
|
||||
const arr = parseEthiopianTime(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
const route = await this.prisma.route.findUnique({
|
||||
@@ -308,16 +311,59 @@ export class SchedulesService {
|
||||
async deleteSchedule(id: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { bookings: true } } },
|
||||
include: {
|
||||
_count: { select: { bookings: true } },
|
||||
train: true,
|
||||
originStation: true,
|
||||
destinationStation: true
|
||||
},
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const constraints = [];
|
||||
if ((schedule as any)._count.bookings > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete schedule. It has ${(schedule as any)._count.bookings} booking(s). Cancel all bookings before deleting.`,
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'booking',
|
||||
count: (schedule as any)._count.bookings,
|
||||
action: 'cancel' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (constraints.length > 0) {
|
||||
const scheduleName = `${schedule.train.number} (${schedule.originStation.name} → ${schedule.destinationStation.name})`;
|
||||
throw new DeleteOperationException('Schedule', scheduleName, constraints);
|
||||
}
|
||||
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.menuItem.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
// Delete travel packages that reference this schedule (required fields cannot be nulled)
|
||||
// First get packages that reference this schedule
|
||||
const packagesToDelete = await this.prisma.travelPackage.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ outboundScheduleId: id },
|
||||
{ returnScheduleId: id }
|
||||
]
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
// Delete price tiers first (they have foreign key to packages)
|
||||
if (packagesToDelete.length > 0) {
|
||||
const packageIds = packagesToDelete.map(p => p.id);
|
||||
await this.prisma.packagePriceTier.deleteMany({
|
||||
where: { packageId: { in: packageIds } }
|
||||
});
|
||||
|
||||
// Now delete the packages
|
||||
await this.prisma.travelPackage.deleteMany({
|
||||
where: { id: { in: packageIds } }
|
||||
});
|
||||
}
|
||||
return this.prisma.trainSchedule.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -338,8 +384,8 @@ export class SchedulesService {
|
||||
return this.prisma.tripStopTime.update({
|
||||
where: { scheduleId_sequence: { scheduleId, sequence } },
|
||||
data: {
|
||||
plannedArrivalAt: dto.plannedArrivalAt ? new Date(dto.plannedArrivalAt) : undefined,
|
||||
plannedDepartureAt: dto.plannedDepartureAt ? new Date(dto.plannedDepartureAt) : undefined,
|
||||
plannedArrivalAt: dto.plannedArrivalAt ? parseEthiopianTime(dto.plannedArrivalAt) : undefined,
|
||||
plannedDepartureAt: dto.plannedDepartureAt ? parseEthiopianTime(dto.plannedDepartureAt) : undefined,
|
||||
status: dto.status,
|
||||
},
|
||||
include: { station: true },
|
||||
@@ -353,8 +399,8 @@ export class SchedulesService {
|
||||
...rest,
|
||||
tripId: scheduleId,
|
||||
nationality,
|
||||
validFrom: new Date(validFrom),
|
||||
validUntil: validUntil ? new Date(validUntil) : null,
|
||||
validFrom: parseEthiopianTime(validFrom),
|
||||
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
@@ -371,8 +417,8 @@ export class SchedulesService {
|
||||
...rest,
|
||||
...(scheduleId !== undefined && { tripId: scheduleId }),
|
||||
...(nationality !== undefined && { nationality }),
|
||||
...(validFrom && { validFrom: new Date(validFrom) }),
|
||||
...(validUntil !== undefined && { validUntil: validUntil ? new Date(validUntil) : null }),
|
||||
...(validFrom && { validFrom: parseEthiopianTime(validFrom) }),
|
||||
...(validUntil !== undefined && { validUntil: validUntil ? parseEthiopianTime(validUntil) : null }),
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
@@ -390,8 +436,8 @@ export class SchedulesService {
|
||||
return this.prisma.segmentFareRule.create({
|
||||
data: {
|
||||
...rest,
|
||||
validFrom: new Date(validFrom),
|
||||
validUntil: validUntil ? new Date(validUntil) : null,
|
||||
validFrom: parseEthiopianTime(validFrom),
|
||||
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true, route: true },
|
||||
});
|
||||
@@ -415,8 +461,8 @@ export class SchedulesService {
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
validFrom: validFrom ? new Date(validFrom) : undefined,
|
||||
validUntil: validUntil ? new Date(validUntil) : null,
|
||||
validFrom: validFrom ? parseEthiopianTime(validFrom) : undefined,
|
||||
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true, route: true },
|
||||
});
|
||||
@@ -523,8 +569,8 @@ export class SchedulesService {
|
||||
const updateData: any = {};
|
||||
|
||||
if (dto.departureAt || dto.arrivalAt) {
|
||||
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
|
||||
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
|
||||
const dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt);
|
||||
const arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
|
||||
updateData.departureAt = dep;
|
||||
updateData.arrivalAt = arr;
|
||||
|
||||
@@ -33,7 +33,7 @@ export class SeatsController {
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: "Get seat map filtered by coach type",
|
||||
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`,
|
||||
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches. Use journeyDirection to filter seat holds (OUTBOUND vs RETURN for round-trip bookings).`,
|
||||
})
|
||||
@ApiParam({ name: "scheduleId", description: "TrainSchedule UUID" })
|
||||
@ApiQuery({
|
||||
@@ -42,6 +42,23 @@ export class SeatsController {
|
||||
description:
|
||||
"Filter by CoachType UUID — returns all coaches of that type (e.g. all Economy coaches)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "journeyDirection",
|
||||
required: false,
|
||||
enum: ['ONE_WAY', 'OUTBOUND', 'RETURN'],
|
||||
description:
|
||||
"Journey direction for round-trip bookings. Filters seat holds to show only conflicting holds. Use OUTBOUND for outbound leg, RETURN for return leg. Defaults to ONE_WAY (shows all holds).",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "originStationId",
|
||||
required: false,
|
||||
description: "Origin station UUID for segment-specific seat availability",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "destinationStationId",
|
||||
required: false,
|
||||
description: "Destination station UUID for segment-specific seat availability",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description:
|
||||
@@ -50,8 +67,17 @@ export class SeatsController {
|
||||
getSeatMap(
|
||||
@Param("scheduleId") scheduleId: string,
|
||||
@Query("coachTypeId") coachTypeId?: string,
|
||||
@Query("journeyDirection") journeyDirection?: string,
|
||||
@Query("originStationId") originStationId?: string,
|
||||
@Query("destinationStationId") destinationStationId?: string,
|
||||
) {
|
||||
return this.service.getSeatMap(scheduleId, coachTypeId);
|
||||
return this.service.getSeatMap(
|
||||
scheduleId,
|
||||
coachTypeId,
|
||||
journeyDirection as any,
|
||||
originStationId,
|
||||
destinationStationId
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hold / Release ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { IsString, IsArray, ValidateNested } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export enum JourneyDirection {
|
||||
ONE_WAY = 'ONE_WAY',
|
||||
OUTBOUND = 'OUTBOUND',
|
||||
RETURN = 'RETURN'
|
||||
}
|
||||
|
||||
export class PassengerSeatDto {
|
||||
@ApiProperty({ example: 'passenger-uuid', description: 'Passenger UUID' })
|
||||
@IsString() passengerId: string;
|
||||
@@ -20,6 +26,15 @@ export class HoldSeatsDto {
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: JourneyDirection,
|
||||
example: JourneyDirection.OUTBOUND,
|
||||
description: 'Journey direction for round-trip bookings. ONE_WAY for single journeys, OUTBOUND/RETURN for round-trip legs. Allows same seats to be held for different directions.'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(JourneyDirection)
|
||||
journeyDirection?: JourneyDirection;
|
||||
|
||||
@ApiProperty({
|
||||
type: [PassengerSeatDto],
|
||||
description: 'One entry per passenger. Each passenger is assigned exactly one seat. Duplicate passengerId or seatId within the same request is rejected.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { HoldSeatsDto } from './seats.dto';
|
||||
import { HoldSeatsDto, JourneyDirection } from './seats.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
@@ -13,7 +13,7 @@ export class SeatsService {
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
async getSeatMap(scheduleId: string, coachTypeId?: string) {
|
||||
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { originStationId: true, destinationStationId: true },
|
||||
@@ -37,7 +37,13 @@ export class SeatsService {
|
||||
});
|
||||
|
||||
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, schedule.originStationId, schedule.destinationStationId);
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(
|
||||
scheduleId,
|
||||
allSeatIds,
|
||||
originStationId ?? schedule.originStationId,
|
||||
destinationStationId ?? schedule.destinationStationId,
|
||||
journeyDirection
|
||||
);
|
||||
|
||||
return {
|
||||
coaches: assignments.map((a) => {
|
||||
@@ -179,6 +185,7 @@ export class SeatsService {
|
||||
seatIds: string[],
|
||||
originStationId?: string,
|
||||
destinationStationId?: string,
|
||||
journeyDirection?: JourneyDirection,
|
||||
): Promise<Map<string, string>> {
|
||||
const statusMap = new Map<string, string>();
|
||||
if (seatIds.length === 0) return statusMap;
|
||||
@@ -211,9 +218,13 @@ export class SeatsService {
|
||||
select: { seatIds: true, createdBy: true },
|
||||
});
|
||||
|
||||
const reqDirection = journeyDirection || JourneyDirection.ONE_WAY;
|
||||
|
||||
for (const hold of activeHolds) {
|
||||
let holdFrom: number | undefined;
|
||||
let holdTo: number | undefined;
|
||||
let holdDirection = JourneyDirection.ONE_WAY;
|
||||
|
||||
try {
|
||||
if (hold.createdBy?.trimStart().startsWith('{')) {
|
||||
const meta = JSON.parse(hold.createdBy);
|
||||
@@ -221,16 +232,26 @@ export class SeatsService {
|
||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
||||
holdFrom = seqOf(meta.originStationId);
|
||||
holdTo = seqOf(meta.destinationStationId);
|
||||
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
for (const seatId of hold.seatIds) {
|
||||
if (!seatIds.includes(seatId)) continue;
|
||||
if (reqFrom !== undefined && reqTo !== undefined && holdFrom !== undefined && holdTo !== undefined) {
|
||||
if (holdFrom < reqTo && reqFrom < holdTo) statusMap.set(seatId, 'HELD');
|
||||
} else {
|
||||
statusMap.set(seatId, 'HELD');
|
||||
}
|
||||
|
||||
// Check leg overlap
|
||||
const legsOverlap =
|
||||
reqFrom === undefined || reqTo === undefined ||
|
||||
holdFrom === undefined || holdTo === undefined ||
|
||||
(holdFrom < reqTo && reqFrom < holdTo);
|
||||
|
||||
if (!legsOverlap) continue;
|
||||
|
||||
// Check direction conflict
|
||||
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
|
||||
if (!directionsConflict) continue;
|
||||
|
||||
statusMap.set(seatId, 'HELD');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +287,36 @@ export class SeatsService {
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two journey directions conflict (should not be allowed simultaneously)
|
||||
* For round-trip bookings: OUTBOUND and RETURN should NOT conflict on same schedule
|
||||
*/
|
||||
private checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean {
|
||||
// OUTBOUND and RETURN are allowed simultaneously (round-trip on different schedules)
|
||||
if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) ||
|
||||
(current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same directions conflict (e.g., two OUTBOUND or two RETURN bookings)
|
||||
if (current === existing) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ONE_WAY conflicts with other ONE_WAY bookings only
|
||||
if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings)
|
||||
if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Default: no conflict
|
||||
return false;
|
||||
}
|
||||
|
||||
async holdSeats(dto: HoldSeatsDto) {
|
||||
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||
const seatIds = dto.passengers.map(p => p.seatId);
|
||||
@@ -307,19 +358,16 @@ export class SeatsService {
|
||||
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED' || s.status === 'HELD');
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED');
|
||||
if (blocked.length > 0)
|
||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
|
||||
|
||||
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
|
||||
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId: dto.scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
const seqOf = (stationId: string) =>
|
||||
stopTimes.find(s => s.stationId === stationId)?.sequence;
|
||||
|
||||
const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence;
|
||||
const reqFrom = seqOf(dto.originStationId);
|
||||
const reqTo = seqOf(dto.destinationStationId);
|
||||
|
||||
@@ -328,57 +376,46 @@ export class SeatsService {
|
||||
if (reqFrom >= reqTo)
|
||||
throw new BadRequestException('Origin must come before destination');
|
||||
|
||||
// ── Check existing holds for overlap ────────────────────────────────────
|
||||
const currentDirection = dto.journeyDirection || JourneyDirection.ONE_WAY;
|
||||
const activeHolds = await tx.seatHold.findMany({
|
||||
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
||||
select: { seatIds: true, createdBy: true },
|
||||
});
|
||||
|
||||
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[]; legUnknown: boolean }[] = [];
|
||||
for (const h of activeHolds) {
|
||||
const rawSeatIds = h.seatIds as string[];
|
||||
let holdDirection = JourneyDirection.ONE_WAY;
|
||||
let holdFrom = 0, holdTo = Number.MAX_SAFE_INTEGER;
|
||||
let passengerIds: string[] = [];
|
||||
let legUnknown = true;
|
||||
|
||||
try {
|
||||
if (h.createdBy?.trimStart().startsWith('{')) {
|
||||
const meta = JSON.parse(h.createdBy);
|
||||
const holdFrom = seqOf(meta.originStationId);
|
||||
const holdTo = seqOf(meta.destinationStationId);
|
||||
parsedHolds.push({
|
||||
seatIds: rawSeatIds,
|
||||
from: holdFrom ?? 0,
|
||||
to: holdTo ?? Number.MAX_SAFE_INTEGER,
|
||||
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
|
||||
legUnknown: holdFrom === undefined || holdTo === undefined,
|
||||
});
|
||||
} else {
|
||||
// Legacy plain-string createdBy — can't determine leg; block conservatively.
|
||||
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
|
||||
holdFrom = seqOf(meta.originStationId) ?? 0;
|
||||
holdTo = seqOf(meta.destinationStationId) ?? Number.MAX_SAFE_INTEGER;
|
||||
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
|
||||
passengerIds = (meta.passengers ?? []).map((p: any) => p.passengerId);
|
||||
legUnknown = !meta.originStationId || !meta.destinationStationId;
|
||||
}
|
||||
} catch {
|
||||
// Malformed JSON — block conservatively.
|
||||
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
for (const { passengerId, seatId } of dto.passengers) {
|
||||
for (const hold of parsedHolds) {
|
||||
const legsOverlap = hold.legUnknown || (hold.from < reqTo && reqFrom < hold.to);
|
||||
if (!legsOverlap) continue;
|
||||
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
|
||||
if (!legsOverlap) continue;
|
||||
|
||||
if (hold.seatIds.includes(seatId)) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seatId]} is already held for this leg`,
|
||||
);
|
||||
const directionsConflict = this.checkDirectionConflict(currentDirection, holdDirection);
|
||||
if (!directionsConflict) continue;
|
||||
|
||||
for (const { passengerId, seatId } of dto.passengers) {
|
||||
if (rawSeatIds.includes(seatId)) {
|
||||
throw new ConflictException(`Seat ${seatLabelById[seatId]} is already held for this leg`);
|
||||
}
|
||||
|
||||
if (!hold.legUnknown && hold.passengerIds.includes(passengerId)) {
|
||||
throw new ConflictException(
|
||||
`Passenger already holds a seat on this journey leg`,
|
||||
);
|
||||
if (!legUnknown && passengerIds.includes(passengerId)) {
|
||||
throw new ConflictException(`Passenger already holds a seat on this journey leg`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check confirmed JourneySegments for overlap ──────────────────────────
|
||||
const bookedSegments = await tx.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId: dto.scheduleId,
|
||||
@@ -392,25 +429,19 @@ export class SeatsService {
|
||||
if (!seg.seatId) continue;
|
||||
const segFrom = seqOf(seg.departureStationId);
|
||||
const segTo = seqOf(seg.arrivalStationId);
|
||||
// If stations can't be resolved, assume overlap (conservative) to prevent double-booking.
|
||||
const overlaps = (segFrom === undefined || segTo === undefined)
|
||||
? true
|
||||
: segFrom < reqTo && reqFrom < segTo;
|
||||
const overlaps = (segFrom === undefined || segTo === undefined) ? true : segFrom < reqTo && reqFrom < segTo;
|
||||
if (overlaps) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
|
||||
);
|
||||
throw new ConflictException(`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`);
|
||||
}
|
||||
}
|
||||
|
||||
const holdMeta = {
|
||||
originStationId: dto.originStationId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
journeyDirection: currentDirection,
|
||||
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
|
||||
};
|
||||
|
||||
// Mark seats as HELD so the status check catches them immediately on any
|
||||
// subsequent hold attempt (avoids relying solely on the SeatHold table scan).
|
||||
await tx.seat.updateMany({
|
||||
where: { id: { in: seatIds } },
|
||||
data: { status: 'HELD' },
|
||||
@@ -418,10 +449,10 @@ export class SeatsService {
|
||||
|
||||
return tx.seatHold.create({
|
||||
data: {
|
||||
scheduleId: dto.scheduleId,
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerId: dto.passengers[0].passengerId,
|
||||
seatIds,
|
||||
createdBy: JSON.stringify(holdMeta),
|
||||
createdBy: JSON.stringify(holdMeta),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ export class CreateStationDto {
|
||||
@ApiProperty({ example: 'ADD' }) @IsString() code: string;
|
||||
@ApiProperty({ example: 'Addis Ababa' }) @IsString() name: string;
|
||||
@ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() timezone?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() countryCode?: string;
|
||||
@ApiPropertyOptional({ example: 9.0054 }) @IsOptional() @IsNumber() lat?: number;
|
||||
@ApiPropertyOptional({ example: 38.7636 }) @IsOptional() @IsNumber() lng?: number;
|
||||
|
||||
@@ -68,8 +68,8 @@ export class StationsService {
|
||||
|
||||
async update(id: string, dto: Partial<CreateStationDto>) {
|
||||
const oldStation = await this.findOne(id);
|
||||
const { code, name, city, timezone, lat, lng } = dto;
|
||||
const data: any = { code, name, city, timezone, lat, lng };
|
||||
const { code, name, city, lat, lng } = dto;
|
||||
const data: any = { code, name, city, lat, lng };
|
||||
if ('countryCode' in dto) data.countryCode = (dto as any).countryCode;
|
||||
if ('sequence' in dto) data.sequence = (dto as any).sequence;
|
||||
if ('isOperational' in dto) data.isOperational = (dto as any).isOperational;
|
||||
|
||||
@@ -39,6 +39,7 @@ export class TicketsController {
|
||||
@ApiQuery({ name: 'arrivalDate', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'coachId', required: false })
|
||||
@ApiQuery({ name: 'skip', required: false })
|
||||
@ApiQuery({ name: 'take', required: false })
|
||||
listTickets(
|
||||
@@ -49,6 +50,7 @@ export class TicketsController {
|
||||
@Query('arrivalDate') arrivalDate?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('coachId') coachId?: string,
|
||||
@Query('skip') skip?: string,
|
||||
@Query('take') take?: string,
|
||||
) {
|
||||
@@ -60,6 +62,7 @@ export class TicketsController {
|
||||
arrivalDate,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
coachId,
|
||||
skip: skip ? parseInt(skip) : 0,
|
||||
take: take ? parseInt(take) : 50,
|
||||
});
|
||||
@@ -83,6 +86,31 @@ export class TicketsController {
|
||||
return this.service.getByRef(ref);
|
||||
}
|
||||
|
||||
@Post('scan-board/:qrCodeOrRef')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Scan QR code or booking ref and automatically board ticket',
|
||||
description: 'Scans ticket QR code or booking reference and automatically boards the passenger. Handles errors like expired tickets, already used tickets, etc. Designed for mobile boarding interface.'
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['validatorId'],
|
||||
properties: {
|
||||
validatorId: { type: 'string', example: 'agent-uuid' },
|
||||
gateId: { type: 'string', example: 'gate-01' },
|
||||
},
|
||||
},
|
||||
})
|
||||
scanAndBoard(
|
||||
@Param('qrCodeOrRef') qrCodeOrRef: string,
|
||||
@Body('validatorId') validatorId: string,
|
||||
@Body('gateId') gateId?: string,
|
||||
) {
|
||||
return this.service.scanAndBoard(qrCodeOrRef, validatorId, gateId);
|
||||
}
|
||||
|
||||
@Post(':bookingRef/validate')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
|
||||
@@ -23,12 +23,13 @@ export class TicketsService {
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; skip: number; take: number }) {
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
|
||||
const where: any = {};
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ bookingRef: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ barcodePayload: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ passengerName: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
@@ -53,6 +54,12 @@ export class TicketsService {
|
||||
...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||
};
|
||||
}
|
||||
// if (filters.coachId) {
|
||||
// where.seat = {
|
||||
// coachId: filters.coachId
|
||||
// };
|
||||
// }
|
||||
|
||||
const [tickets, total] = await Promise.all([
|
||||
this.prisma.ticket.findMany({
|
||||
where,
|
||||
@@ -61,19 +68,20 @@ export class TicketsService {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
passenger: { select: { id: true, iamUserId: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
seat: { include: { coach: { include: { coachType: true } } } },
|
||||
} as any,
|
||||
skip: filters.skip,
|
||||
take: filters.take,
|
||||
orderBy: { issuedAt: 'desc' },
|
||||
}),
|
||||
}) as any,
|
||||
this.prisma.ticket.count({ where }),
|
||||
]);
|
||||
|
||||
const iamUserIds = tickets.map(t => t.booking.passenger?.iamUserId).filter(Boolean) as string[];
|
||||
const iamUserIds = tickets.map((t: any) => t.booking?.passenger?.iamUserId).filter(Boolean) as string[];
|
||||
const iamRows = iamUserIds.length > 0
|
||||
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
|
||||
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
|
||||
@@ -83,34 +91,49 @@ export class TicketsService {
|
||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||
|
||||
return {
|
||||
items: tickets.map((t) => {
|
||||
const iam = t.booking.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
|
||||
items: tickets.map((t: any) => {
|
||||
const iam = t.booking?.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
|
||||
const passengerInfo = iam
|
||||
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
|
||||
: { fullName: 'Guest', email: t.booking.contactEmail, phone: null };
|
||||
: { fullName: 'Guest', email: t.booking?.contactEmail, phone: null };
|
||||
return {
|
||||
id: t.id,
|
||||
ticketNumber: t.barcodePayload,
|
||||
bookingRef: t.bookingRef,
|
||||
passengerName: t.passengerName,
|
||||
leg: t.leg,
|
||||
booking: {
|
||||
id: t.booking.id,
|
||||
bookingRef: t.booking.bookingRef,
|
||||
status: t.booking.status,
|
||||
bookingType: t.booking.bookingType,
|
||||
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
|
||||
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
|
||||
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
|
||||
totalMinor: t.booking.totalMinor,
|
||||
currency: t.booking.currency,
|
||||
displayCurrency: t.booking.displayCurrency,
|
||||
displayTotalMinor: t.booking.displayTotalMinor,
|
||||
id: t.booking?.id,
|
||||
bookingRef: t.booking?.bookingRef,
|
||||
status: t.booking?.status,
|
||||
bookingType: t.booking?.bookingType,
|
||||
returnLegStatus: t.booking?.returnLegStatus ?? null,
|
||||
outboundBoardedAt: t.booking?.outboundBoardedAt ?? null,
|
||||
returnBoardedAt: t.booking?.returnBoardedAt ?? null,
|
||||
totalMinor: t.booking?.totalMinor,
|
||||
currency: t.booking?.currency,
|
||||
displayCurrency: t.booking?.displayCurrency,
|
||||
displayTotalMinor: t.booking?.displayTotalMinor,
|
||||
passenger: passengerInfo,
|
||||
contactEmail: t.booking.contactEmail,
|
||||
contactPhone: t.booking.contactPhone,
|
||||
returnSchedule: (t.booking as any).returnSchedule ?? null,
|
||||
contactEmail: t.booking?.contactEmail,
|
||||
contactPhone: t.booking?.contactPhone,
|
||||
returnSchedule: t.booking?.returnSchedule ?? null,
|
||||
seats: t.booking?.seats ?? [],
|
||||
},
|
||||
schedule: t.booking.schedule,
|
||||
seat: t.booking.seats[0]?.seat,
|
||||
schedule: t.booking?.schedule,
|
||||
seat: t.seat ? {
|
||||
id: t.seat.id,
|
||||
seatNumber: t.seat.seatNumber,
|
||||
coach: t.seat.coach ? {
|
||||
id: t.seat.coach.id,
|
||||
number: t.seat.coach.number,
|
||||
coachType: t.seat.coach.coachType ? {
|
||||
id: t.seat.coach.coachType.id,
|
||||
name: t.seat.coach.coachType.name,
|
||||
type: t.seat.coach.coachType.type,
|
||||
} : null,
|
||||
} : null,
|
||||
} : null,
|
||||
status: t.status,
|
||||
validatedAt: t.validatedAt,
|
||||
createdAt: t.issuedAt,
|
||||
@@ -136,7 +159,7 @@ export class TicketsService {
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
// No payment intent record at all
|
||||
if (!booking.paymentIntent) {
|
||||
if (!(booking as any).paymentIntent) {
|
||||
throw new HttpException(
|
||||
{ status: 'error', message: 'Payment not completed', code: 400 },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
@@ -144,21 +167,19 @@ export class TicketsService {
|
||||
}
|
||||
|
||||
// Payment intent exists but not yet succeeded
|
||||
if (booking.paymentIntent.status !== 'SUCCEEDED') {
|
||||
if ((booking as any).paymentIntent.status !== 'SUCCEEDED') {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Payment not completed',
|
||||
code: 400,
|
||||
detail: `Payment status: ${booking.paymentIntent.status}`,
|
||||
detail: `Payment status: ${(booking as any).paymentIntent.status}`,
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
// Booking not in CONFIRMED state — could be a webhook delivery failure.
|
||||
// If the intent already SUCCEEDED but the booking is still PENDING_PAYMENT,
|
||||
// self-heal here rather than rejecting a legitimately paid booking.
|
||||
if (booking.status !== 'CONFIRMED') {
|
||||
if (booking.status === 'PENDING_PAYMENT') {
|
||||
this.logger.warn(
|
||||
@@ -181,154 +202,282 @@ export class TicketsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Build a compact multi-leg payload for the QR so gate scanners see all legs
|
||||
const legSummary = this.buildLegSummary(booking);
|
||||
const qrData = JSON.stringify({
|
||||
ref: booking.bookingRef,
|
||||
type: booking.bookingType,
|
||||
legs: legSummary,
|
||||
});
|
||||
const qrPayload = await QRCode.toDataURL(qrData);
|
||||
const barcodePayload = `${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
|
||||
// Delete existing tickets if any
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
|
||||
const ticket = await this.prisma.ticket.upsert({
|
||||
where: { bookingId },
|
||||
update: { qrPayload, barcodePayload },
|
||||
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
|
||||
});
|
||||
// Generate one ticket per unique passenger (grouped by passengerName)
|
||||
const tickets = [];
|
||||
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
|
||||
|
||||
// Group seats by passenger
|
||||
const passengerSeatsMap = new Map<string, any[]>();
|
||||
for (const bookingSeat of (booking as any).seats) {
|
||||
const key = bookingSeat.passengerName;
|
||||
if (!passengerSeatsMap.has(key)) {
|
||||
passengerSeatsMap.set(key, []);
|
||||
}
|
||||
passengerSeatsMap.get(key)!.push(bookingSeat);
|
||||
}
|
||||
|
||||
// Create one ticket per passenger
|
||||
for (const [passengerName, passengerSeats] of passengerSeatsMap.entries()) {
|
||||
// Use first seat for primary data
|
||||
const primarySeat = passengerSeats[0];
|
||||
|
||||
// Build passenger QR data with all legs included
|
||||
const qrData = JSON.stringify({
|
||||
ref: booking.bookingRef,
|
||||
type: booking.bookingType,
|
||||
passenger: passengerName,
|
||||
seats: passengerSeats.map(ps => ({
|
||||
seat: ps.seat.seatNumber,
|
||||
coach: ps.seat.coach.number,
|
||||
leg: ps.leg || 1,
|
||||
scheduleId: ps.scheduleId || booking.scheduleId,
|
||||
})),
|
||||
});
|
||||
const qrPayload = await QRCode.toDataURL(qrData);
|
||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
|
||||
|
||||
const ticket = await this.prisma.ticket.create({
|
||||
data: {
|
||||
bookingId,
|
||||
bookingRef: booking.bookingRef,
|
||||
passengerName,
|
||||
seatId: primarySeat.seatId,
|
||||
leg: primarySeat.leg || 1,
|
||||
scheduleId: primarySeat.scheduleId || booking.scheduleId,
|
||||
qrPayload,
|
||||
barcodePayload,
|
||||
} as any,
|
||||
});
|
||||
tickets.push(ticket);
|
||||
}
|
||||
|
||||
// Block all seats across all legs
|
||||
const seatIds = booking.seats.map(bs => bs.seatId);
|
||||
for (const seatId of seatIds) {
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } });
|
||||
await this.prisma.seatBlock.create({
|
||||
data: { seatId, reason: `Booked in ticket ${ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
|
||||
data: { seatId, reason: `Booked in tickets ${tickets.map(t => t.id).join(', ')}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
return { ...ticket, legs: legSummary };
|
||||
return { tickets, totalTickets: tickets.length };
|
||||
}
|
||||
|
||||
private buildLegSummary(booking: any) {
|
||||
const seatsByLeg = new Map<number, any[]>();
|
||||
for (const bs of booking.seats) {
|
||||
const leg = bs.leg ?? 1;
|
||||
if (!seatsByLeg.has(leg)) seatsByLeg.set(leg, []);
|
||||
seatsByLeg.get(leg)!.push(bs);
|
||||
}
|
||||
return Array.from(seatsByLeg.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([leg, seats]) => ({
|
||||
leg,
|
||||
scheduleId: (seats[0] as any).scheduleId ?? booking.scheduleId,
|
||||
passengers: seats.map(bs => ({
|
||||
name: bs.passengerName,
|
||||
category: bs.passengerCategory,
|
||||
coach: bs.seat?.coach?.number,
|
||||
seat: bs.seat?.seatNumber,
|
||||
fareMinor: bs.fareMinor,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async updateSeats(bookingId: string, newSeatIds: string[]) {
|
||||
async updateSeats(bookingId: string, seatIds: string[]) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { seats: true, ticket: true },
|
||||
include: { tickets: true, seats: true } as any
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if (!booking.ticket) throw new BadRequestException('No ticket found for this booking');
|
||||
|
||||
// Remove old seat blocks
|
||||
const oldSeatIds = booking.seats.map(bs => bs.seatId);
|
||||
for (const seatId of oldSeatIds) {
|
||||
for (const ticket of (booking as any).tickets) {
|
||||
await this.prisma.seatBlock.deleteMany({
|
||||
where: {
|
||||
seatId,
|
||||
reason: { contains: booking.ticket.id }
|
||||
}
|
||||
where: { reason: { contains: ticket.id } }
|
||||
});
|
||||
}
|
||||
|
||||
// Remove old booking seats
|
||||
// Delete existing tickets
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
|
||||
// Update booking seats
|
||||
await this.prisma.bookingSeat.deleteMany({ where: { bookingId } });
|
||||
|
||||
// Create new seat blocks
|
||||
for (const seatId of newSeatIds) {
|
||||
await this.prisma.seatBlock.create({
|
||||
data: {
|
||||
seatId,
|
||||
reason: `Permanently booked in ticket ${booking.ticket.id}`,
|
||||
blockedBy: 'SYSTEM',
|
||||
approvedBy: 'SYSTEM',
|
||||
}
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
// Create new booking seats (placeholder with minimal data)
|
||||
for (let i = 0; i < newSeatIds.length; i++) {
|
||||
|
||||
// Create new seat assignments (simplified)
|
||||
for (let i = 0; i < seatIds.length; i++) {
|
||||
await this.prisma.bookingSeat.create({
|
||||
data: {
|
||||
bookingId,
|
||||
seatId: newSeatIds[i],
|
||||
seatId: seatIds[i],
|
||||
passengerName: `Passenger ${i + 1}`,
|
||||
}
|
||||
leg: 1
|
||||
} as any
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, updatedSeats: newSeatIds.length };
|
||||
// Generate new tickets
|
||||
return this.generate(bookingId);
|
||||
}
|
||||
|
||||
async getByMerchantOrderId(merchantOrderId: string) {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
const paymentIntent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { merchantOrderId },
|
||||
select: { bookingId: true },
|
||||
include: { booking: { include: { tickets: true } as any } } as any
|
||||
});
|
||||
if (!intent) throw new NotFoundException(`No payment intent found for order ${merchantOrderId}`);
|
||||
if (!paymentIntent) throw new NotFoundException('Payment not found');
|
||||
|
||||
const booking = (paymentIntent as any).booking;
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
return this.getByRef(booking.bookingRef);
|
||||
}
|
||||
|
||||
async getByRef(ref: string) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: intent.bookingId },
|
||||
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
|
||||
where: { bookingRef: ref },
|
||||
include: {
|
||||
tickets: true,
|
||||
schedule: { include: { originStation: true, destinationStation: true } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } }
|
||||
} as any
|
||||
});
|
||||
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
|
||||
const seat = booking.seats[0];
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
return {
|
||||
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||
fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name,
|
||||
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
|
||||
coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName,
|
||||
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
|
||||
barcodePayload: booking.ticket.barcodePayload,
|
||||
booking: {
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
bookingType: booking.bookingType,
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency
|
||||
},
|
||||
tickets: (booking as any).tickets,
|
||||
schedule: (booking as any).schedule,
|
||||
returnSchedule: (booking as any).returnSchedule,
|
||||
seats: (booking as any).seats
|
||||
};
|
||||
}
|
||||
|
||||
async getByRef(bookingRef: string) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
ticket: true
|
||||
},
|
||||
});
|
||||
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
|
||||
const seat = booking.seats[0];
|
||||
return {
|
||||
id: booking.ticket.id,
|
||||
bookingId: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
fromStationName: booking.schedule.originStation.name,
|
||||
toStationName: booking.schedule.destinationStation.name,
|
||||
departureAt: booking.schedule.departureAt,
|
||||
trainName: booking.schedule.train.name,
|
||||
coachLabel: seat?.seat.coach.number,
|
||||
seatLabel: seat?.seat.seatNumber,
|
||||
passengerName: seat?.passengerName,
|
||||
priceMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
qrPayload: booking.ticket.qrPayload,
|
||||
barcodePayload: booking.ticket.barcodePayload
|
||||
};
|
||||
async scanAndBoard(qrCodeOrRef: string, validatorId: string, gateId?: string) {
|
||||
try {
|
||||
// Extract booking reference from QR code if it's JSON
|
||||
let bookingRef = qrCodeOrRef;
|
||||
try {
|
||||
const qrData = JSON.parse(qrCodeOrRef);
|
||||
if (qrData.ref) {
|
||||
bookingRef = qrData.ref;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, treat as booking reference
|
||||
}
|
||||
|
||||
// Get booking and ticket info
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { 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) {
|
||||
throw new NotFoundException('Ticket not found');
|
||||
}
|
||||
|
||||
if (booking.status !== 'CONFIRMED') {
|
||||
throw new BadRequestException('Ticket is not confirmed');
|
||||
}
|
||||
|
||||
const ticket = (booking as any).tickets[0];
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('No ticket found for this booking');
|
||||
}
|
||||
|
||||
// Check if ticket date matches today
|
||||
const today = new Date();
|
||||
const todayDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format
|
||||
|
||||
if ((booking as any).schedule?.departureAt) {
|
||||
const departureDate = new Date((booking as any).schedule.departureAt);
|
||||
const departureDateStr = departureDate.toISOString().split('T')[0];
|
||||
|
||||
// Check if ticket is for today
|
||||
if (departureDateStr !== todayDateStr) {
|
||||
if (departureDateStr < todayDateStr) {
|
||||
throw new BadRequestException('Ticket has expired - departure date has passed');
|
||||
} else {
|
||||
throw new BadRequestException('Ticket is for a future date - cannot board early');
|
||||
}
|
||||
}
|
||||
|
||||
// Additional check: ticket expires 4 hours after departure time
|
||||
const departureTime = new Date((booking as any).schedule.departureAt);
|
||||
const expiryTime = new Date(departureTime.getTime() + 4 * 60 * 60 * 1000); // 4 hours after departure
|
||||
if (today > expiryTime) {
|
||||
throw new BadRequestException('Ticket has expired - boarding window closed');
|
||||
}
|
||||
}
|
||||
|
||||
// Use existing validation logic to handle round trips properly
|
||||
const result = await this.validate(bookingRef, validatorId, gateId);
|
||||
|
||||
// Get seat information
|
||||
const seatInfo = (booking as any).seats[0];
|
||||
const seatNumber = seatInfo?.seat?.seatNumber || 'N/A';
|
||||
const coachNumber = seatInfo?.seat?.coach?.number || 'N/A';
|
||||
|
||||
// Send notifications after successful boarding
|
||||
await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
|
||||
boarding: {
|
||||
ticketId: ticket.id,
|
||||
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'}`,
|
||||
seat: seatNumber,
|
||||
coach: coachNumber,
|
||||
trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A',
|
||||
departureTime: (booking as any).schedule?.departureAt,
|
||||
boardedAt: result.validatedAt,
|
||||
leg: result.leg || 'OUTBOUND',
|
||||
bookingType: booking.bookingType,
|
||||
isRoundTrip: booking.bookingType === 'ROUND_TRIP' || booking.bookingType === 'ROUND_TRIP_TRANSIT',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// Return structured error for the UI
|
||||
const errorMessage = error instanceof Error ? error.message : 'Boarding failed';
|
||||
const errorCode = error instanceof BadRequestException ? 'VALIDATION_ERROR'
|
||||
: error instanceof NotFoundException ? 'NOT_FOUND'
|
||||
: 'SYSTEM_ERROR';
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async sendBoardingNotifications(booking: any, ticket: any, leg: string) {
|
||||
try {
|
||||
const passengerName = booking.seats?.[0]?.passengerName || ticket.passengerName || 'Passenger';
|
||||
const contactEmail = booking.contactEmail;
|
||||
const contactPhone = booking.contactPhone;
|
||||
|
||||
if (!contactEmail && !contactPhone) {
|
||||
this.logger.warn(`No contact details found for booking ${booking.bookingRef}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const routeInfo = `${booking.schedule?.originStation?.name} → ${booking.schedule?.destinationStation?.name}`;
|
||||
const trainName = booking.schedule?.train?.name || booking.schedule?.train?.number;
|
||||
const departureTime = booking.schedule?.departureAt ? new Date(booking.schedule.departureAt).toLocaleString() : 'N/A';
|
||||
const legText = leg === 'RETURN' ? 'Return' : 'Outbound';
|
||||
|
||||
// Use the existing sendBoardingPassNotification method
|
||||
await this.notifications.sendBoardingPassNotification({
|
||||
passengerId: booking.passengerId || null,
|
||||
contactEmail,
|
||||
contactPhone,
|
||||
bookingRef: booking.bookingRef,
|
||||
leg,
|
||||
booking,
|
||||
ticket,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
this.logger.error('Error sending boarding notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
|
||||
@@ -343,11 +492,11 @@ export class TicketsService {
|
||||
const resolvedValidatorId = validatorId || 'BACKOFFICE';
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
|
||||
const ticket = await this.prisma.ticket.findFirst({ where: { bookingId: booking.id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
const type = booking.bookingType;
|
||||
const now = new Date();
|
||||
const now = new Date();
|
||||
|
||||
// ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
|
||||
if (type === 'ONE_WAY') {
|
||||
@@ -389,92 +538,34 @@ export class TicketsService {
|
||||
if (resolvedLeg === 'OUTBOUND') {
|
||||
if ((booking as any).outboundBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
|
||||
throw new BadRequestException('Outbound leg already used');
|
||||
throw new BadRequestException('Outbound leg already validated');
|
||||
}
|
||||
bookingData.outboundBoardedAt = now;
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
} else if (resolvedLeg === 'RETURN') {
|
||||
if ((booking as any).returnBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
|
||||
throw new BadRequestException('Return leg already used');
|
||||
throw new BadRequestException('Return leg already validated');
|
||||
}
|
||||
bookingData.returnBoardedAt = now;
|
||||
} else {
|
||||
throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN');
|
||||
}
|
||||
const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
|
||||
const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
|
||||
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
|
||||
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
|
||||
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
|
||||
|
||||
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
if (!ticket.validatedAt) {
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
}
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
}
|
||||
|
||||
// ── ROUND_TRIP_TRANSIT — leg=OUTBOUND_LEG1|OUTBOUND_LEG2|RETURN_LEG1|RETURN_LEG2
|
||||
if (type === 'ROUND_TRIP_TRANSIT') {
|
||||
const validLegs = ['OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'];
|
||||
const resolvedLeg = (leg ?? '').toUpperCase();
|
||||
if (!validLegs.includes(resolvedLeg)) {
|
||||
throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`);
|
||||
}
|
||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
|
||||
if (logs.some(l => l.leg === resolvedLeg)) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
throw new BadRequestException(`${resolvedLeg} already validated`);
|
||||
}
|
||||
const bookingData: Record<string, any> = {};
|
||||
if (resolvedLeg.startsWith('OUTBOUND') && !logs.some(l => l.leg?.startsWith('OUTBOUND') && l.status === 'APPROVED')) {
|
||||
bookingData.outboundBoardedAt = now;
|
||||
}
|
||||
if (resolvedLeg.startsWith('RETURN') && !logs.some(l => l.leg?.startsWith('RETURN') && l.status === 'APPROVED')) {
|
||||
bookingData.returnBoardedAt = now;
|
||||
}
|
||||
const allOutboundDone = ['OUTBOUND_LEG1','OUTBOUND_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
|
||||
const allReturnDone = ['RETURN_LEG1','RETURN_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
|
||||
if (allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'BOTH_USED';
|
||||
else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
|
||||
else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY';
|
||||
if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
}
|
||||
|
||||
// Fallback for unknown booking types — single scan
|
||||
if (ticket.validatedAt) {
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
|
||||
this.fireBoardingPassNotification(booking, ticket, null);
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: now };
|
||||
throw new BadRequestException(`Unsupported booking type: ${type}`);
|
||||
}
|
||||
|
||||
/** Fire-and-forget — enriches booking with schedule+seats then sends email+SMS boarding pass. */
|
||||
private fireBoardingPassNotification(booking: any, ticket: any, leg: string | null): void {
|
||||
this.prisma.booking.findUnique({
|
||||
where: { id: booking.id },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
passenger: { select: { id: true, iamUserId: true } },
|
||||
},
|
||||
}).then((enriched) => {
|
||||
if (!enriched) return;
|
||||
this.notifications.sendBoardingPassNotification({
|
||||
passengerId: enriched.passenger?.iamUserId ?? enriched.passenger?.id ?? null,
|
||||
contactEmail: (enriched as any).contactEmail ?? null,
|
||||
contactPhone: (enriched as any).contactPhone ?? null,
|
||||
bookingRef: enriched.bookingRef,
|
||||
leg,
|
||||
booking: enriched,
|
||||
ticket,
|
||||
}).catch(() => null);
|
||||
}).catch(() => null);
|
||||
private async fireBoardingPassNotification(booking: any, ticket: any, leg: string | null) {
|
||||
// TODO: Implement notification logic
|
||||
console.log(`Boarding pass notification for booking ${booking.bookingRef}, leg: ${leg}`);
|
||||
}
|
||||
|
||||
async getValidationLogs(ticketId: string) {
|
||||
@@ -488,111 +579,57 @@ export class TicketsService {
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: { scheduleId: tripId, status: 'CONFIRMED' },
|
||||
include: {
|
||||
ticket: true,
|
||||
tickets: true,
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
passenger: { select: { id: true, iamUserId: true } },
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
return bookings.map((b) => ({
|
||||
bookingRef: b.bookingRef,
|
||||
ticketId: b.ticket?.id,
|
||||
passengerName: b.seats[0]?.passengerName,
|
||||
seatLabel: b.seats[0]?.seat.seatNumber,
|
||||
coachLabel: b.seats[0]?.seat.coach.number,
|
||||
qrPayload: b.ticket?.qrPayload,
|
||||
ticketId: (b as any).tickets?.[0]?.id,
|
||||
passengerName: (b as any).seats[0]?.passengerName,
|
||||
seatLabel: (b as any).seats[0]?.seat.seatNumber,
|
||||
coachLabel: (b as any).seats[0]?.seat.coach.number,
|
||||
qrPayload: (b as any).tickets?.[0]?.qrPayload,
|
||||
status: b.status,
|
||||
bookingType: b.bookingType,
|
||||
returnLegStatus: (b as any).returnLegStatus ?? null,
|
||||
validatedAt: b.ticket?.validatedAt,
|
||||
validatedAt: (b as any).tickets?.[0]?.validatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async validateOfflineBatch(validations: OfflineValidation[]) {
|
||||
const results = { success: 0, failed: 0, duplicate: 0, errors: [] as string[] };
|
||||
const processedRefs = new Set<string>();
|
||||
|
||||
for (const v of validations) {
|
||||
const offlineLeg = v.leg;
|
||||
const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
|
||||
if (processedRefs.has(dedupKey)) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
processedRefs.add(dedupKey);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const validation of validations) {
|
||||
try {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
|
||||
if (!booking) {
|
||||
results.failed++;
|
||||
results.errors.push(`Booking ${v.bookingRef} not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
|
||||
if (!ticket) {
|
||||
results.failed++;
|
||||
results.errors.push(`Ticket for ${v.bookingRef} not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP' &&
|
||||
booking.bookingType !== 'TRANSIT' && booking.bookingType !== 'ROUND_TRIP_TRANSIT') {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// For multi-leg bookings, check per-leg duplication
|
||||
const isMultiLeg = booking.bookingType === 'ROUND_TRIP' ||
|
||||
booking.bookingType === 'TRANSIT' ||
|
||||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
if (isMultiLeg && offlineLeg) {
|
||||
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
|
||||
if (existingLogs.some(l => l.leg === offlineLeg)) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
|
||||
const result = await this.validate(
|
||||
validation.bookingRef,
|
||||
validation.validatorId,
|
||||
validation.gateId,
|
||||
validation.leg
|
||||
);
|
||||
results.push({
|
||||
bookingRef: validation.bookingRef,
|
||||
success: true,
|
||||
result
|
||||
});
|
||||
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: {
|
||||
ticketId: ticket.id,
|
||||
validatorId: v.validatorId,
|
||||
gateId: v.gateId,
|
||||
leg: v.leg ?? null,
|
||||
status: 'APPROVED',
|
||||
validatedAt: new Date(v.validatedAt),
|
||||
} as any,
|
||||
} catch (error) {
|
||||
results.push({
|
||||
bookingRef: validation.bookingRef,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Validation failed'
|
||||
});
|
||||
|
||||
// update boarding timestamps for multi-leg bookings
|
||||
const isMultiLegBooking = booking.bookingType === 'ROUND_TRIP' ||
|
||||
booking.bookingType === 'TRANSIT' ||
|
||||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
if (isMultiLegBooking && offlineLeg) {
|
||||
const bookingData: Record<string, any> = {};
|
||||
const isOutbound = (offlineLeg as string) === 'OUTBOUND' || (offlineLeg as string) === 'OUTBOUND_LEG1' || (offlineLeg as string) === 'LEG1';
|
||||
const isReturn = (offlineLeg as string) === 'RETURN' || (offlineLeg as string) === 'RETURN_LEG1' || (offlineLeg as string) === 'RETURN_LEG2';
|
||||
if (isOutbound && !(booking as any).outboundBoardedAt) bookingData.outboundBoardedAt = new Date(v.validatedAt);
|
||||
if (isReturn && !(booking as any).returnBoardedAt) bookingData.returnBoardedAt = new Date(v.validatedAt);
|
||||
if (Object.keys(bookingData).length) {
|
||||
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
}
|
||||
}
|
||||
|
||||
results.success++;
|
||||
} catch (err) {
|
||||
results.failed++;
|
||||
results.errors.push(`Error processing ${v.bookingRef}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
return {
|
||||
processed: results.length,
|
||||
successful: results.filter(r => r.success).length,
|
||||
failed: results.filter(r => !r.success).length,
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
@@ -618,4 +655,4 @@ export class TicketsService {
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user