mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
494 lines
20 KiB
TypeScript
494 lines
20 KiB
TypeScript
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
import { PrismaService } from '../../common/prisma.service';
|
|
import { CurrencyService } from '../currency/currency.service';
|
|
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
|
|
import { Currency } from '@prisma/client';
|
|
import { BookingsService } from '../bookings/bookings.service';
|
|
import { GuestBookingService } from '../bookings/guest-booking.service';
|
|
|
|
/** Package-specific fare rules */
|
|
const PKG_MAX_ADULTS = 5;
|
|
const PKG_MAX_CHILDREN = 2;
|
|
const PKG_CHILD_FARE_RATIO = 0.1;
|
|
|
|
function calculatePackageFareBreakdown(
|
|
priceMinor: number,
|
|
isRoundTrip: boolean,
|
|
adultCount: number,
|
|
childCount: number,
|
|
) {
|
|
const multiplier = isRoundTrip ? 2 : 1;
|
|
const adultFareMinor = priceMinor * multiplier;
|
|
const childFareMinor = Math.round(adultFareMinor * PKG_CHILD_FARE_RATIO);
|
|
const totalMinor = adultCount * adultFareMinor + childCount * childFareMinor;
|
|
return { adultFareMinor, childFareMinor, totalMinor, multiplier };
|
|
}
|
|
|
|
function deriveAge(dateOfBirth: string | Date): number {
|
|
const today = new Date();
|
|
const dob = new Date(dateOfBirth);
|
|
let age = today.getFullYear() - dob.getFullYear();
|
|
if (today < new Date(today.getFullYear(), dob.getMonth(), dob.getDate())) age--;
|
|
return age;
|
|
}
|
|
|
|
function generateRef(): string {
|
|
return 'PKG-' + Array.from({ length: 6 }, () =>
|
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)],
|
|
).join('');
|
|
}
|
|
|
|
@Injectable()
|
|
export class PackagesService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly currencyService: CurrencyService,
|
|
private readonly bookingsService: BookingsService,
|
|
private readonly guestBookingService: GuestBookingService,
|
|
) {}
|
|
|
|
async getBookingContext(packageId: string, tierId: string, adultCount: number, childCount = 0) {
|
|
const pkg = await this.prisma.travelPackage.findUnique({
|
|
where: { id: packageId },
|
|
include: {
|
|
priceTiers: true,
|
|
outboundSchedule: {
|
|
include: {
|
|
originStation: true,
|
|
destinationStation: true,
|
|
coachAssignments: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } },
|
|
},
|
|
},
|
|
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
|
},
|
|
});
|
|
if (!pkg || pkg.status !== 'ACTIVE') throw new NotFoundException('Package not available');
|
|
|
|
const tier = pkg.priceTiers.find(t => t.id === tierId);
|
|
if (!tier) throw new NotFoundException('Price tier not found');
|
|
|
|
if (adultCount < 1) throw new BadRequestException('At least one adult passenger required');
|
|
if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`);
|
|
if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`);
|
|
|
|
const passengerCount = adultCount + childCount;
|
|
const remaining = tier.availableSeats - tier.bookedSeats;
|
|
if (passengerCount > remaining)
|
|
throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`);
|
|
|
|
const isRoundTrip = !!pkg.returnScheduleId;
|
|
const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown(
|
|
tier.priceMinor, isRoundTrip, adultCount, childCount,
|
|
);
|
|
|
|
// Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches
|
|
let seatClassId: string | null = null;
|
|
let coachTypeId: string | null = null;
|
|
for (const a of pkg.outboundSchedule.coachAssignments) {
|
|
const sc = a.coach.coachType?.seatClasses?.find(
|
|
(s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) ||
|
|
tier.seatType.toLowerCase().includes(s.name.toLowerCase()),
|
|
);
|
|
if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; }
|
|
}
|
|
if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) {
|
|
const first = pkg.outboundSchedule.coachAssignments[0];
|
|
coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null;
|
|
}
|
|
|
|
return {
|
|
packageId: pkg.id,
|
|
packageName: pkg.name,
|
|
priceTierId: tier.id,
|
|
tierLabel: tier.label,
|
|
seatType: tier.seatType,
|
|
seatClassId,
|
|
coachTypeId,
|
|
adultCount,
|
|
childCount,
|
|
passengerCount,
|
|
isRoundTrip,
|
|
pricePerAdultMinor: adultFareMinor,
|
|
pricePerChildMinor: childFareMinor,
|
|
childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`,
|
|
maxAdults: PKG_MAX_ADULTS,
|
|
maxChildren: PKG_MAX_CHILDREN,
|
|
totalMinor,
|
|
currency: tier.currency,
|
|
remainingSeats: remaining,
|
|
outboundSchedule: {
|
|
scheduleId: pkg.outboundScheduleId,
|
|
originStationId: pkg.originStationId,
|
|
destinationStationId: pkg.destinationStationId,
|
|
departureAt: pkg.outboundSchedule.departureAt,
|
|
arrivalAt: pkg.outboundSchedule.arrivalAt,
|
|
originStation: pkg.outboundSchedule.originStation,
|
|
destinationStation: pkg.outboundSchedule.destinationStation,
|
|
},
|
|
returnSchedule: pkg.returnSchedule ? {
|
|
scheduleId: pkg.returnScheduleId,
|
|
originStationId: pkg.destinationStationId,
|
|
destinationStationId: pkg.originStationId,
|
|
departureAt: pkg.returnSchedule.departureAt,
|
|
arrivalAt: pkg.returnSchedule.arrivalAt,
|
|
originStation: pkg.returnSchedule.destinationStation,
|
|
destinationStation: pkg.returnSchedule.originStation,
|
|
} : null,
|
|
includedServices: pkg.includedServices,
|
|
busTransferIncluded: pkg.busTransferIncluded,
|
|
busTransferRoute: pkg.busTransferRoute,
|
|
};
|
|
}
|
|
|
|
async createInquiry(dto: CreateInquiryDto) {
|
|
return this.prisma.packageInquiry.create({
|
|
data: {
|
|
packageId: dto.packageId,
|
|
priceTierId: dto.priceTierId ?? null,
|
|
travelerCount: dto.travelerCount,
|
|
contactName: dto.contactName,
|
|
contactEmail: dto.contactEmail ?? null,
|
|
contactPhone: dto.contactPhone ?? null,
|
|
notes: dto.notes ?? null,
|
|
enquiredAt: new Date(),
|
|
},
|
|
include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true } } },
|
|
});
|
|
}
|
|
|
|
async listInquiries({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) {
|
|
const where: any = {};
|
|
if (packageId) where.packageId = packageId;
|
|
if (status) where.status = status;
|
|
const skip = (page - 1) * pageSize;
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.packageInquiry.findMany({
|
|
where,
|
|
include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true, priceMinor: true } } },
|
|
orderBy: { enquiredAt: 'desc' },
|
|
skip,
|
|
take: pageSize,
|
|
}),
|
|
this.prisma.packageInquiry.count({ where }),
|
|
]);
|
|
return { items, total, page, pageSize };
|
|
}
|
|
|
|
async updateInquiryStatus(id: string, status: string) {
|
|
const inquiry = await this.prisma.packageInquiry.findUnique({ where: { id } });
|
|
if (!inquiry) throw new NotFoundException('Inquiry not found');
|
|
return this.prisma.packageInquiry.update({ where: { id }, data: { status } });
|
|
}
|
|
|
|
async deleteInquiry(id: string) {
|
|
const inquiry = await this.prisma.packageInquiry.findUnique({ where: { id } });
|
|
if (!inquiry) throw new NotFoundException('Inquiry not found');
|
|
await this.prisma.packageInquiry.delete({ where: { id } });
|
|
return { deleted: true };
|
|
}
|
|
|
|
listActive() {
|
|
const now = new Date();
|
|
return this.prisma.travelPackage.findMany({
|
|
where: { status: 'ACTIVE', validUntil: { gte: now } },
|
|
include: {
|
|
priceTiers: true,
|
|
outboundSchedule: { include: { originStation: true, destinationStation: true } },
|
|
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
|
},
|
|
orderBy: { validFrom: 'asc' },
|
|
}).then(pkgs => pkgs.map(p => ({ ...p, journeyType: p.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' })));
|
|
}
|
|
|
|
async getById(id: string) {
|
|
const pkg = await this.prisma.travelPackage.findUnique({
|
|
where: { id },
|
|
include: {
|
|
priceTiers: true,
|
|
outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } },
|
|
returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
|
|
},
|
|
});
|
|
if (!pkg) throw new NotFoundException('Package not found');
|
|
return { ...pkg, journeyType: pkg.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' };
|
|
}
|
|
|
|
create(dto: CreatePackageDto) {
|
|
return this.prisma.travelPackage.create({
|
|
data: {
|
|
code: dto.code,
|
|
name: dto.name,
|
|
description: dto.description,
|
|
outboundScheduleId: dto.outboundScheduleId,
|
|
returnScheduleId: dto.returnScheduleId,
|
|
originStationId: dto.originStationId,
|
|
destinationStationId: dto.destinationStationId,
|
|
boardingTime: new Date(dto.boardingTime),
|
|
departureTime: new Date(dto.departureTime),
|
|
arrivalTime: new Date(dto.arrivalTime),
|
|
totalCapacity: dto.totalCapacity,
|
|
coachConfiguration: dto.coachConfiguration,
|
|
includedServices: dto.includedServices,
|
|
busTransferIncluded: dto.busTransferIncluded ?? false,
|
|
busTransferRoute: dto.busTransferRoute,
|
|
validFrom: new Date(dto.validFrom),
|
|
validUntil: new Date(dto.validUntil),
|
|
status: 'DRAFT',
|
|
priceTiers: { create: dto.priceTiers },
|
|
},
|
|
include: { priceTiers: true },
|
|
});
|
|
}
|
|
|
|
async update(id: string, dto: Partial<CreatePackageDto>) {
|
|
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
|
|
if (!pkg) throw new NotFoundException('Package not found');
|
|
return this.prisma.travelPackage.update({
|
|
where: { id },
|
|
data: {
|
|
...(dto.code && { code: dto.code }),
|
|
...(dto.name && { name: dto.name }),
|
|
...(dto.description !== undefined && { description: dto.description }),
|
|
...(dto.outboundScheduleId && { outboundScheduleId: dto.outboundScheduleId }),
|
|
...(dto.returnScheduleId && { returnScheduleId: dto.returnScheduleId }),
|
|
...(dto.originStationId && { originStationId: dto.originStationId }),
|
|
...(dto.destinationStationId && { destinationStationId: dto.destinationStationId }),
|
|
...(dto.boardingTime && { boardingTime: new Date(dto.boardingTime) }),
|
|
...(dto.departureTime && { departureTime: new Date(dto.departureTime) }),
|
|
...(dto.arrivalTime && { arrivalTime: new Date(dto.arrivalTime) }),
|
|
...(dto.totalCapacity && { totalCapacity: dto.totalCapacity }),
|
|
...(dto.coachConfiguration !== undefined && { coachConfiguration: dto.coachConfiguration }),
|
|
...(dto.includedServices && { includedServices: dto.includedServices }),
|
|
...(dto.busTransferIncluded !== undefined && { busTransferIncluded: dto.busTransferIncluded }),
|
|
...(dto.busTransferRoute !== undefined && { busTransferRoute: dto.busTransferRoute }),
|
|
...(dto.validFrom && { validFrom: new Date(dto.validFrom) }),
|
|
...(dto.validUntil && { validUntil: new Date(dto.validUntil) }),
|
|
},
|
|
include: { priceTiers: true },
|
|
});
|
|
}
|
|
|
|
async addTier(packageId: string, dto: CreatePriceTierDto) {
|
|
const pkg = await this.prisma.travelPackage.findUnique({ where: { id: packageId } });
|
|
if (!pkg) throw new NotFoundException('Package not found');
|
|
return this.prisma.packagePriceTier.create({ data: { ...dto, packageId } });
|
|
}
|
|
|
|
async updateTier(tierId: string, dto: UpdatePriceTierDto) {
|
|
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } });
|
|
if (!tier) throw new NotFoundException('Price tier not found');
|
|
return this.prisma.packagePriceTier.update({ where: { id: tierId }, data: dto });
|
|
}
|
|
|
|
async deleteTier(tierId: string) {
|
|
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } });
|
|
if (!tier) throw new NotFoundException('Price tier not found');
|
|
if (tier.bookedSeats > 0) throw new BadRequestException('Cannot delete a tier that has bookings');
|
|
return this.prisma.packagePriceTier.delete({ where: { id: tierId } });
|
|
}
|
|
|
|
async remove(id: string) {
|
|
const pkg = await this.prisma.travelPackage.findUnique({
|
|
where: { id },
|
|
include: { bookings: { select: { id: true, status: true } } },
|
|
});
|
|
if (!pkg) throw new NotFoundException('Package not found');
|
|
const hasActive = pkg.bookings.some((b) => b.status === 'PENDING_PAYMENT' || b.status === 'CONFIRMED');
|
|
if (hasActive) throw new BadRequestException('Cannot delete a package with active bookings');
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
const bookingIds = pkg.bookings.map((b) => b.id);
|
|
if (bookingIds.length > 0) {
|
|
await tx.packageBookingPassenger.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
|
await tx.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: bookingIds } } });
|
|
await tx.packageBooking.deleteMany({ where: { packageId: id } });
|
|
}
|
|
await tx.packagePriceTier.deleteMany({ where: { packageId: id } });
|
|
await tx.travelPackage.delete({ where: { id } });
|
|
});
|
|
return { deleted: true };
|
|
}
|
|
|
|
async activate(id: string) {
|
|
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
|
|
if (!pkg) throw new NotFoundException('Package not found');
|
|
return this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } });
|
|
}
|
|
|
|
async deactivate(id: string) {
|
|
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
|
|
if (!pkg) throw new NotFoundException('Package not found');
|
|
return this.prisma.travelPackage.update({ where: { id }, data: { status: 'DRAFT' } });
|
|
}
|
|
|
|
async book(dto: BookPackageDto, passengerId?: string) {
|
|
const pkg = await this.prisma.travelPackage.findUnique({
|
|
where: { id: dto.packageId },
|
|
include: { priceTiers: true },
|
|
});
|
|
if (!pkg) throw new NotFoundException('Package not found');
|
|
if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking');
|
|
|
|
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
|
|
if (!tier) throw new NotFoundException('Price tier not found');
|
|
|
|
// Derive adult/child counts from the passengers array (dateOfBirth-based)
|
|
let adultCount = 0, childCount = 0;
|
|
for (const p of dto.passengers) {
|
|
if (p.dateOfBirth && deriveAge(p.dateOfBirth) < 5) childCount++;
|
|
else adultCount++;
|
|
}
|
|
// Allow explicit override from mobile app (e.g. when dateOfBirth is not provided per passenger)
|
|
if (dto.adultCount !== undefined) adultCount = dto.adultCount;
|
|
if (dto.childCount !== undefined) childCount = dto.childCount;
|
|
|
|
if (adultCount < 1) throw new BadRequestException('At least one adult passenger required');
|
|
if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`);
|
|
if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`);
|
|
|
|
const passengerCount = adultCount + childCount;
|
|
const remaining = tier.availableSeats - tier.bookedSeats;
|
|
if (passengerCount > remaining) {
|
|
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`);
|
|
}
|
|
|
|
const isRoundTrip = !!pkg.returnScheduleId;
|
|
const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown(
|
|
tier.priceMinor, isRoundTrip, adultCount, childCount,
|
|
);
|
|
const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB;
|
|
const displayTotalMinor =
|
|
displayCurrency !== Currency.ETB
|
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
|
: totalMinor;
|
|
|
|
const [booking] = await this.prisma.$transaction([
|
|
this.prisma.packageBooking.create({
|
|
data: {
|
|
bookingRef: generateRef(),
|
|
packageId: dto.packageId,
|
|
priceTierId: dto.priceTierId,
|
|
passengerId: passengerId ?? null,
|
|
contactEmail: dto.contactEmail,
|
|
contactPhone: dto.contactPhone,
|
|
promoCode: dto.promoCode,
|
|
passengerCount,
|
|
totalMinor,
|
|
currency: 'ETB',
|
|
displayCurrency,
|
|
displayTotalMinor,
|
|
status: 'PENDING_PAYMENT',
|
|
passengers: {
|
|
create: dto.passengers.map((p) => ({
|
|
passengerName: p.passengerName,
|
|
dateOfBirth: p.dateOfBirth ? new Date(p.dateOfBirth) : undefined,
|
|
idDocumentType: p.idDocumentType as any,
|
|
idDocumentNumber: p.idDocumentNumber,
|
|
passportNumber: p.passportNumber,
|
|
passportCountry: p.passportCountry,
|
|
})),
|
|
},
|
|
},
|
|
include: {
|
|
passengers: true,
|
|
priceTier: true,
|
|
package: {
|
|
include: {
|
|
outboundSchedule: { include: { originStation: true, destinationStation: true } },
|
|
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
this.prisma.packagePriceTier.update({
|
|
where: { id: dto.priceTierId },
|
|
data: { bookedSeats: { increment: passengerCount } },
|
|
}),
|
|
]);
|
|
|
|
return {
|
|
...booking,
|
|
fareBreakdown: {
|
|
isRoundTrip,
|
|
adultCount,
|
|
adultFareMinor,
|
|
childCount,
|
|
childFareMinor,
|
|
childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`,
|
|
totalMinor,
|
|
currency: 'ETB',
|
|
displayCurrency,
|
|
displayTotalMinor,
|
|
},
|
|
};
|
|
}
|
|
|
|
getMyBookings(passengerId: string) {
|
|
return this.prisma.packageBooking.findMany({
|
|
where: { passengerId },
|
|
include: { package: true, priceTier: true, passengers: true, paymentIntent: true },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
}
|
|
|
|
async getBookingByRef(bookingRef: string) {
|
|
const booking = await this.prisma.packageBooking.findUnique({
|
|
where: { bookingRef },
|
|
include: {
|
|
package: {
|
|
include: {
|
|
outboundSchedule: { include: { originStation: true, destinationStation: true } },
|
|
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
|
},
|
|
},
|
|
priceTier: true,
|
|
passengers: true,
|
|
paymentIntent: true,
|
|
},
|
|
});
|
|
if (!booking) throw new NotFoundException('Package booking not found');
|
|
return booking;
|
|
}
|
|
|
|
async listBookings({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) {
|
|
const where: any = {};
|
|
if (packageId) where.packageId = packageId;
|
|
if (status) where.status = status;
|
|
const skip = (page - 1) * pageSize;
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.packageBooking.findMany({
|
|
where,
|
|
include: {
|
|
package: { select: { id: true, name: true, code: true } },
|
|
priceTier: { select: { id: true, label: true, seatType: true } },
|
|
passengers: true,
|
|
paymentIntent: true,
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
skip,
|
|
take: pageSize,
|
|
}),
|
|
this.prisma.packageBooking.count({ where }),
|
|
]);
|
|
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
|
|
}
|
|
|
|
async listAll(page = 1, pageSize = 20) {
|
|
const skip = (page - 1) * pageSize;
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.travelPackage.findMany({
|
|
skip,
|
|
take: pageSize,
|
|
include: {
|
|
priceTiers: true,
|
|
outboundSchedule: { include: { originStation: true, destinationStation: true } },
|
|
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
}),
|
|
this.prisma.travelPackage.count(),
|
|
]);
|
|
return { items, total, page, pageSize };
|
|
}
|
|
}
|