mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 17:10:56 +00:00
Package passenger numbers and pricing updates
This commit is contained in:
@@ -341,8 +341,9 @@ export class BookingsService {
|
||||
totalMinor: b.totalMinor, currency: b.currency || 'ETB',
|
||||
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
|
||||
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
|
||||
bookingType: 'PACKAGE', packageId: b.packageId, isPackageBooking: true,
|
||||
packageName: b.package?.name, packageCode: b.package?.code,
|
||||
bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
|
||||
isPackageBooking: true, packageName: b.package?.name, packageCode: b.package?.code,
|
||||
tierLabel: b.priceTier?.label ?? null,
|
||||
returnLegStatus: null, adultCount: b.passengerCount, childCount: 0,
|
||||
createdAt: b.createdAt, passenger: null,
|
||||
passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [],
|
||||
@@ -372,6 +373,8 @@ export class BookingsService {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
package: { select: { id: true, name: true, code: true } },
|
||||
priceTier: { select: { id: true, label: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
@@ -415,6 +418,10 @@ export class BookingsService {
|
||||
contactPhone: booking.contactPhone,
|
||||
bookingType: booking.bookingType,
|
||||
packageId: booking.packageId ?? null,
|
||||
priceTierId: (booking as any).priceTierId ?? null,
|
||||
packageName: (booking as any).package?.name ?? null,
|
||||
packageCode: (booking as any).package?.code ?? null,
|
||||
tierLabel: (booking as any).priceTier?.label ?? null,
|
||||
isPackageBooking: !!booking.packageId,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
adultCount: booking.adultCount,
|
||||
@@ -446,9 +453,11 @@ export class BookingsService {
|
||||
contactPhone: b.contactPhone,
|
||||
bookingType: 'PACKAGE',
|
||||
packageId: b.packageId,
|
||||
priceTierId: b.priceTierId,
|
||||
isPackageBooking: true,
|
||||
packageName: b.package?.name,
|
||||
packageCode: b.package?.code,
|
||||
tierLabel: b.priceTier?.label ?? null,
|
||||
returnLegStatus: null,
|
||||
adultCount: b.passengerCount,
|
||||
childCount: 0,
|
||||
@@ -518,20 +527,18 @@ export class BookingsService {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
// Track which child gets free fare (first child encountered)
|
||||
// Track per-seat fare. For package bookings children pay 10% of adult fare;
|
||||
// for regular bookings the first child is free.
|
||||
let freeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let fareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
fareMinor = fareCalculation.baseFareMinor;
|
||||
} else if (dto.packageId) {
|
||||
fareMinor = Math.round(fareCalculation.baseFareMinor * 0.1);
|
||||
} else {
|
||||
// Child: first child is free, subsequent children pay full fare
|
||||
if (!freeChildUsed) {
|
||||
fareMinor = 0;
|
||||
freeChildUsed = true;
|
||||
} else {
|
||||
fareMinor = fareCalculation.baseFareMinor;
|
||||
}
|
||||
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||||
else fareMinor = fareCalculation.baseFareMinor;
|
||||
}
|
||||
return { ...p, fareMinor };
|
||||
});
|
||||
@@ -661,34 +668,27 @@ export class BookingsService {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
// Track which child gets free fare for outbound and return legs
|
||||
// Track per-seat fare. For package bookings children pay 10% of adult fare;
|
||||
// for regular bookings the first child is free per leg.
|
||||
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 if (dto.packageId) {
|
||||
outboundFareMinor = Math.round(outboundFare.baseFareMinor * 0.1);
|
||||
returnFareMinor = Math.round(returnFare.baseFareMinor * 0.1);
|
||||
} 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;
|
||||
}
|
||||
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||||
else outboundFareMinor = outboundFare.baseFareMinor;
|
||||
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||||
else returnFareMinor = returnFare.baseFareMinor;
|
||||
}
|
||||
|
||||
|
||||
return { ...p, outboundFareMinor, returnFareMinor };
|
||||
});
|
||||
|
||||
@@ -1231,16 +1231,20 @@ export class BookingsService {
|
||||
childCount: number,
|
||||
) {
|
||||
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } });
|
||||
const passengerCount = adultCount + childCount;
|
||||
const totalBaseFareMinor = tier.priceMinor * passengerCount;
|
||||
// For round-trip packages the caller splits the tier price across legs, so
|
||||
// priceMinor here is already the per-leg amount. Children pay 10% of adult fare.
|
||||
const childFareMinor = Math.round(tier.priceMinor * 0.1);
|
||||
const adultFareMinor = tier.priceMinor * adultCount;
|
||||
const childTotalMinor = childFareMinor * childCount;
|
||||
const totalBaseFareMinor = adultFareMinor + childTotalMinor;
|
||||
return {
|
||||
baseFareMinor: tier.priceMinor,
|
||||
adultCount,
|
||||
adultFareMinor: tier.priceMinor * adultCount,
|
||||
adultFareMinor,
|
||||
childCount,
|
||||
freeChildrenCount: 0,
|
||||
paidChildrenCount: childCount,
|
||||
childFareMinor: tier.priceMinor * childCount,
|
||||
childFareMinor: childTotalMinor,
|
||||
totalBaseFareMinor,
|
||||
discountMinor: 0,
|
||||
loyaltyRedemptionMinor: 0,
|
||||
|
||||
@@ -144,6 +144,12 @@ export class CreateGuestBookingDto {
|
||||
|
||||
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' })
|
||||
@IsOptional() @IsString() deviceId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier' })
|
||||
@IsOptional() @IsString() packageId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
||||
@IsOptional() @IsString() priceTierId?: string;
|
||||
}
|
||||
|
||||
export class SavedPassengerProfileDto {
|
||||
|
||||
@@ -157,8 +157,10 @@ export class GuestBookingService {
|
||||
);
|
||||
|
||||
const adultFareMinor = baseFareMinor * adultCount;
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const childFareMinor = baseFareMinor * paidChildrenCount;
|
||||
const isPackageOneway = !!dto.packageId;
|
||||
const paidChildrenCount = isPackageOneway ? childCount : Math.max(0, childCount - 1);
|
||||
const childUnitFare = isPackageOneway ? Math.round(baseFareMinor * 0.1) : baseFareMinor;
|
||||
const childFareMinor = childUnitFare * paidChildrenCount;
|
||||
const totalBaseFareMinor = adultFareMinor + childFareMinor;
|
||||
|
||||
let discountMinor = 0;
|
||||
@@ -217,6 +219,7 @@ export class GuestBookingService {
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
bookingType: 'ONE_WAY',
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: firstPassenger.email || null,
|
||||
contactPhone: firstPassenger.phone || null,
|
||||
@@ -231,7 +234,7 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0),
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
|
||||
displayCurrency,
|
||||
})),
|
||||
},
|
||||
@@ -258,7 +261,7 @@ export class GuestBookingService {
|
||||
adultCount,
|
||||
adultFareMinor,
|
||||
childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
freeChildrenCount: isPackageOneway ? 0 : Math.min(childCount, 1),
|
||||
paidChildrenCount,
|
||||
childFareMinor,
|
||||
totalBaseFareMinor,
|
||||
@@ -375,9 +378,12 @@ export class GuestBookingService {
|
||||
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
|
||||
]);
|
||||
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount;
|
||||
const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount;
|
||||
const isPackageRoundTrip = !!dto.packageId;
|
||||
const paidChildrenCount = isPackageRoundTrip ? childCount : Math.max(0, childCount - 1);
|
||||
const outboundChildUnitFare = isPackageRoundTrip ? Math.round(outboundBaseFare * 0.1) : outboundBaseFare;
|
||||
const returnChildUnitFare = isPackageRoundTrip ? Math.round(returnBaseFare * 0.1) : returnBaseFare;
|
||||
const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount;
|
||||
const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount;
|
||||
const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
|
||||
|
||||
let discountMinor = 0;
|
||||
@@ -423,6 +429,7 @@ export class GuestBookingService {
|
||||
returnHoldId: dto.returnHoldId,
|
||||
returnSeatClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
@@ -440,7 +447,7 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0),
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
|
||||
displayCurrency,
|
||||
})),
|
||||
...passengersData.map((p) => ({
|
||||
@@ -455,7 +462,7 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0),
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
|
||||
displayCurrency,
|
||||
})),
|
||||
],
|
||||
@@ -484,7 +491,7 @@ export class GuestBookingService {
|
||||
returnBaseFareMinor: returnBaseFare,
|
||||
adultCount,
|
||||
childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1),
|
||||
paidChildrenCount,
|
||||
combinedBaseFareMinor,
|
||||
discountMinor,
|
||||
|
||||
@@ -329,7 +329,7 @@ export class ConfigurableFareService {
|
||||
);
|
||||
|
||||
if (childRule) {
|
||||
const freeChildren = Math.min(childCount, childRule.max_free_passengers);
|
||||
const freeChildren = Math.min(childCount, adultCount);
|
||||
const paidChildren = Math.max(0, childCount - freeChildren);
|
||||
|
||||
if (freeChildren > 0) {
|
||||
|
||||
@@ -131,7 +131,7 @@ export class FareEngineService {
|
||||
const adultCount = dto.adultCount ?? 1;
|
||||
const childCount = dto.childCount ?? 0;
|
||||
const freeChildrenCount = Math.min(childCount, adultCount);
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const paidChildrenCount = Math.max(0, childCount - freeChildrenCount);
|
||||
|
||||
// Subtotal includes: (distance-based fare + premium + insurance) × passengers
|
||||
// First child is free, but pays premium and insurance
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Controller, Get, Post, Put, Delete,
|
||||
Param, Body, Query, UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
import { SegmentFareService } from './segment-fare.service';
|
||||
import { CreateSegmentFareDto, UpdateSegmentFareDto } from './segment-fare.dto';
|
||||
|
||||
@ApiTags('Admin – Segment Fares')
|
||||
@Controller('admin/segment-fares')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN', 'SUPERVISOR')
|
||||
export class SegmentFareController {
|
||||
constructor(private readonly service: SegmentFareService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all segment fare rules, optionally filtered by route' })
|
||||
@ApiQuery({ name: 'routeId', required: false })
|
||||
findAll(@Query('routeId') routeId?: string) {
|
||||
return this.service.findAll(routeId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a single segment fare rule' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a segment fare rule' })
|
||||
create(@Body() dto: CreateSegmentFareDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update a segment fare rule' })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateSegmentFareDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString, IsInt, IsOptional, IsDateString, IsIn, Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateSegmentFareDto {
|
||||
@ApiProperty({ example: 'route-uuid' })
|
||||
@IsString()
|
||||
routeId: string;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
@IsInt() @Min(0)
|
||||
originStopSequence: number;
|
||||
|
||||
@ApiProperty({ example: 5 })
|
||||
@IsInt() @Min(1)
|
||||
destinationStopSequence: number;
|
||||
|
||||
@ApiProperty({ example: 'seat-class-uuid' })
|
||||
@IsString()
|
||||
seatClassId: string;
|
||||
|
||||
@ApiProperty({ example: 35000, description: 'Base fare in minor units (e.g. 350.00 ETB = 35000)' })
|
||||
@IsInt() @Min(0)
|
||||
baseFareMinor: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'LOCAL', enum: ['LOCAL', 'INTERNATIONAL'] })
|
||||
@IsOptional()
|
||||
@IsIn(['LOCAL', 'INTERNATIONAL'])
|
||||
nationality?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ETB', default: 'ETB' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
|
||||
@ApiProperty({ example: '2025-01-01T00:00:00.000Z' })
|
||||
@IsDateString()
|
||||
validFrom: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-12-31T23:59:59.000Z' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
validUntil?: string;
|
||||
}
|
||||
|
||||
export class UpdateSegmentFareDto {
|
||||
@ApiPropertyOptional({ example: 40000 })
|
||||
@IsOptional()
|
||||
@IsInt() @Min(0)
|
||||
baseFareMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ETB' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2025-06-01T00:00:00.000Z' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
validFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-12-31T23:59:59.000Z' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
validUntil?: string;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateSegmentFareDto, UpdateSegmentFareDto } from './segment-fare.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SegmentFareService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(routeId?: string) {
|
||||
return this.prisma.segmentFareRule.findMany({
|
||||
where: routeId ? { routeId } : undefined,
|
||||
include: { seatClass: true, route: { select: { id: true, code: true, name: true } } },
|
||||
orderBy: [{ routeId: 'asc' }, { originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const rule = await this.prisma.segmentFareRule.findUnique({
|
||||
where: { id },
|
||||
include: { seatClass: true, route: { select: { id: true, code: true, name: true } } },
|
||||
});
|
||||
if (!rule) throw new NotFoundException(`SegmentFareRule ${id} not found`);
|
||||
return rule;
|
||||
}
|
||||
|
||||
create(dto: CreateSegmentFareDto) {
|
||||
return this.prisma.segmentFareRule.create({
|
||||
data: {
|
||||
routeId: dto.routeId,
|
||||
originStopSequence: dto.originStopSequence,
|
||||
destinationStopSequence: dto.destinationStopSequence,
|
||||
seatClassId: dto.seatClassId,
|
||||
baseFareMinor: dto.baseFareMinor,
|
||||
nationality: dto.nationality ?? null,
|
||||
currency: dto.currency ?? 'ETB',
|
||||
validFrom: new Date(dto.validFrom),
|
||||
validUntil: dto.validUntil ? new Date(dto.validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateSegmentFareDto) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.segmentFareRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }),
|
||||
...(dto.currency !== undefined && { currency: dto.currency }),
|
||||
...(dto.validFrom !== undefined && { validFrom: new Date(dto.validFrom) }),
|
||||
...(dto.validUntil !== undefined && { validUntil: new Date(dto.validUntil) }),
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id);
|
||||
await this.prisma.segmentFareRule.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,24 @@ import { SegmentsService } from './segments.service';
|
||||
import { EnhancedSeatsService } from './enhanced-seats.service';
|
||||
import { TripProgressService } from './trip-progress.service';
|
||||
import { SegmentSeatsController } from './segments.controller';
|
||||
import { SegmentFareController } from './segment-fare.controller';
|
||||
import { SegmentFareService } from './segment-fare.service';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SegmentSeatsController],
|
||||
controllers: [SegmentSeatsController, SegmentFareController],
|
||||
providers: [
|
||||
SegmentsService,
|
||||
EnhancedSeatsService,
|
||||
TripProgressService,
|
||||
PrismaService
|
||||
SegmentFareService,
|
||||
PrismaService,
|
||||
],
|
||||
exports: [
|
||||
SegmentsService,
|
||||
EnhancedSeatsService,
|
||||
TripProgressService
|
||||
]
|
||||
TripProgressService,
|
||||
SegmentFareService,
|
||||
],
|
||||
})
|
||||
export class SegmentsModule {}
|
||||
Reference in New Issue
Block a user