Package passenger numbers and pricing updates

This commit is contained in:
Stephanos A
2026-07-05 18:14:42 +03:00
parent 9e77ca7865
commit afd30c36a0
20 changed files with 537 additions and 105 deletions

View File

@@ -118,4 +118,10 @@ export class BookPackageDto {
@ApiProperty({ type: [BookPackagePassengerDto] })
@IsArray() @ValidateNested({ each: true }) @Type(() => BookPackagePassengerDto)
passengers: BookPackagePassengerDto[];
/** Number of adult passengers (≥5 years). Derived from passengers array if omitted. */
@ApiPropertyOptional({ example: 2 }) @IsOptional() @IsInt() @Min(1) adultCount?: number;
/** Number of child passengers (<5 years). Derived from passengers array if omitted. */
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number;
}

View File

@@ -6,6 +6,32 @@ 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)],
@@ -41,14 +67,19 @@ export class PackagesService {
const tier = pkg.priceTiers.find(t => t.id === tierId);
if (!tier) throw new NotFoundException('Price tier not found');
const passengerCount = adultCount + childCount;
if (passengerCount < 1) throw new BadRequestException('At least one passenger required');
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 totalMinor = tier.priceMinor * passengerCount;
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;
@@ -60,7 +91,6 @@ export class PackagesService {
);
if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; }
}
// Fallback: use the first coach assignment's coachTypeId if no match found
if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) {
const first = pkg.outboundSchedule.coachAssignments[0];
coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null;
@@ -77,7 +107,12 @@ export class PackagesService {
adultCount,
childCount,
passengerCount,
pricePerPassengerMinor: tier.priceMinor,
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,
@@ -297,13 +332,30 @@ export class PackagesService {
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
if (!tier) throw new NotFoundException('Price tier not found');
const passengerCount = dto.passengers.length;
// 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 totalMinor = tier.priceMinor * passengerCount;
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
@@ -354,7 +406,21 @@ export class PackagesService {
}),
]);
return booking;
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) {