feat: move display-assembly out of BookingPriceCalculator
This commit is contained in:
@@ -15,7 +15,6 @@ use App\Form\Model\BookingDto;
|
|||||||
use App\Htmx\HxTrait;
|
use App\Htmx\HxTrait;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Service\BookingCreateContextFactory;
|
use App\Service\BookingCreateContextFactory;
|
||||||
use App\Service\BookingPriceCalculator;
|
|
||||||
use App\Service\BookingConfigurator;
|
use App\Service\BookingConfigurator;
|
||||||
use App\Service\BookingSessionStore;
|
use App\Service\BookingSessionStore;
|
||||||
use App\Service\MailjetApiClient;
|
use App\Service\MailjetApiClient;
|
||||||
@@ -41,7 +40,6 @@ class Step4Controller extends AbstractController
|
|||||||
private readonly BookingConfigurator $bookingService,
|
private readonly BookingConfigurator $bookingService,
|
||||||
private readonly BookingSessionStore $bookingSessionService,
|
private readonly BookingSessionStore $bookingSessionService,
|
||||||
private readonly BookingCreateContextFactory $createContextFactory,
|
private readonly BookingCreateContextFactory $createContextFactory,
|
||||||
private readonly BookingPriceCalculator $priceCalculator,
|
|
||||||
private readonly ApiClient $apiClient,
|
private readonly ApiClient $apiClient,
|
||||||
private readonly CacheInterface $cache,
|
private readonly CacheInterface $cache,
|
||||||
private readonly MailjetApiClient $newsletterService,
|
private readonly MailjetApiClient $newsletterService,
|
||||||
|
|||||||
@@ -4,132 +4,32 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\BusProNet\Constants;
|
|
||||||
use App\BusProNet\Model\Insurance;
|
|
||||||
use App\BusProNet\Model\Service;
|
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Facade for booking pricing calculations.
|
* Computes numeric price totals for bookings and individual participants.
|
||||||
*
|
*
|
||||||
* Provides comprehensive pricing calculations for the booking system by
|
* All methods return floats or arrays of floats — no display formatting.
|
||||||
* coordinating room and participant calculations and handling service aggregation directly.
|
* For display-ready pricing breakdowns, use BookingPricingAssembler.
|
||||||
* Returns structured pricing data for display in forms and summaries.
|
|
||||||
*/
|
*/
|
||||||
class BookingPriceCalculator
|
class BookingPriceCalculator
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly RoomPricingCalculator $roomPricingCalculator,
|
private readonly RoomPricingCalculator $roomPricingCalculator,
|
||||||
private readonly ParticipantEligibilityChecker $participantEligibilityService,
|
private readonly BookingPricingAssembler $pricingAssembler,
|
||||||
private readonly InsuranceManager $insuranceService,
|
|
||||||
private readonly ParticipantPricingCalculator $participantPricingCalculator,
|
private readonly ParticipantPricingCalculator $participantPricingCalculator,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates comprehensive pricing breakdown for a booking.
|
|
||||||
*
|
|
||||||
* @param BookingDto $bookingDto The booking data to calculate pricing for
|
|
||||||
*
|
|
||||||
* @return array{rooms: array, services: array, grandTotal: float} Complete pricing breakdown
|
|
||||||
*/
|
|
||||||
public function getPricingBreakdown(
|
|
||||||
BookingDto $bookingDto,
|
|
||||||
string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT,
|
|
||||||
): array
|
|
||||||
{
|
|
||||||
$roomPricing = $this->roomPricingCalculator->calculateRoomPricing($bookingDto, $roomPricingMode);
|
|
||||||
$servicePricing = $this->calculateServicePricing($bookingDto);
|
|
||||||
$grandTotal = $this->calculateGrandTotal($bookingDto, $roomPricingMode);
|
|
||||||
|
|
||||||
$result = [
|
|
||||||
'rooms' => $roomPricing,
|
|
||||||
'services' => $servicePricing,
|
|
||||||
'grandTotal' => $grandTotal,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add surcharges if in edit mode with booking data
|
|
||||||
if (null !== $bookingDto->booking && [] !== $bookingDto->booking->surcharges) {
|
|
||||||
$surchargePricing = $this->calculateSurchargePricing($bookingDto->booking);
|
|
||||||
|
|
||||||
if ([] !== $surchargePricing) {
|
|
||||||
$result['surcharges'] = $surchargePricing;
|
|
||||||
|
|
||||||
// Add surcharge total to grand total
|
|
||||||
$surchargeTotal = array_sum(array_column($surchargePricing, 'totalPrice'));
|
|
||||||
$result['grandTotal'] += $surchargeTotal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates pricing for all selected rooms.
|
|
||||||
*
|
|
||||||
* @param BookingDto $bookingDto The booking data containing room selections
|
|
||||||
*
|
|
||||||
* @return array Array of room pricing data with labels, quantities, and totals
|
|
||||||
*/
|
|
||||||
public function calculateRoomPricing(
|
|
||||||
BookingDto $bookingDto,
|
|
||||||
string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT,
|
|
||||||
): array
|
|
||||||
{
|
|
||||||
return $this->roomPricingCalculator->calculateRoomPricing($bookingDto, $roomPricingMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates pricing for all selected services across all participants, grouped by subtype.
|
|
||||||
*
|
|
||||||
* Only includes services from eligible participants (those with available skipasses for their age).
|
|
||||||
*
|
|
||||||
* @param BookingDto $bookingDto The booking data containing participants and their service selections
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, mixed>> Array of service groups with each group containing services of the same subtype
|
|
||||||
*/
|
|
||||||
public function calculateServicePricing(BookingDto $bookingDto): array
|
|
||||||
{
|
|
||||||
$participants = $bookingDto->getParticipants();
|
|
||||||
|
|
||||||
if ([] === $participants) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$serviceAggregation = [];
|
|
||||||
|
|
||||||
foreach ($participants as $participantIndex => $participant) {
|
|
||||||
if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->aggregateParticipantServices($participant, $serviceAggregation, $bookingDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->aggregateTransportationServices($bookingDto) as $key => $transportationItem) {
|
|
||||||
$serviceAggregation[$key] = $transportationItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->groupServicesBySubtype($serviceAggregation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the grand total for the entire booking.
|
* Calculates the grand total for the entire booking.
|
||||||
*
|
|
||||||
* @param BookingDto $bookingDto The booking data to calculate total for
|
|
||||||
*
|
|
||||||
* @return float The grand total price
|
|
||||||
*/
|
*/
|
||||||
public function calculateGrandTotal(
|
public function calculateGrandTotal(
|
||||||
BookingDto $bookingDto,
|
BookingDto $bookingDto,
|
||||||
string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT,
|
string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT,
|
||||||
): float
|
): float {
|
||||||
{
|
return $this->roomPricingCalculator->calculateRoomTotal($bookingDto, $roomPricingMode)
|
||||||
$roomTotal = $this->roomPricingCalculator->calculateRoomTotal($bookingDto, $roomPricingMode);
|
+ $this->calculateServiceTotal($bookingDto);
|
||||||
$serviceTotal = $this->calculateServiceTotal($bookingDto);
|
|
||||||
|
|
||||||
return $roomTotal + $serviceTotal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,387 +38,28 @@ class BookingPriceCalculator
|
|||||||
public function calculateRoomTotal(
|
public function calculateRoomTotal(
|
||||||
BookingDto $bookingDto,
|
BookingDto $bookingDto,
|
||||||
string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT,
|
string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT,
|
||||||
): float
|
): float {
|
||||||
{
|
|
||||||
return $this->roomPricingCalculator->calculateRoomTotal($bookingDto, $roomPricingMode);
|
return $this->roomPricingCalculator->calculateRoomTotal($bookingDto, $roomPricingMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates total price for all services.
|
* Calculates total price for all services across all eligible participants.
|
||||||
|
*
|
||||||
|
* Delegates to BookingPricingAssembler to guarantee the same insurance-tier
|
||||||
|
* resolution used in the display breakdown (including bulk-family-insurance
|
||||||
|
* price-tier adjustment per dependent). This keeps the numeric total and the
|
||||||
|
* displayed total in sync.
|
||||||
*/
|
*/
|
||||||
public function calculateServiceTotal(BookingDto $bookingDto): float
|
public function calculateServiceTotal(BookingDto $bookingDto): float
|
||||||
{
|
{
|
||||||
$servicePricing = $this->calculateServicePricing($bookingDto);
|
return array_sum(array_column(
|
||||||
|
$this->pricingAssembler->calculateServicePricing($bookingDto),
|
||||||
$total = 0.0;
|
'groupTotal'
|
||||||
foreach ($servicePricing as $group) {
|
));
|
||||||
$total += $group['groupTotal'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $total;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates surcharge pricing grouped by label (edit mode only).
|
|
||||||
*
|
|
||||||
* Returns surcharges in a format similar to services for display in summary.
|
|
||||||
* Groups surcharges by label and counts participant assignments.
|
|
||||||
*
|
|
||||||
* @param \App\BusProNet\Model\Booking $bookingData The booking entity with surcharge information
|
|
||||||
*
|
|
||||||
* @return array Array of surcharge data with labels, counts, and totals
|
|
||||||
*/
|
|
||||||
public function calculateSurchargePricing(\App\BusProNet\Model\Booking $bookingData): array
|
|
||||||
{
|
|
||||||
$surchargePricing = [];
|
|
||||||
|
|
||||||
foreach ($bookingData->surcharges as $surcharge) {
|
|
||||||
$surchargePricing[] = [
|
|
||||||
'label' => $surcharge->label ?? 'unbekannt',
|
|
||||||
'participantCount' => count($surcharge->mapping),
|
|
||||||
'totalPrice' => $surcharge->totalPrice ?? 0.0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $surchargePricing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregates all transportation-related services and pricing into separate line items.
|
|
||||||
*
|
|
||||||
* Creates separate entries for:
|
|
||||||
* - Befoerderung: Sum of all positive pickup prices and base transportation costs
|
|
||||||
* - Befoerderung - Rabatt: Sum of all negative transportation prices (discounts)
|
|
||||||
* - Parkplatz: Sum of all parking service prices
|
|
||||||
*
|
|
||||||
* Only includes transportation costs from eligible participants.
|
|
||||||
*
|
|
||||||
* @param BookingDto $bookingDto The booking data containing participants
|
|
||||||
*
|
|
||||||
* @return array<string, array<string, mixed>> Array of transportation line items
|
|
||||||
*/
|
|
||||||
private function aggregateTransportationServices(BookingDto $bookingDto): array
|
|
||||||
{
|
|
||||||
$participants = $bookingDto->getParticipants();
|
|
||||||
|
|
||||||
$transportationPositiveTotal = 0.0;
|
|
||||||
$transportationDiscountTotal = 0.0;
|
|
||||||
$parkingTotal = 0.0;
|
|
||||||
|
|
||||||
$transportationParticipants = 0;
|
|
||||||
$discountParticipants = 0;
|
|
||||||
$parkingParticipants = 0;
|
|
||||||
|
|
||||||
foreach ($participants as $participantIndex => $participant) {
|
|
||||||
if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$participantTransportationPositiveCost = 0.0;
|
|
||||||
$participantTransportationDiscountCost = 0.0;
|
|
||||||
$participantParkingCost = 0.0;
|
|
||||||
|
|
||||||
if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) {
|
|
||||||
if ($participant->transportationOutbound->price < 0) {
|
|
||||||
$participantTransportationDiscountCost += $participant->transportationOutbound->price;
|
|
||||||
} else {
|
|
||||||
$participantTransportationPositiveCost += $participant->transportationOutbound->price;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) {
|
|
||||||
if ($participant->transportationInbound->price < 0) {
|
|
||||||
$participantTransportationDiscountCost += $participant->transportationInbound->price;
|
|
||||||
} else {
|
|
||||||
$participantTransportationPositiveCost += $participant->transportationInbound->price;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$hasOutboundBus = null !== $participant->transportationOutbound
|
|
||||||
&& 'BUS' === $participant->transportationOutbound->subType;
|
|
||||||
|
|
||||||
if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) {
|
|
||||||
if ($participant->pickup->price < 0) {
|
|
||||||
$participantTransportationDiscountCost += $participant->pickup->price;
|
|
||||||
} else {
|
|
||||||
$participantTransportationPositiveCost += $participant->pickup->price;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $participant->dropOff && null !== $participant->dropOff->price) {
|
|
||||||
if ($participant->dropOff->price < 0) {
|
|
||||||
$participantTransportationDiscountCost += $participant->dropOff->price;
|
|
||||||
} else {
|
|
||||||
$participantTransportationPositiveCost += $participant->dropOff->price;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $participant->parkingService && null !== $participant->parkingService->price) {
|
|
||||||
$participantParkingCost += $participant->parkingService->price;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($participantTransportationPositiveCost > 0) {
|
|
||||||
$transportationPositiveTotal += $participantTransportationPositiveCost;
|
|
||||||
++$transportationParticipants;
|
|
||||||
}
|
|
||||||
if ($participantTransportationDiscountCost < 0) {
|
|
||||||
$transportationDiscountTotal += $participantTransportationDiscountCost;
|
|
||||||
++$discountParticipants;
|
|
||||||
}
|
|
||||||
if ($participantParkingCost > 0) {
|
|
||||||
$parkingTotal += $participantParkingCost;
|
|
||||||
++$parkingParticipants;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$transportationItems = [];
|
|
||||||
|
|
||||||
if ($transportationPositiveTotal > 0) {
|
|
||||||
$transportationItems['transportation_positive'] = [
|
|
||||||
'serviceId' => 'transportation_positive',
|
|
||||||
'label' => 'Beförderung',
|
|
||||||
'unitPrice' => null,
|
|
||||||
'participantCount' => $transportationParticipants,
|
|
||||||
'totalPrice' => $transportationPositiveTotal,
|
|
||||||
'subType' => Constants::GROUP_TRANSPORTATION,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($transportationDiscountTotal < 0) {
|
|
||||||
$transportationItems['transportation_discount'] = [
|
|
||||||
'serviceId' => 'transportation_discount',
|
|
||||||
'label' => 'Beförderung - Rabatt',
|
|
||||||
'unitPrice' => null,
|
|
||||||
'participantCount' => $discountParticipants,
|
|
||||||
'totalPrice' => $transportationDiscountTotal,
|
|
||||||
'subType' => Constants::GROUP_TRANSPORTATION.'_discount',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($parkingTotal > 0) {
|
|
||||||
$transportationItems['transportation_parking'] = [
|
|
||||||
'serviceId' => 'transportation_parking',
|
|
||||||
'label' => Constants::SERVICE_LABELS[Constants::TOKEN_PARKING],
|
|
||||||
'unitPrice' => null,
|
|
||||||
'participantCount' => $parkingParticipants,
|
|
||||||
'totalPrice' => $parkingTotal,
|
|
||||||
'subType' => Constants::GROUP_TRANSPORTATION,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $transportationItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Groups services by their subtypes for display, separating positive costs and discounts.
|
|
||||||
*
|
|
||||||
* @param array<string, array<string, mixed>> $serviceAggregation
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function groupServicesBySubtype(array $serviceAggregation): array
|
|
||||||
{
|
|
||||||
$groupedServices = [];
|
|
||||||
$servicesBySubtypeAndSign = [];
|
|
||||||
|
|
||||||
foreach ($serviceAggregation as $serviceData) {
|
|
||||||
$subType = $serviceData['subType'] ?? 'other';
|
|
||||||
if (null === $subType || '' === $subType) {
|
|
||||||
$subType = 'other';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) {
|
|
||||||
$subType = Constants::GROUP_RENTALS;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (true === in_array($subType, Constants::TOKEN_INSURANCES, true)) {
|
|
||||||
$subType = Constants::GROUP_INSURANCE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$isDiscount = $serviceData['totalPrice'] < 0;
|
|
||||||
$bucketKey = $subType.($isDiscount ? '_discount' : '_regular');
|
|
||||||
|
|
||||||
if (false === isset($servicesBySubtypeAndSign[$bucketKey])) {
|
|
||||||
$servicesBySubtypeAndSign[$bucketKey] = [
|
|
||||||
'subType' => $subType,
|
|
||||||
'isDiscount' => $isDiscount,
|
|
||||||
'services' => [],
|
|
||||||
'total' => 0.0,
|
|
||||||
'participantCount' => 0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$servicesBySubtypeAndSign[$bucketKey]['services'][] = $serviceData;
|
|
||||||
$servicesBySubtypeAndSign[$bucketKey]['total'] += $serviceData['totalPrice'];
|
|
||||||
$servicesBySubtypeAndSign[$bucketKey]['participantCount'] += $serviceData['participantCount'];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($servicesBySubtypeAndSign as $bucketData) {
|
|
||||||
$baseGroupName = $this->getGroupNameForSubtype($bucketData['subType']);
|
|
||||||
$groupName = $bucketData['isDiscount'] ? $baseGroupName.' - Rabatt' : $baseGroupName;
|
|
||||||
$services = array_map(
|
|
||||||
fn (array $serviceData): array => $this->createServicePricingLineItem($serviceData),
|
|
||||||
$bucketData['services']
|
|
||||||
);
|
|
||||||
|
|
||||||
$groupedServices[] = [
|
|
||||||
'groupName' => $groupName,
|
|
||||||
'services' => $services,
|
|
||||||
'groupTotal' => $bucketData['total'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $groupedServices;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getGroupNameForSubtype(string $subType): string
|
|
||||||
{
|
|
||||||
if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) {
|
|
||||||
return Constants::SERVICE_LABELS[Constants::GROUP_RENTALS];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Constants::SERVICE_LABELS[$subType] ?? 'Sonstige Leistungen';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function aggregateParticipantServices(
|
|
||||||
ParticipantDto $participant,
|
|
||||||
array &$serviceAggregation,
|
|
||||||
BookingDto $bookingDto,
|
|
||||||
): void {
|
|
||||||
if (null !== $participant->skiPass && null !== $participant->skiPass->price) {
|
|
||||||
$this->addToServiceAggregation($serviceAggregation, $participant->skiPass);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $participant->veg && null !== $participant->veg->price) {
|
|
||||||
$this->addToServiceAggregation($serviceAggregation, $participant->veg);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) {
|
|
||||||
$this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance);
|
|
||||||
}
|
|
||||||
|
|
||||||
$insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto);
|
|
||||||
if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price && false === $insuranceToAggregate->isNoInsurance()) {
|
|
||||||
$this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate);
|
|
||||||
}
|
|
||||||
|
|
||||||
$multipleServiceArrays = [
|
|
||||||
'courses' => $participant->courses,
|
|
||||||
'additionalServices' => $participant->additionalServices,
|
|
||||||
'board' => $participant->board,
|
|
||||||
'rentals' => $participant->rentals,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($multipleServiceArrays as $serviceArray) {
|
|
||||||
if (true === is_array($serviceArray)) {
|
|
||||||
foreach ($serviceArray as $service) {
|
|
||||||
if ($service instanceof Service && null !== $service->price) {
|
|
||||||
$this->addToServiceAggregation($serviceAggregation, $service);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function addToServiceAggregation(array &$serviceAggregation, Service $service, int $quantity = 1): void
|
|
||||||
{
|
|
||||||
$serviceKey = $service->id.'_'.$service->label;
|
|
||||||
|
|
||||||
if (false === isset($serviceAggregation[$serviceKey])) {
|
|
||||||
$serviceAggregation[$serviceKey] = [
|
|
||||||
'serviceId' => $service->id,
|
|
||||||
'label' => $service->label,
|
|
||||||
'unitPrice' => $service->price,
|
|
||||||
'participantCount' => 0,
|
|
||||||
'totalPrice' => 0.0,
|
|
||||||
'subType' => $service->subType,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$serviceAggregation[$serviceKey]['participantCount'] += $quantity;
|
|
||||||
$serviceAggregation[$serviceKey]['totalPrice'] += $service->price * $quantity;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function addInsuranceToServiceAggregation(array &$serviceAggregation, Insurance $insurance, int $quantity = 1): void
|
|
||||||
{
|
|
||||||
$serviceKey = $insurance->id.'_'.$insurance->label;
|
|
||||||
|
|
||||||
if (false === isset($serviceAggregation[$serviceKey])) {
|
|
||||||
$serviceAggregation[$serviceKey] = [
|
|
||||||
'serviceId' => $insurance->id,
|
|
||||||
'label' => $insurance->label,
|
|
||||||
'unitPrice' => $insurance->price,
|
|
||||||
'participantCount' => 0,
|
|
||||||
'totalPrice' => 0.0,
|
|
||||||
'subType' => $insurance->getSubType(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$serviceAggregation[$serviceKey]['participantCount'] += $quantity;
|
|
||||||
$serviceAggregation[$serviceKey]['totalPrice'] += $insurance->price * $quantity;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $serviceData
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function createServicePricingLineItem(array $serviceData): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'serviceId' => $serviceData['serviceId'] ?? null,
|
|
||||||
'label' => (string) ($serviceData['label'] ?? ''),
|
|
||||||
'unitPrice' => array_key_exists('unitPrice', $serviceData) ? $serviceData['unitPrice'] : null,
|
|
||||||
'participantCount' => (int) ($serviceData['participantCount'] ?? 0),
|
|
||||||
'totalPrice' => (float) ($serviceData['totalPrice'] ?? 0.0),
|
|
||||||
'subType' => $serviceData['subType'] ?? null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance
|
|
||||||
{
|
|
||||||
if (null !== $participant->insurance) {
|
|
||||||
return $participant->insurance;
|
|
||||||
}
|
|
||||||
|
|
||||||
$applicant = $bookingDto->getParticipant(0);
|
|
||||||
if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (0 === $participant->index) {
|
|
||||||
return $participant->insurance;
|
|
||||||
}
|
|
||||||
|
|
||||||
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
|
||||||
$sameTypeInsurances = $this->insuranceService->filterByType(
|
|
||||||
$selectableInsurances,
|
|
||||||
$applicant->insurance
|
|
||||||
);
|
|
||||||
$travelPrice = $this->participantPricingCalculator->calculateIndividualParticipantPriceExcludingInsurance(
|
|
||||||
$bookingDto,
|
|
||||||
$participant->index
|
|
||||||
);
|
|
||||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances(
|
|
||||||
$sameTypeInsurances,
|
|
||||||
$participant,
|
|
||||||
$bookingDto,
|
|
||||||
$travelPrice
|
|
||||||
);
|
|
||||||
|
|
||||||
return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the total price for an individual participant.
|
* Calculates the total price for an individual participant.
|
||||||
*
|
|
||||||
* This method calculates the complete price breakdown for a single participant,
|
|
||||||
* including their room allocation (full room price) and all selected services.
|
|
||||||
*
|
|
||||||
* @param BookingDto $bookingDto The booking data containing all participants
|
|
||||||
* @param int $participantIndex The index of the participant to calculate for
|
|
||||||
*
|
|
||||||
* @return float The total price for the specified participant
|
|
||||||
*/
|
*/
|
||||||
public function calculateIndividualParticipantPrice(BookingDto $bookingDto, int $participantIndex): float
|
public function calculateIndividualParticipantPrice(BookingDto $bookingDto, int $participantIndex): float
|
||||||
{
|
{
|
||||||
@@ -528,9 +69,7 @@ class BookingPriceCalculator
|
|||||||
/**
|
/**
|
||||||
* Calculates individual prices for all participants in a booking.
|
* Calculates individual prices for all participants in a booking.
|
||||||
*
|
*
|
||||||
* @param BookingDto $bookingDto The booking data containing all participants
|
* @return array<int, float>
|
||||||
*
|
|
||||||
* @return array Array indexed by participant index containing individual prices
|
|
||||||
*/
|
*/
|
||||||
public function calculateAllParticipantIndividualPrices(BookingDto $bookingDto): array
|
public function calculateAllParticipantIndividualPrices(BookingDto $bookingDto): array
|
||||||
{
|
{
|
||||||
@@ -540,17 +79,12 @@ class BookingPriceCalculator
|
|||||||
/**
|
/**
|
||||||
* Calculates the total price for an individual participant excluding insurance.
|
* Calculates the total price for an individual participant excluding insurance.
|
||||||
*
|
*
|
||||||
* This method is used for insurance eligibility filtering to avoid circular dependency
|
* Used for insurance eligibility filtering to avoid circular dependency where
|
||||||
* where insurance selection affects travel price which affects insurance eligibility.
|
* insurance selection affects travel price which affects insurance eligibility.
|
||||||
*
|
*
|
||||||
* Only includes services where versicherungsberechnung='J' in the XML. Services with
|
* Only includes services where versicherungsberechnung='J' in the XML. Services with
|
||||||
* versicherungsberechnung='N' (like CO2 compensation) are excluded from the calculation
|
* versicherungsberechnung='N' (like CO2 compensation) are excluded as per BPN API
|
||||||
* as per BPN API requirements for insurance tier determination.
|
* requirements for insurance tier determination.
|
||||||
*
|
|
||||||
* @param BookingDto $bookingDto The booking data containing all participants
|
|
||||||
* @param int $participantIndex The index of the participant to calculate for
|
|
||||||
*
|
|
||||||
* @return float The total price for the specified participant excluding insurance and non-calculated services
|
|
||||||
*/
|
*/
|
||||||
public function calculateIndividualParticipantPriceExcludingInsurance(
|
public function calculateIndividualParticipantPriceExcludingInsurance(
|
||||||
BookingDto $bookingDto,
|
BookingDto $bookingDto,
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\Model\Booking;
|
||||||
|
use App\BusProNet\Model\Insurance;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles display-ready pricing breakdowns for booking summaries and diagnostics.
|
||||||
|
*
|
||||||
|
* Transforms raw booking data into structured arrays of rooms, service groups,
|
||||||
|
* and surcharges ready for rendering in Twig templates or diagnostic payloads.
|
||||||
|
* For numeric price totals only, use BookingPriceCalculator.
|
||||||
|
*/
|
||||||
|
class BookingPricingAssembler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly RoomPricingCalculator $roomPricingCalculator,
|
||||||
|
private readonly ParticipantEligibilityChecker $participantEligibilityChecker,
|
||||||
|
private readonly InsuranceManager $insuranceService,
|
||||||
|
private readonly ParticipantPricingCalculator $participantPricingCalculator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles a complete pricing breakdown for display or diagnostic use.
|
||||||
|
*
|
||||||
|
* @return array{rooms: array, services: array, grandTotal: float, surcharges?: array}
|
||||||
|
*/
|
||||||
|
public function getPricingBreakdown(
|
||||||
|
BookingDto $bookingDto,
|
||||||
|
string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT,
|
||||||
|
): array {
|
||||||
|
$roomPricing = $this->roomPricingCalculator->calculateRoomPricing($bookingDto, $roomPricingMode);
|
||||||
|
$servicePricing = $this->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
$grandTotal = array_sum(array_column($roomPricing, 'totalPrice'))
|
||||||
|
+ array_sum(array_column($servicePricing, 'groupTotal'));
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'rooms' => $roomPricing,
|
||||||
|
'services' => $servicePricing,
|
||||||
|
'grandTotal' => $grandTotal,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (null !== $bookingDto->booking && [] !== $bookingDto->booking->surcharges) {
|
||||||
|
$surchargePricing = $this->calculateSurchargePricing($bookingDto->booking);
|
||||||
|
|
||||||
|
if ([] !== $surchargePricing) {
|
||||||
|
$result['surcharges'] = $surchargePricing;
|
||||||
|
$result['grandTotal'] += array_sum(array_column($surchargePricing, 'totalPrice'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates pricing for all selected services across all participants, grouped by subtype.
|
||||||
|
*
|
||||||
|
* Only includes services from eligible participants (those with available skipasses for their age).
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function calculateServicePricing(BookingDto $bookingDto): array
|
||||||
|
{
|
||||||
|
$participants = $bookingDto->getParticipants();
|
||||||
|
|
||||||
|
if ([] === $participants) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$serviceAggregation = [];
|
||||||
|
|
||||||
|
foreach ($participants as $participantIndex => $participant) {
|
||||||
|
if (false === $this->participantEligibilityChecker->isParticipantEligible($bookingDto, $participantIndex)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->aggregateParticipantServices($participant, $serviceAggregation, $bookingDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->aggregateTransportationServices($bookingDto) as $key => $transportationItem) {
|
||||||
|
$serviceAggregation[$key] = $transportationItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->groupServicesBySubtype($serviceAggregation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates surcharge pricing grouped by label (edit mode only).
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function calculateSurchargePricing(Booking $bookingData): array
|
||||||
|
{
|
||||||
|
$surchargePricing = [];
|
||||||
|
|
||||||
|
foreach ($bookingData->surcharges as $surcharge) {
|
||||||
|
$surchargePricing[] = [
|
||||||
|
'label' => $surcharge->label ?? 'unbekannt',
|
||||||
|
'participantCount' => count($surcharge->mapping),
|
||||||
|
'totalPrice' => $surcharge->totalPrice ?? 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $surchargePricing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates all transportation-related services and pricing into separate line items.
|
||||||
|
*
|
||||||
|
* Creates separate entries for:
|
||||||
|
* - Beförderung: Sum of all positive pickup prices and base transportation costs
|
||||||
|
* - Beförderung - Rabatt: Sum of all negative transportation prices (discounts)
|
||||||
|
* - Parkplatz: Sum of all parking service prices
|
||||||
|
*
|
||||||
|
* Only includes transportation costs from eligible participants.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function aggregateTransportationServices(BookingDto $bookingDto): array
|
||||||
|
{
|
||||||
|
$participants = $bookingDto->getParticipants();
|
||||||
|
|
||||||
|
$transportationPositiveTotal = 0.0;
|
||||||
|
$transportationDiscountTotal = 0.0;
|
||||||
|
$parkingTotal = 0.0;
|
||||||
|
|
||||||
|
$transportationParticipants = 0;
|
||||||
|
$discountParticipants = 0;
|
||||||
|
$parkingParticipants = 0;
|
||||||
|
|
||||||
|
foreach ($participants as $participantIndex => $participant) {
|
||||||
|
if (false === $this->participantEligibilityChecker->isParticipantEligible($bookingDto, $participantIndex)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$participantTransportationPositiveCost = 0.0;
|
||||||
|
$participantTransportationDiscountCost = 0.0;
|
||||||
|
$participantParkingCost = 0.0;
|
||||||
|
|
||||||
|
if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) {
|
||||||
|
if ($participant->transportationOutbound->price < 0) {
|
||||||
|
$participantTransportationDiscountCost += $participant->transportationOutbound->price;
|
||||||
|
} else {
|
||||||
|
$participantTransportationPositiveCost += $participant->transportationOutbound->price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) {
|
||||||
|
if ($participant->transportationInbound->price < 0) {
|
||||||
|
$participantTransportationDiscountCost += $participant->transportationInbound->price;
|
||||||
|
} else {
|
||||||
|
$participantTransportationPositiveCost += $participant->transportationInbound->price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasOutboundBus = null !== $participant->transportationOutbound
|
||||||
|
&& 'BUS' === $participant->transportationOutbound->subType;
|
||||||
|
|
||||||
|
if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) {
|
||||||
|
if ($participant->pickup->price < 0) {
|
||||||
|
$participantTransportationDiscountCost += $participant->pickup->price;
|
||||||
|
} else {
|
||||||
|
$participantTransportationPositiveCost += $participant->pickup->price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $participant->dropOff && null !== $participant->dropOff->price) {
|
||||||
|
if ($participant->dropOff->price < 0) {
|
||||||
|
$participantTransportationDiscountCost += $participant->dropOff->price;
|
||||||
|
} else {
|
||||||
|
$participantTransportationPositiveCost += $participant->dropOff->price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $participant->parkingService && null !== $participant->parkingService->price) {
|
||||||
|
$participantParkingCost += $participant->parkingService->price;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($participantTransportationPositiveCost > 0) {
|
||||||
|
$transportationPositiveTotal += $participantTransportationPositiveCost;
|
||||||
|
++$transportationParticipants;
|
||||||
|
}
|
||||||
|
if ($participantTransportationDiscountCost < 0) {
|
||||||
|
$transportationDiscountTotal += $participantTransportationDiscountCost;
|
||||||
|
++$discountParticipants;
|
||||||
|
}
|
||||||
|
if ($participantParkingCost > 0) {
|
||||||
|
$parkingTotal += $participantParkingCost;
|
||||||
|
++$parkingParticipants;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$transportationItems = [];
|
||||||
|
|
||||||
|
if ($transportationPositiveTotal > 0) {
|
||||||
|
$transportationItems['transportation_positive'] = [
|
||||||
|
'serviceId' => 'transportation_positive',
|
||||||
|
'label' => 'Beförderung',
|
||||||
|
'unitPrice' => null,
|
||||||
|
'participantCount' => $transportationParticipants,
|
||||||
|
'totalPrice' => $transportationPositiveTotal,
|
||||||
|
'subType' => Constants::GROUP_TRANSPORTATION,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($transportationDiscountTotal < 0) {
|
||||||
|
$transportationItems['transportation_discount'] = [
|
||||||
|
'serviceId' => 'transportation_discount',
|
||||||
|
'label' => 'Beförderung - Rabatt',
|
||||||
|
'unitPrice' => null,
|
||||||
|
'participantCount' => $discountParticipants,
|
||||||
|
'totalPrice' => $transportationDiscountTotal,
|
||||||
|
'subType' => Constants::GROUP_TRANSPORTATION.'_discount',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parkingTotal > 0) {
|
||||||
|
$transportationItems['transportation_parking'] = [
|
||||||
|
'serviceId' => 'transportation_parking',
|
||||||
|
'label' => Constants::SERVICE_LABELS[Constants::TOKEN_PARKING],
|
||||||
|
'unitPrice' => null,
|
||||||
|
'participantCount' => $parkingParticipants,
|
||||||
|
'totalPrice' => $parkingTotal,
|
||||||
|
'subType' => Constants::GROUP_TRANSPORTATION,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $transportationItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups services by their subtypes for display, separating positive costs and discounts.
|
||||||
|
*
|
||||||
|
* @param array<string, array<string, mixed>> $serviceAggregation
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function groupServicesBySubtype(array $serviceAggregation): array
|
||||||
|
{
|
||||||
|
$groupedServices = [];
|
||||||
|
$servicesBySubtypeAndSign = [];
|
||||||
|
|
||||||
|
foreach ($serviceAggregation as $serviceData) {
|
||||||
|
$subType = $serviceData['subType'] ?? 'other';
|
||||||
|
if (null === $subType || '' === $subType) {
|
||||||
|
$subType = 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) {
|
||||||
|
$subType = Constants::GROUP_RENTALS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true === in_array($subType, Constants::TOKEN_INSURANCES, true)) {
|
||||||
|
$subType = Constants::GROUP_INSURANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isDiscount = $serviceData['totalPrice'] < 0;
|
||||||
|
$bucketKey = $subType.($isDiscount ? '_discount' : '_regular');
|
||||||
|
|
||||||
|
if (false === isset($servicesBySubtypeAndSign[$bucketKey])) {
|
||||||
|
$servicesBySubtypeAndSign[$bucketKey] = [
|
||||||
|
'subType' => $subType,
|
||||||
|
'isDiscount' => $isDiscount,
|
||||||
|
'services' => [],
|
||||||
|
'total' => 0.0,
|
||||||
|
'participantCount' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$servicesBySubtypeAndSign[$bucketKey]['services'][] = $serviceData;
|
||||||
|
$servicesBySubtypeAndSign[$bucketKey]['total'] += $serviceData['totalPrice'];
|
||||||
|
$servicesBySubtypeAndSign[$bucketKey]['participantCount'] += $serviceData['participantCount'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($servicesBySubtypeAndSign as $bucketData) {
|
||||||
|
$baseGroupName = $this->getGroupNameForSubtype($bucketData['subType']);
|
||||||
|
$groupName = $bucketData['isDiscount'] ? $baseGroupName.' - Rabatt' : $baseGroupName;
|
||||||
|
$services = array_map(
|
||||||
|
fn (array $serviceData): array => $this->createServicePricingLineItem($serviceData),
|
||||||
|
$bucketData['services']
|
||||||
|
);
|
||||||
|
|
||||||
|
$groupedServices[] = [
|
||||||
|
'groupName' => $groupName,
|
||||||
|
'services' => $services,
|
||||||
|
'groupTotal' => $bucketData['total'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groupedServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getGroupNameForSubtype(string $subType): string
|
||||||
|
{
|
||||||
|
if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) {
|
||||||
|
return Constants::SERVICE_LABELS[Constants::GROUP_RENTALS];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Constants::SERVICE_LABELS[$subType] ?? 'Sonstige Leistungen';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aggregateParticipantServices(
|
||||||
|
ParticipantDto $participant,
|
||||||
|
array &$serviceAggregation,
|
||||||
|
BookingDto $bookingDto,
|
||||||
|
): void {
|
||||||
|
if (null !== $participant->skiPass && null !== $participant->skiPass->price) {
|
||||||
|
$this->addToServiceAggregation($serviceAggregation, $participant->skiPass);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $participant->veg && null !== $participant->veg->price) {
|
||||||
|
$this->addToServiceAggregation($serviceAggregation, $participant->veg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) {
|
||||||
|
$this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance);
|
||||||
|
}
|
||||||
|
|
||||||
|
$insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto);
|
||||||
|
if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price && false === $insuranceToAggregate->isNoInsurance()) {
|
||||||
|
$this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate);
|
||||||
|
}
|
||||||
|
|
||||||
|
$multipleServiceArrays = [
|
||||||
|
'courses' => $participant->courses,
|
||||||
|
'additionalServices' => $participant->additionalServices,
|
||||||
|
'board' => $participant->board,
|
||||||
|
'rentals' => $participant->rentals,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($multipleServiceArrays as $serviceArray) {
|
||||||
|
if (true === is_array($serviceArray)) {
|
||||||
|
foreach ($serviceArray as $service) {
|
||||||
|
if ($service instanceof Service && null !== $service->price) {
|
||||||
|
$this->addToServiceAggregation($serviceAggregation, $service);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addToServiceAggregation(array &$serviceAggregation, Service $service, int $quantity = 1): void
|
||||||
|
{
|
||||||
|
$serviceKey = $service->id.'_'.$service->label;
|
||||||
|
|
||||||
|
if (false === isset($serviceAggregation[$serviceKey])) {
|
||||||
|
$serviceAggregation[$serviceKey] = [
|
||||||
|
'serviceId' => $service->id,
|
||||||
|
'label' => $service->label,
|
||||||
|
'unitPrice' => $service->price,
|
||||||
|
'participantCount' => 0,
|
||||||
|
'totalPrice' => 0.0,
|
||||||
|
'subType' => $service->subType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$serviceAggregation[$serviceKey]['participantCount'] += $quantity;
|
||||||
|
$serviceAggregation[$serviceKey]['totalPrice'] += $service->price * $quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addInsuranceToServiceAggregation(array &$serviceAggregation, Insurance $insurance, int $quantity = 1): void
|
||||||
|
{
|
||||||
|
$serviceKey = $insurance->id.'_'.$insurance->label;
|
||||||
|
|
||||||
|
if (false === isset($serviceAggregation[$serviceKey])) {
|
||||||
|
$serviceAggregation[$serviceKey] = [
|
||||||
|
'serviceId' => $insurance->id,
|
||||||
|
'label' => $insurance->label,
|
||||||
|
'unitPrice' => $insurance->price,
|
||||||
|
'participantCount' => 0,
|
||||||
|
'totalPrice' => 0.0,
|
||||||
|
'subType' => $insurance->getSubType(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$serviceAggregation[$serviceKey]['participantCount'] += $quantity;
|
||||||
|
$serviceAggregation[$serviceKey]['totalPrice'] += $insurance->price * $quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $serviceData
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function createServicePricingLineItem(array $serviceData): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'serviceId' => $serviceData['serviceId'] ?? null,
|
||||||
|
'label' => (string) ($serviceData['label'] ?? ''),
|
||||||
|
'unitPrice' => array_key_exists('unitPrice', $serviceData) ? $serviceData['unitPrice'] : null,
|
||||||
|
'participantCount' => (int) ($serviceData['participantCount'] ?? 0),
|
||||||
|
'totalPrice' => (float) ($serviceData['totalPrice'] ?? 0.0),
|
||||||
|
'subType' => $serviceData['subType'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance
|
||||||
|
{
|
||||||
|
if (null !== $participant->insurance) {
|
||||||
|
return $participant->insurance;
|
||||||
|
}
|
||||||
|
|
||||||
|
$applicant = $bookingDto->getParticipant(0);
|
||||||
|
if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 === $participant->index) {
|
||||||
|
return $participant->insurance;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
||||||
|
$sameTypeInsurances = $this->insuranceService->filterByType(
|
||||||
|
$selectableInsurances,
|
||||||
|
$applicant->insurance
|
||||||
|
);
|
||||||
|
$travelPrice = $this->participantPricingCalculator->calculateIndividualParticipantPriceExcludingInsurance(
|
||||||
|
$bookingDto,
|
||||||
|
$participant->index
|
||||||
|
);
|
||||||
|
$eligibleInsurances = $this->insuranceService->getEligibleInsurances(
|
||||||
|
$sameTypeInsurances,
|
||||||
|
$participant,
|
||||||
|
$bookingDto,
|
||||||
|
$travelPrice
|
||||||
|
);
|
||||||
|
|
||||||
|
return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ class BookingSummaryAssembler
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BookingPriceCalculator $priceCalculator,
|
private readonly BookingPriceCalculator $priceCalculator,
|
||||||
|
private readonly BookingPricingAssembler $pricingAssembler,
|
||||||
private readonly CmsDataProvider $cmsDataService,
|
private readonly CmsDataProvider $cmsDataService,
|
||||||
private readonly HotelLoader $hotelLoader,
|
private readonly HotelLoader $hotelLoader,
|
||||||
private readonly CountryDataProvider $countryDataProvider,
|
private readonly CountryDataProvider $countryDataProvider,
|
||||||
@@ -58,7 +59,7 @@ class BookingSummaryAssembler
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get detailed pricing breakdown
|
// Get detailed pricing breakdown
|
||||||
$pricingData = $this->priceCalculator->getPricingBreakdown($bookingDto, $roomPricingMode);
|
$pricingData = $this->pricingAssembler->getPricingBreakdown($bookingDto, $roomPricingMode);
|
||||||
|
|
||||||
// Fetch CMS data (images, etc.)
|
// Fetch CMS data (images, etc.)
|
||||||
$productCode = $bookingDto->travel->productCode;
|
$productCode = $bookingDto->travel->productCode;
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Service;
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Insurance;
|
||||||
use App\BusProNet\Model\Room;
|
use App\BusProNet\Model\Room;
|
||||||
use App\BusProNet\Model\Booking;
|
|
||||||
use App\BusProNet\Model\Service;
|
use App\BusProNet\Model\Service;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Form\Model\RoomSelectionDto;
|
use App\Form\Model\RoomSelectionDto;
|
||||||
use App\Service\BookingPriceCalculator;
|
use App\Service\BookingPriceCalculator;
|
||||||
|
use App\Service\BookingPricingAssembler;
|
||||||
use App\Service\InsuranceManager;
|
use App\Service\InsuranceManager;
|
||||||
use App\Service\ParticipantEligibilityChecker;
|
use App\Service\ParticipantEligibilityChecker;
|
||||||
use App\Service\ParticipantPricingCalculator;
|
use App\Service\ParticipantPricingCalculator;
|
||||||
@@ -21,292 +22,33 @@ use PHPUnit\Framework\TestCase;
|
|||||||
class BookingPriceCalculatorTest extends TestCase
|
class BookingPriceCalculatorTest extends TestCase
|
||||||
{
|
{
|
||||||
private BookingPriceCalculator $service;
|
private BookingPriceCalculator $service;
|
||||||
private ParticipantEligibilityChecker $participantEligibilityService;
|
|
||||||
private RoomPricingCalculator $roomPricingCalculator;
|
private RoomPricingCalculator $roomPricingCalculator;
|
||||||
private ParticipantPricingCalculator $participantPricingCalculator;
|
private ParticipantPricingCalculator $participantPricingCalculator;
|
||||||
|
private BookingPricingAssembler $pricingAssembler;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->participantEligibilityService = $this->createMock(ParticipantEligibilityChecker::class);
|
$eligibilityChecker = $this->createMock(ParticipantEligibilityChecker::class);
|
||||||
$this->participantEligibilityService->method('isParticipantEligible')->willReturn(true);
|
$eligibilityChecker->method('isParticipantEligible')->willReturn(true);
|
||||||
|
|
||||||
$this->roomPricingCalculator = new RoomPricingCalculator();
|
$this->roomPricingCalculator = new RoomPricingCalculator();
|
||||||
$this->participantPricingCalculator = new ParticipantPricingCalculator($this->roomPricingCalculator);
|
$this->participantPricingCalculator = new ParticipantPricingCalculator($this->roomPricingCalculator);
|
||||||
|
$this->pricingAssembler = new BookingPricingAssembler(
|
||||||
|
$this->roomPricingCalculator,
|
||||||
|
$eligibilityChecker,
|
||||||
|
new InsuranceManager(),
|
||||||
|
$this->participantPricingCalculator,
|
||||||
|
);
|
||||||
|
|
||||||
$this->service = $this->createService($this->participantEligibilityService);
|
$this->service = new BookingPriceCalculator(
|
||||||
}
|
$this->roomPricingCalculator,
|
||||||
|
$this->pricingAssembler,
|
||||||
public function testCalculateRoomPricingWithParticipantBasedCalculation(): void
|
$this->participantPricingCalculator,
|
||||||
{
|
);
|
||||||
// Create test data: 2 double rooms at €100 each, minPax=2
|
|
||||||
$room = new Room();
|
|
||||||
$room->id = 1;
|
|
||||||
$room->price = 100.0;
|
|
||||||
$room->minPax = 2;
|
|
||||||
$room->label = 'Double Room';
|
|
||||||
|
|
||||||
$travel = new Travel();
|
|
||||||
$travel->rooms = [$room];
|
|
||||||
|
|
||||||
$roomSelection = new RoomSelectionDto();
|
|
||||||
$roomSelection->id = 1;
|
|
||||||
$roomSelection->quantity = 2; // 2 rooms selected
|
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 1);
|
|
||||||
$bookingDto->roomSelections = [$roomSelection];
|
|
||||||
$bookingDto->participants = [
|
|
||||||
$this->createParticipantWithAssignedRoom(1),
|
|
||||||
$this->createParticipantWithAssignedRoom(1),
|
|
||||||
$this->createParticipantWithAssignedRoom(1),
|
|
||||||
$this->createParticipantWithAssignedRoom(1),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Test the calculation
|
|
||||||
$result = $this->service->calculateRoomPricing($bookingDto);
|
|
||||||
|
|
||||||
// Expected: 4 assigned participants × €100 = €400 total
|
|
||||||
$this->assertCount(1, $result);
|
|
||||||
$this->assertEquals(1, $result[0]['roomId']);
|
|
||||||
$this->assertEquals('Double Room', $result[0]['label']);
|
|
||||||
$this->assertEquals(2, $result[0]['quantity']); // 2 rooms
|
|
||||||
$this->assertEquals(4, $result[0]['participantCount']); // billed units follow assigned participants
|
|
||||||
$this->assertEquals(100.0, $result[0]['unitPrice']); // €100 per participant
|
|
||||||
$this->assertEquals(400.0, $result[0]['totalPrice']); // €400 total
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCalculateRoomPricingWithSingleRoomSelection(): void
|
|
||||||
{
|
|
||||||
// Create test data: 1 triple room at €150, minPax=3
|
|
||||||
$room = new Room();
|
|
||||||
$room->id = 2;
|
|
||||||
$room->price = 150.0;
|
|
||||||
$room->minPax = 3;
|
|
||||||
$room->label = 'Triple Room';
|
|
||||||
|
|
||||||
$travel = new Travel();
|
|
||||||
$travel->rooms = [$room];
|
|
||||||
|
|
||||||
$roomSelection = new RoomSelectionDto();
|
|
||||||
$roomSelection->id = 2;
|
|
||||||
$roomSelection->quantity = 1; // 1 room selected
|
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 1);
|
|
||||||
$bookingDto->roomSelections = [$roomSelection];
|
|
||||||
$bookingDto->participants = [
|
|
||||||
$this->createParticipantWithAssignedRoom(2),
|
|
||||||
$this->createParticipantWithAssignedRoom(2),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Test the calculation
|
|
||||||
$result = $this->service->calculateRoomPricing($bookingDto);
|
|
||||||
|
|
||||||
// Expected: 2 assigned participants × €150 = €300 total
|
|
||||||
$this->assertCount(1, $result);
|
|
||||||
$this->assertEquals(2, $result[0]['roomId']);
|
|
||||||
$this->assertEquals('Triple Room', $result[0]['label']);
|
|
||||||
$this->assertEquals(1, $result[0]['quantity']); // 1 room
|
|
||||||
$this->assertEquals(2, $result[0]['participantCount']); // billed units follow assigned participants
|
|
||||||
$this->assertEquals(150.0, $result[0]['unitPrice']); // €150 per participant
|
|
||||||
$this->assertEquals(300.0, $result[0]['totalPrice']); // €300 total
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCalculateRoomPricingWithMultipleRoomTypes(): void
|
|
||||||
{
|
|
||||||
// Create test data: Multiple room types
|
|
||||||
$singleRoom = new Room();
|
|
||||||
$singleRoom->id = 1;
|
|
||||||
$singleRoom->price = 80.0;
|
|
||||||
$singleRoom->minPax = 1;
|
|
||||||
$singleRoom->label = 'Single Room';
|
|
||||||
|
|
||||||
$doubleRoom = new Room();
|
|
||||||
$doubleRoom->id = 2;
|
|
||||||
$doubleRoom->price = 120.0;
|
|
||||||
$doubleRoom->minPax = 2;
|
|
||||||
$doubleRoom->label = 'Double Room';
|
|
||||||
|
|
||||||
$travel = new Travel();
|
|
||||||
$travel->rooms = [$singleRoom, $doubleRoom];
|
|
||||||
|
|
||||||
$singleRoomSelection = new RoomSelectionDto();
|
|
||||||
$singleRoomSelection->id = 1;
|
|
||||||
$singleRoomSelection->quantity = 1; // 1 single room
|
|
||||||
|
|
||||||
$doubleRoomSelection = new RoomSelectionDto();
|
|
||||||
$doubleRoomSelection->id = 2;
|
|
||||||
$doubleRoomSelection->quantity = 2; // 2 double rooms
|
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 1);
|
|
||||||
$bookingDto->roomSelections = [$singleRoomSelection, $doubleRoomSelection];
|
|
||||||
$bookingDto->participants = [
|
|
||||||
$this->createParticipantWithAssignedRoom(1),
|
|
||||||
$this->createParticipantWithAssignedRoom(2),
|
|
||||||
$this->createParticipantWithAssignedRoom(2),
|
|
||||||
$this->createParticipantWithAssignedRoom(2),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Test the calculation
|
|
||||||
$result = $this->service->calculateRoomPricing($bookingDto);
|
|
||||||
|
|
||||||
// Expected:
|
|
||||||
// - Single: 1 assigned participant × €80 = €80
|
|
||||||
// - Double: 3 assigned participants × €120 = €360
|
|
||||||
$this->assertCount(2, $result);
|
|
||||||
|
|
||||||
// Single room result
|
|
||||||
$singleResult = $result[0];
|
|
||||||
$this->assertEquals(1, $singleResult['roomId']);
|
|
||||||
$this->assertEquals(1, $singleResult['quantity']);
|
|
||||||
$this->assertEquals(1, $singleResult['participantCount']);
|
|
||||||
$this->assertEquals(80.0, $singleResult['unitPrice']);
|
|
||||||
$this->assertEquals(80.0, $singleResult['totalPrice']);
|
|
||||||
|
|
||||||
// Double room result
|
|
||||||
$doubleResult = $result[1];
|
|
||||||
$this->assertEquals(2, $doubleResult['roomId']);
|
|
||||||
$this->assertEquals(2, $doubleResult['quantity']);
|
|
||||||
$this->assertEquals(3, $doubleResult['participantCount']);
|
|
||||||
$this->assertEquals(120.0, $doubleResult['unitPrice']);
|
|
||||||
$this->assertEquals(360.0, $doubleResult['totalPrice']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCalculateRoomPricingSkipsRoomsWithNullPrice(): void
|
|
||||||
{
|
|
||||||
$room = new Room();
|
|
||||||
$room->id = 1;
|
|
||||||
$room->price = null; // No price set
|
|
||||||
$room->minPax = 2;
|
|
||||||
$room->label = 'Free Room';
|
|
||||||
|
|
||||||
$travel = new Travel();
|
|
||||||
$travel->rooms = [$room];
|
|
||||||
|
|
||||||
$roomSelection = new RoomSelectionDto();
|
|
||||||
$roomSelection->id = 1;
|
|
||||||
$roomSelection->quantity = 1;
|
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 1);
|
|
||||||
$bookingDto->roomSelections = [$roomSelection];
|
|
||||||
|
|
||||||
$result = $this->service->calculateRoomPricing($bookingDto);
|
|
||||||
|
|
||||||
// Should skip rooms with null price
|
|
||||||
$this->assertEmpty($result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCalculateRoomPricingWithZeroQuantityRooms(): void
|
|
||||||
{
|
|
||||||
$room = new Room();
|
|
||||||
$room->id = 1;
|
|
||||||
$room->price = 100.0;
|
|
||||||
$room->minPax = 2;
|
|
||||||
$room->label = 'Double Room';
|
|
||||||
|
|
||||||
$travel = new Travel();
|
|
||||||
$travel->rooms = [$room];
|
|
||||||
|
|
||||||
$roomSelection = new RoomSelectionDto();
|
|
||||||
$roomSelection->id = 1;
|
|
||||||
$roomSelection->quantity = 0; // Zero quantity - not selected
|
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 1);
|
|
||||||
$bookingDto->roomSelections = [$roomSelection];
|
|
||||||
|
|
||||||
$result = $this->service->calculateRoomPricing($bookingDto);
|
|
||||||
|
|
||||||
// Should skip rooms with zero quantity
|
|
||||||
$this->assertEmpty($result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCalculateRoomPricingShowsSelectedRoomWithZeroAssignments(): void
|
|
||||||
{
|
|
||||||
$room = new Room();
|
|
||||||
$room->id = 1;
|
|
||||||
$room->price = 100.0;
|
|
||||||
$room->label = 'Double Room';
|
|
||||||
|
|
||||||
$travel = new Travel();
|
|
||||||
$travel->rooms = [$room];
|
|
||||||
|
|
||||||
$roomSelection = new RoomSelectionDto();
|
|
||||||
$roomSelection->id = 1;
|
|
||||||
$roomSelection->quantity = 1;
|
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 1);
|
|
||||||
$bookingDto->roomSelections = [$roomSelection];
|
|
||||||
$bookingDto->participants = []; // nothing assigned yet
|
|
||||||
|
|
||||||
$result = $this->service->calculateRoomPricing($bookingDto);
|
|
||||||
|
|
||||||
$this->assertCount(1, $result);
|
|
||||||
$this->assertEquals(1, $result[0]['roomId']);
|
|
||||||
$this->assertEquals(1, $result[0]['quantity']);
|
|
||||||
$this->assertEquals(0, $result[0]['participantCount']);
|
|
||||||
$this->assertEquals(0.0, $result[0]['totalPrice']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCalculateRoomPricingUsesSelectionModeForStepOneSummary(): void
|
|
||||||
{
|
|
||||||
$room = new Room();
|
|
||||||
$room->id = 3;
|
|
||||||
$room->price = 229.0;
|
|
||||||
$room->minPax = 2;
|
|
||||||
$room->label = 'Doppelzimmer Dusche/WC';
|
|
||||||
|
|
||||||
$travel = new Travel();
|
|
||||||
$travel->rooms = [$room];
|
|
||||||
|
|
||||||
$roomSelection = new RoomSelectionDto();
|
|
||||||
$roomSelection->id = 3;
|
|
||||||
$roomSelection->quantity = 1;
|
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 1);
|
|
||||||
$bookingDto->roomSelections = [$roomSelection];
|
|
||||||
$bookingDto->participants = [
|
|
||||||
$this->createParticipantWithAssignedRoom(3),
|
|
||||||
$this->createParticipantWithAssignedRoom(3),
|
|
||||||
];
|
|
||||||
|
|
||||||
$result = $this->service->calculateRoomPricing($bookingDto, RoomPricingCalculator::PRICING_MODE_SELECTION);
|
|
||||||
|
|
||||||
$this->assertCount(1, $result);
|
|
||||||
$this->assertEquals(2, $result[0]['participantCount']);
|
|
||||||
$this->assertEquals(458.0, $result[0]['totalPrice']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCalculateRoomPricingInEditModeUsesStoredIndividualPrices(): void
|
|
||||||
{
|
|
||||||
$travel = new Travel();
|
|
||||||
$bookingDto = new BookingDto($travel, 2);
|
|
||||||
|
|
||||||
$participantA = new ParticipantDto();
|
|
||||||
$participantA->assignedRoomId = 10;
|
|
||||||
$participantB = new ParticipantDto();
|
|
||||||
$participantB->assignedRoomId = 10;
|
|
||||||
$bookingDto->participants = [$participantA, $participantB];
|
|
||||||
|
|
||||||
$booking = new Booking();
|
|
||||||
$room = new Room();
|
|
||||||
$room->id = 10;
|
|
||||||
$room->label = 'Stored Room';
|
|
||||||
$room->mapping = [0, 1];
|
|
||||||
$room->individualPrice = [0 => 200.0, 1 => 220.0];
|
|
||||||
$room->totalCount = 1;
|
|
||||||
$booking->rooms = [$room];
|
|
||||||
$bookingDto->booking = $booking;
|
|
||||||
|
|
||||||
$result = $this->service->calculateRoomPricing($bookingDto);
|
|
||||||
|
|
||||||
$this->assertCount(1, $result);
|
|
||||||
$this->assertEquals(10, $result[0]['roomId']);
|
|
||||||
$this->assertEquals(2, $result[0]['participantCount']);
|
|
||||||
$this->assertEquals(210.0, $result[0]['unitPrice']);
|
|
||||||
$this->assertEquals(420.0, $result[0]['totalPrice']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCalculateIndividualParticipantPriceWithRoomAndServices(): void
|
public function testCalculateIndividualParticipantPriceWithRoomAndServices(): void
|
||||||
{
|
{
|
||||||
// Create test room
|
|
||||||
$room = new Room();
|
$room = new Room();
|
||||||
$room->id = 1;
|
$room->id = 1;
|
||||||
$room->price = 100.0;
|
$room->price = 100.0;
|
||||||
@@ -315,14 +57,12 @@ class BookingPriceCalculatorTest extends TestCase
|
|||||||
$travel = new Travel();
|
$travel = new Travel();
|
||||||
$travel->rooms = [$room];
|
$travel->rooms = [$room];
|
||||||
|
|
||||||
// Create test services
|
|
||||||
$skiPass = new Service();
|
$skiPass = new Service();
|
||||||
$skiPass->price = 50.0;
|
$skiPass->price = 50.0;
|
||||||
|
|
||||||
$course = new Service();
|
$course = new Service();
|
||||||
$course->price = 30.0;
|
$course->price = 30.0;
|
||||||
|
|
||||||
// Create participant with room assignment and services
|
|
||||||
$participant = new ParticipantDto();
|
$participant = new ParticipantDto();
|
||||||
$participant->assignedRoomId = 1;
|
$participant->assignedRoomId = 1;
|
||||||
$participant->skiPass = $skiPass;
|
$participant->skiPass = $skiPass;
|
||||||
@@ -333,7 +73,7 @@ class BookingPriceCalculatorTest extends TestCase
|
|||||||
|
|
||||||
$result = $this->service->calculateIndividualParticipantPrice($bookingDto, 0);
|
$result = $this->service->calculateIndividualParticipantPrice($bookingDto, 0);
|
||||||
|
|
||||||
// Expected: €100 (room) + €50 (ski pass) + €30 (course) = €180
|
// €100 (room) + €50 (ski pass) + €30 (course) = €180
|
||||||
$this->assertEquals(180.0, $result);
|
$this->assertEquals(180.0, $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,13 +81,11 @@ class BookingPriceCalculatorTest extends TestCase
|
|||||||
{
|
{
|
||||||
$travel = new Travel();
|
$travel = new Travel();
|
||||||
|
|
||||||
// Create test services
|
|
||||||
$skiPass = new Service();
|
$skiPass = new Service();
|
||||||
$skiPass->price = 50.0;
|
$skiPass->price = 50.0;
|
||||||
|
|
||||||
// Create participant without room assignment
|
|
||||||
$participant = new ParticipantDto();
|
$participant = new ParticipantDto();
|
||||||
$participant->assignedRoomId = null; // No room assigned
|
$participant->assignedRoomId = null;
|
||||||
$participant->skiPass = $skiPass;
|
$participant->skiPass = $skiPass;
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 1);
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
@@ -355,7 +93,6 @@ class BookingPriceCalculatorTest extends TestCase
|
|||||||
|
|
||||||
$result = $this->service->calculateIndividualParticipantPrice($bookingDto, 0);
|
$result = $this->service->calculateIndividualParticipantPrice($bookingDto, 0);
|
||||||
|
|
||||||
// Expected: €0 (no room) + €50 (ski pass) = €50
|
|
||||||
$this->assertEquals(50.0, $result);
|
$this->assertEquals(50.0, $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,13 +104,11 @@ class BookingPriceCalculatorTest extends TestCase
|
|||||||
|
|
||||||
$result = $this->service->calculateIndividualParticipantPrice($bookingDto, 0);
|
$result = $this->service->calculateIndividualParticipantPrice($bookingDto, 0);
|
||||||
|
|
||||||
// Expected: €0 for non-existent participant
|
|
||||||
$this->assertEquals(0.0, $result);
|
$this->assertEquals(0.0, $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCalculateAllParticipantIndividualPricesWithMultipleParticipants(): void
|
public function testCalculateAllParticipantIndividualPricesWithMultipleParticipants(): void
|
||||||
{
|
{
|
||||||
// Create test rooms
|
|
||||||
$singleRoom = new Room();
|
$singleRoom = new Room();
|
||||||
$singleRoom->id = 1;
|
$singleRoom->id = 1;
|
||||||
$singleRoom->price = 80.0;
|
$singleRoom->price = 80.0;
|
||||||
@@ -387,34 +122,31 @@ class BookingPriceCalculatorTest extends TestCase
|
|||||||
$travel = new Travel();
|
$travel = new Travel();
|
||||||
$travel->rooms = [$singleRoom, $doubleRoom];
|
$travel->rooms = [$singleRoom, $doubleRoom];
|
||||||
|
|
||||||
// Create test services
|
|
||||||
$skiPass = new Service();
|
$skiPass = new Service();
|
||||||
$skiPass->price = 50.0;
|
$skiPass->price = 50.0;
|
||||||
|
|
||||||
$course = new Service();
|
$course = new Service();
|
||||||
$course->price = 30.0;
|
$course->price = 30.0;
|
||||||
|
|
||||||
// Create participants
|
|
||||||
$participant1 = new ParticipantDto();
|
$participant1 = new ParticipantDto();
|
||||||
$participant1->assignedRoomId = 1; // Single room
|
$participant1->assignedRoomId = 1;
|
||||||
$participant1->skiPass = $skiPass;
|
$participant1->skiPass = $skiPass;
|
||||||
|
|
||||||
$participant2 = new ParticipantDto();
|
$participant2 = new ParticipantDto();
|
||||||
$participant2->assignedRoomId = 2; // Double room
|
$participant2->assignedRoomId = 2;
|
||||||
$participant2->courses = [$course];
|
$participant2->courses = [$course];
|
||||||
|
|
||||||
$participant3 = new ParticipantDto();
|
$participant3 = new ParticipantDto();
|
||||||
$participant3->assignedRoomId = null; // No room assigned
|
$participant3->assignedRoomId = null;
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 1);
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
||||||
|
|
||||||
$result = $this->service->calculateAllParticipantIndividualPrices($bookingDto);
|
$result = $this->service->calculateAllParticipantIndividualPrices($bookingDto);
|
||||||
|
|
||||||
// Expected results:
|
// Participant 0: €80 + €50 = €130
|
||||||
// Participant 0: €80 (single room) + €50 (ski pass) = €130
|
// Participant 1: €120 + €30 = €150
|
||||||
// Participant 1: €120 (double room) + €30 (course) = €150
|
// Participant 2: €0
|
||||||
// Participant 2: €0 (no room) + €0 (no services) = €0
|
|
||||||
$this->assertCount(3, $result);
|
$this->assertCount(3, $result);
|
||||||
$this->assertEquals(130.0, $result[0]);
|
$this->assertEquals(130.0, $result[0]);
|
||||||
$this->assertEquals(150.0, $result[1]);
|
$this->assertEquals(150.0, $result[1]);
|
||||||
@@ -432,86 +164,60 @@ class BookingPriceCalculatorTest extends TestCase
|
|||||||
$this->assertEmpty($result);
|
$this->assertEmpty($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testServicePricingWithoutBulkInsurance(): void
|
public function testCalculateGrandTotalCombinesRoomsAndServices(): void
|
||||||
{
|
{
|
||||||
// Test that aggregation works normally when bulk insurance is not enabled
|
$room = new Room();
|
||||||
$travel = new Travel();
|
$room->id = 1;
|
||||||
$travel->insurances = [];
|
$room->price = 100.0;
|
||||||
|
$room->minPax = 1;
|
||||||
// Create participants with their own insurances
|
|
||||||
$insurance1 = $this->createInsurance(1, 'Insurance A', 50.0);
|
|
||||||
$insurance2 = $this->createInsurance(2, 'Insurance B', 75.0);
|
|
||||||
|
|
||||||
$participant1 = new ParticipantDto();
|
|
||||||
$participant1->index = 0;
|
|
||||||
$participant1->insurance = $insurance1;
|
|
||||||
$participant1->bulkInsuranceBooking = false;
|
|
||||||
|
|
||||||
$participant2 = new ParticipantDto();
|
|
||||||
$participant2->index = 1;
|
|
||||||
$participant2->insurance = $insurance2;
|
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 2);
|
|
||||||
$bookingDto->participants = [$participant1, $participant2];
|
|
||||||
|
|
||||||
$result = $this->service->calculateServicePricing($bookingDto);
|
|
||||||
|
|
||||||
// Expected: Both insurances should be counted separately
|
|
||||||
$this->assertNotEmpty($result);
|
|
||||||
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
|
||||||
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
|
||||||
$this->assertCount(2, $insuranceGroup['services'], 'Should have 2 different insurance line items');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testServicePricingWithBulkInsuranceSamePriceTier(): void
|
|
||||||
{
|
|
||||||
// Test that bulk insurance counts all participants when enabled with same price tier
|
|
||||||
$insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
|
|
||||||
$insurance->travelPriceFrom = 0.0; // Accepts all prices
|
|
||||||
$insurance->travelPriceTo = 10000.0;
|
|
||||||
|
|
||||||
$travel = new Travel();
|
$travel = new Travel();
|
||||||
$travel->insurances = [$insurance];
|
$travel->rooms = [$room];
|
||||||
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
|
|
||||||
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
|
|
||||||
|
|
||||||
// Applicant with bulk insurance enabled
|
$skiPass = new Service();
|
||||||
$participant1 = new ParticipantDto();
|
$skiPass->price = 40.0;
|
||||||
$participant1->index = 0;
|
|
||||||
$participant1->insurance = $insurance;
|
|
||||||
$participant1->bulkInsuranceBooking = true;
|
|
||||||
|
|
||||||
// Dependent with no insurance (will get price-tier-adjusted version)
|
$participant = new ParticipantDto();
|
||||||
$participant2 = new ParticipantDto();
|
$participant->assignedRoomId = 1;
|
||||||
$participant2->index = 1;
|
$participant->skiPass = $skiPass;
|
||||||
$participant2->insurance = null;
|
|
||||||
|
|
||||||
$participant3 = new ParticipantDto();
|
$roomSelection = new RoomSelectionDto();
|
||||||
$participant3->index = 2;
|
$roomSelection->id = 1;
|
||||||
$participant3->insurance = null;
|
$roomSelection->quantity = 1;
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 3);
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
$bookingDto->roomSelections = [$roomSelection];
|
||||||
|
$bookingDto->participants = [$participant];
|
||||||
|
|
||||||
$result = $this->service->calculateServicePricing($bookingDto);
|
$result = $this->service->calculateGrandTotal($bookingDto);
|
||||||
|
|
||||||
// Expected: 3x same insurance should be aggregated into one line item
|
// €100 (room) + €40 (ski pass) = €140
|
||||||
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
$this->assertEquals(140.0, $result);
|
||||||
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
|
||||||
$this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item');
|
|
||||||
$this->assertEquals(3, $insuranceGroup['services'][0]['participantCount'], 'Should count all 3 participants');
|
|
||||||
$this->assertEquals(150.0, $insuranceGroup['services'][0]['totalPrice'], 'Total should be 3 × €50');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testServicePricingWithBulkInsuranceShowsPriceTierAdjustment(): void
|
/**
|
||||||
|
* Regression: calculateServiceTotal() must use the same insurance-tier resolution
|
||||||
|
* as BookingPricingAssembler. In a bulk-insurance booking, dependents may qualify
|
||||||
|
* for a cheaper tier than the applicant, so copying the applicant's insurance object
|
||||||
|
* directly produces a higher total than what the display breakdown shows.
|
||||||
|
*/
|
||||||
|
public function testCalculateServiceTotalMatchesDisplayBreakdownForBulkInsurance(): void
|
||||||
{
|
{
|
||||||
// Test that bulk insurance shows correct price tiers based on individual travel prices
|
// Tier 1 (cheap): covers travel prices €0–€500 at €30/person
|
||||||
// Price tiers: Tier 1 (€0-€500): €30, Tier 2 (€501-€1000): €50
|
$insuranceTier1 = new Insurance();
|
||||||
$insuranceTier1 = $this->createInsurance(1, 'Reise-Rücktritt', 30.0, 'RRV');
|
$insuranceTier1->id = '1';
|
||||||
|
$insuranceTier1->label = 'Reise-Rücktritt';
|
||||||
|
$insuranceTier1->price = 30.0;
|
||||||
|
$insuranceTier1->subType = 'RRV';
|
||||||
$insuranceTier1->travelPriceFrom = 0.0;
|
$insuranceTier1->travelPriceFrom = 0.0;
|
||||||
$insuranceTier1->travelPriceTo = 500.0;
|
$insuranceTier1->travelPriceTo = 500.0;
|
||||||
|
|
||||||
$insuranceTier2 = $this->createInsurance(2, 'Reise-Rücktritt', 50.0, 'RRV');
|
// Tier 2 (expensive): covers travel prices €501–€1000 at €100/person
|
||||||
|
$insuranceTier2 = new Insurance();
|
||||||
|
$insuranceTier2->id = '2';
|
||||||
|
$insuranceTier2->label = 'Reise-Rücktritt';
|
||||||
|
$insuranceTier2->price = 100.0;
|
||||||
|
$insuranceTier2->subType = 'RRV';
|
||||||
$insuranceTier2->travelPriceFrom = 501.0;
|
$insuranceTier2->travelPriceFrom = 501.0;
|
||||||
$insuranceTier2->travelPriceTo = 1000.0;
|
$insuranceTier2->travelPriceTo = 1000.0;
|
||||||
|
|
||||||
@@ -520,188 +226,30 @@ class BookingPriceCalculatorTest extends TestCase
|
|||||||
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
|
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
|
||||||
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
|
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
|
||||||
|
|
||||||
// Applicant in Tier 2 (travel price €600)
|
// Applicant: tier 2 insurance (high travel price), enables bulk insurance
|
||||||
$participant1 = new ParticipantDto();
|
$applicant = new ParticipantDto();
|
||||||
$participant1->index = 0;
|
$applicant->index = 0;
|
||||||
$participant1->insurance = $insuranceTier2;
|
$applicant->insurance = $insuranceTier2;
|
||||||
$participant1->bulkInsuranceBooking = true;
|
$applicant->bulkInsuranceBooking = true;
|
||||||
|
// No room assigned — service-total-only scenario
|
||||||
|
|
||||||
|
// Dependent: no insurance assigned; eligible for tier 1 (lower travel price)
|
||||||
|
$dependent = new ParticipantDto();
|
||||||
|
$dependent->index = 1;
|
||||||
|
$dependent->insurance = null;
|
||||||
|
|
||||||
// This test will aggregate based on what insurance is resolved
|
|
||||||
// Without room/service assignments, we can't test real price calculation
|
|
||||||
// So this test verifies the logic structure is correct
|
|
||||||
$bookingDto = new BookingDto($travel, 1);
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
$bookingDto->participants = [$participant1];
|
$bookingDto->participants = [$applicant, $dependent];
|
||||||
|
|
||||||
$result = $this->service->calculateServicePricing($bookingDto);
|
$serviceTotal = $this->service->calculateServiceTotal($bookingDto);
|
||||||
|
$breakdownServiceTotal = array_sum(array_column(
|
||||||
|
$this->pricingAssembler->calculateServicePricing($bookingDto),
|
||||||
|
'groupTotal'
|
||||||
|
));
|
||||||
|
|
||||||
// Expected: Applicant's insurance is counted
|
// The numeric total must match the display breakdown exactly.
|
||||||
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
$this->assertEquals($breakdownServiceTotal, $serviceTotal,
|
||||||
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
'calculateServiceTotal() must agree with the display breakdown on bulk-insurance bookings'
|
||||||
$this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count applicant');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testServicePricingWithBulkInsuranceWhenNoBulkEnabled(): void
|
|
||||||
{
|
|
||||||
// Test that dependents are not counted when bulk insurance checkbox is not enabled
|
|
||||||
$insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
|
|
||||||
|
|
||||||
$travel = new Travel();
|
|
||||||
$travel->insurances = [$insurance];
|
|
||||||
|
|
||||||
// Applicant WITHOUT bulk insurance enabled
|
|
||||||
$participant1 = new ParticipantDto();
|
|
||||||
$participant1->index = 0;
|
|
||||||
$participant1->insurance = $insurance;
|
|
||||||
$participant1->bulkInsuranceBooking = false; // NOT enabled
|
|
||||||
|
|
||||||
// Dependent with no insurance
|
|
||||||
$participant2 = new ParticipantDto();
|
|
||||||
$participant2->index = 1;
|
|
||||||
$participant2->insurance = null; // No insurance assigned
|
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 2);
|
|
||||||
$bookingDto->participants = [$participant1, $participant2];
|
|
||||||
|
|
||||||
$result = $this->service->calculateServicePricing($bookingDto);
|
|
||||||
|
|
||||||
// Expected: Only applicant's insurance counted
|
|
||||||
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
|
||||||
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
|
||||||
$this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item');
|
|
||||||
$this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count only applicant');
|
|
||||||
$this->assertEquals(50.0, $insuranceGroup['services'][0]['totalPrice'], 'Total should be 1 × €50');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testServicePricingWithIneligibleParticipant(): void
|
|
||||||
{
|
|
||||||
// Test that ineligible participants are skipped (not counted at all)
|
|
||||||
$insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
|
|
||||||
|
|
||||||
$travel = new Travel();
|
|
||||||
$travel->insurances = [$insurance];
|
|
||||||
|
|
||||||
// Applicant with insurance
|
|
||||||
$participant1 = new ParticipantDto();
|
|
||||||
$participant1->index = 0;
|
|
||||||
$participant1->insurance = $insurance;
|
|
||||||
$participant1->bulkInsuranceBooking = false;
|
|
||||||
|
|
||||||
// Dependent (will be marked as ineligible by the eligibility service)
|
|
||||||
$participant2 = new ParticipantDto();
|
|
||||||
$participant2->index = 1;
|
|
||||||
$participant2->insurance = null;
|
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 2);
|
|
||||||
$bookingDto->participants = [$participant1, $participant2];
|
|
||||||
|
|
||||||
// Mock participant eligibility to mark second participant as ineligible
|
|
||||||
$participantEligibilityService = $this->createMock(ParticipantEligibilityChecker::class);
|
|
||||||
$participantEligibilityService->method('isParticipantEligible')
|
|
||||||
->willReturnCallback(fn ($booking, $index) => 0 === $index); // Only first participant eligible
|
|
||||||
|
|
||||||
$this->service = $this->createService($participantEligibilityService);
|
|
||||||
|
|
||||||
$result = $this->service->calculateServicePricing($bookingDto);
|
|
||||||
|
|
||||||
// Expected: Only applicant's insurance counted (dependent is ineligible)
|
|
||||||
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
|
||||||
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
|
||||||
$this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item');
|
|
||||||
$this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count only applicant');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testServicePricingWithBulkInsuranceCountsAllParticipants(): void
|
|
||||||
{
|
|
||||||
// Test that bulk insurance counts all participants (price tier adjusted per participant)
|
|
||||||
$insuranceA = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
|
|
||||||
|
|
||||||
$travel = new Travel();
|
|
||||||
$travel->insurances = [$insuranceA];
|
|
||||||
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
|
|
||||||
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
|
|
||||||
|
|
||||||
// Applicant with bulk insurance enabled
|
|
||||||
$participant1 = new ParticipantDto();
|
|
||||||
$participant1->index = 0;
|
|
||||||
$participant1->insurance = $insuranceA;
|
|
||||||
$participant1->bulkInsuranceBooking = true;
|
|
||||||
|
|
||||||
// Dependent 1 - no insurance (will get price-tier-adjusted version of A)
|
|
||||||
$participant2 = new ParticipantDto();
|
|
||||||
$participant2->index = 1;
|
|
||||||
$participant2->insurance = null;
|
|
||||||
|
|
||||||
// Dependent 2 - no insurance (will get price-tier-adjusted version of A)
|
|
||||||
$participant3 = new ParticipantDto();
|
|
||||||
$participant3->index = 2;
|
|
||||||
$participant3->insurance = null;
|
|
||||||
|
|
||||||
$bookingDto = new BookingDto($travel, 3);
|
|
||||||
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
|
||||||
|
|
||||||
$result = $this->service->calculateServicePricing($bookingDto);
|
|
||||||
|
|
||||||
// Expected: All 3 participants counted (price tier may vary per participant based on travel price)
|
|
||||||
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
|
||||||
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
|
||||||
|
|
||||||
// Count total participants across all insurance line items
|
|
||||||
$totalParticipants = array_sum(array_column($insuranceGroup['services'], 'participantCount'));
|
|
||||||
$this->assertEquals(3, $totalParticipants, 'Should count all 3 participants across all tiers');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper methods
|
|
||||||
|
|
||||||
private function createInsurance(int $id, string $label, float $price, string $subType = 'RRV', bool $package = false, bool $complementary = false): \App\BusProNet\Model\Insurance
|
|
||||||
{
|
|
||||||
$insurance = new \App\BusProNet\Model\Insurance();
|
|
||||||
$insurance->id = (string) $id;
|
|
||||||
$insurance->label = $label;
|
|
||||||
$insurance->price = $price;
|
|
||||||
$insurance->subType = $subType;
|
|
||||||
$insurance->package = $package;
|
|
||||||
$insurance->complementary = $complementary;
|
|
||||||
|
|
||||||
return $insurance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findServiceGroup(array $groups, string $groupName): ?array
|
|
||||||
{
|
|
||||||
foreach ($groups as $group) {
|
|
||||||
if ($group['groupName'] === $groupName) {
|
|
||||||
return $group;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findServiceItem(array $items, string $label): ?array
|
|
||||||
{
|
|
||||||
foreach ($items as $item) {
|
|
||||||
if ($item['label'] === $label) {
|
|
||||||
return $item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createParticipantWithAssignedRoom(int $roomId): ParticipantDto
|
|
||||||
{
|
|
||||||
$participant = new ParticipantDto();
|
|
||||||
$participant->assignedRoomId = $roomId;
|
|
||||||
|
|
||||||
return $participant;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createService(ParticipantEligibilityChecker $participantEligibilityService): BookingPriceCalculator
|
|
||||||
{
|
|
||||||
return new BookingPriceCalculator(
|
|
||||||
$this->roomPricingCalculator,
|
|
||||||
$participantEligibilityService,
|
|
||||||
new InsuranceManager(),
|
|
||||||
$this->participantPricingCalculator,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Insurance;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Service\BookingPricingAssembler;
|
||||||
|
use App\Service\InsuranceManager;
|
||||||
|
use App\Service\ParticipantEligibilityChecker;
|
||||||
|
use App\Service\ParticipantPricingCalculator;
|
||||||
|
use App\Service\RoomPricingCalculator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class BookingPricingAssemblerTest extends TestCase
|
||||||
|
{
|
||||||
|
private BookingPricingAssembler $assembler;
|
||||||
|
private ParticipantEligibilityChecker $eligibilityChecker;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->eligibilityChecker = $this->createMock(ParticipantEligibilityChecker::class);
|
||||||
|
$this->eligibilityChecker->method('isParticipantEligible')->willReturn(true);
|
||||||
|
|
||||||
|
$this->assembler = $this->createAssembler($this->eligibilityChecker);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicePricingWithoutBulkInsurance(): void
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->insurances = [];
|
||||||
|
|
||||||
|
$insurance1 = $this->createInsurance(1, 'Insurance A', 50.0);
|
||||||
|
$insurance2 = $this->createInsurance(2, 'Insurance B', 75.0);
|
||||||
|
|
||||||
|
$participant1 = new ParticipantDto();
|
||||||
|
$participant1->index = 0;
|
||||||
|
$participant1->insurance = $insurance1;
|
||||||
|
$participant1->bulkInsuranceBooking = false;
|
||||||
|
|
||||||
|
$participant2 = new ParticipantDto();
|
||||||
|
$participant2->index = 1;
|
||||||
|
$participant2->insurance = $insurance2;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 2);
|
||||||
|
$bookingDto->participants = [$participant1, $participant2];
|
||||||
|
|
||||||
|
$result = $this->assembler->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
$this->assertNotEmpty($result);
|
||||||
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
|
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||||
|
$this->assertCount(2, $insuranceGroup['services'], 'Should have 2 different insurance line items');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicePricingWithBulkInsuranceSamePriceTier(): void
|
||||||
|
{
|
||||||
|
$insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
|
||||||
|
$insurance->travelPriceFrom = 0.0;
|
||||||
|
$insurance->travelPriceTo = 10000.0;
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->insurances = [$insurance];
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
|
||||||
|
|
||||||
|
$participant1 = new ParticipantDto();
|
||||||
|
$participant1->index = 0;
|
||||||
|
$participant1->insurance = $insurance;
|
||||||
|
$participant1->bulkInsuranceBooking = true;
|
||||||
|
|
||||||
|
$participant2 = new ParticipantDto();
|
||||||
|
$participant2->index = 1;
|
||||||
|
$participant2->insurance = null;
|
||||||
|
|
||||||
|
$participant3 = new ParticipantDto();
|
||||||
|
$participant3->index = 2;
|
||||||
|
$participant3->insurance = null;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 3);
|
||||||
|
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
||||||
|
|
||||||
|
$result = $this->assembler->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
// 3× same insurance should aggregate into one line item
|
||||||
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
|
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||||
|
$this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item');
|
||||||
|
$this->assertEquals(3, $insuranceGroup['services'][0]['participantCount'], 'Should count all 3 participants');
|
||||||
|
$this->assertEquals(150.0, $insuranceGroup['services'][0]['totalPrice'], 'Total should be 3 × €50');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicePricingWithBulkInsuranceShowsPriceTierAdjustment(): void
|
||||||
|
{
|
||||||
|
$insuranceTier1 = $this->createInsurance(1, 'Reise-Rücktritt', 30.0, 'RRV');
|
||||||
|
$insuranceTier1->travelPriceFrom = 0.0;
|
||||||
|
$insuranceTier1->travelPriceTo = 500.0;
|
||||||
|
|
||||||
|
$insuranceTier2 = $this->createInsurance(2, 'Reise-Rücktritt', 50.0, 'RRV');
|
||||||
|
$insuranceTier2->travelPriceFrom = 501.0;
|
||||||
|
$insuranceTier2->travelPriceTo = 1000.0;
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->insurances = [$insuranceTier1, $insuranceTier2];
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
|
||||||
|
|
||||||
|
$participant1 = new ParticipantDto();
|
||||||
|
$participant1->index = 0;
|
||||||
|
$participant1->insurance = $insuranceTier2;
|
||||||
|
$participant1->bulkInsuranceBooking = true;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
$bookingDto->participants = [$participant1];
|
||||||
|
|
||||||
|
$result = $this->assembler->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
|
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||||
|
$this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count applicant');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicePricingWithBulkInsuranceWhenNoBulkEnabled(): void
|
||||||
|
{
|
||||||
|
$insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->insurances = [$insurance];
|
||||||
|
|
||||||
|
$participant1 = new ParticipantDto();
|
||||||
|
$participant1->index = 0;
|
||||||
|
$participant1->insurance = $insurance;
|
||||||
|
$participant1->bulkInsuranceBooking = false;
|
||||||
|
|
||||||
|
$participant2 = new ParticipantDto();
|
||||||
|
$participant2->index = 1;
|
||||||
|
$participant2->insurance = null;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 2);
|
||||||
|
$bookingDto->participants = [$participant1, $participant2];
|
||||||
|
|
||||||
|
$result = $this->assembler->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
|
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||||
|
$this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item');
|
||||||
|
$this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count only applicant');
|
||||||
|
$this->assertEquals(50.0, $insuranceGroup['services'][0]['totalPrice'], 'Total should be 1 × €50');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicePricingWithIneligibleParticipant(): void
|
||||||
|
{
|
||||||
|
$insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->insurances = [$insurance];
|
||||||
|
|
||||||
|
$participant1 = new ParticipantDto();
|
||||||
|
$participant1->index = 0;
|
||||||
|
$participant1->insurance = $insurance;
|
||||||
|
$participant1->bulkInsuranceBooking = false;
|
||||||
|
|
||||||
|
$participant2 = new ParticipantDto();
|
||||||
|
$participant2->index = 1;
|
||||||
|
$participant2->insurance = null;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 2);
|
||||||
|
$bookingDto->participants = [$participant1, $participant2];
|
||||||
|
|
||||||
|
$eligibilityChecker = $this->createMock(ParticipantEligibilityChecker::class);
|
||||||
|
$eligibilityChecker->method('isParticipantEligible')
|
||||||
|
->willReturnCallback(fn ($booking, int $index): bool => 0 === $index);
|
||||||
|
|
||||||
|
$result = $this->createAssembler($eligibilityChecker)->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
|
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||||
|
$this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item');
|
||||||
|
$this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count only applicant');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicePricingWithBulkInsuranceCountsAllParticipants(): void
|
||||||
|
{
|
||||||
|
$insuranceA = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->insurances = [$insuranceA];
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
|
||||||
|
|
||||||
|
$participant1 = new ParticipantDto();
|
||||||
|
$participant1->index = 0;
|
||||||
|
$participant1->insurance = $insuranceA;
|
||||||
|
$participant1->bulkInsuranceBooking = true;
|
||||||
|
|
||||||
|
$participant2 = new ParticipantDto();
|
||||||
|
$participant2->index = 1;
|
||||||
|
$participant2->insurance = null;
|
||||||
|
|
||||||
|
$participant3 = new ParticipantDto();
|
||||||
|
$participant3->index = 2;
|
||||||
|
$participant3->insurance = null;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 3);
|
||||||
|
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
||||||
|
|
||||||
|
$result = $this->assembler->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
|
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||||
|
|
||||||
|
$totalParticipants = array_sum(array_column($insuranceGroup['services'], 'participantCount'));
|
||||||
|
$this->assertEquals(3, $totalParticipants, 'Should count all 3 participants across all tiers');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
|
||||||
|
private function createAssembler(ParticipantEligibilityChecker $eligibilityChecker): BookingPricingAssembler
|
||||||
|
{
|
||||||
|
$roomPricingCalculator = new RoomPricingCalculator();
|
||||||
|
|
||||||
|
return new BookingPricingAssembler(
|
||||||
|
$roomPricingCalculator,
|
||||||
|
$eligibilityChecker,
|
||||||
|
new InsuranceManager(),
|
||||||
|
new ParticipantPricingCalculator($roomPricingCalculator),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createInsurance(int $id, string $label, float $price, string $subType = 'RRV'): Insurance
|
||||||
|
{
|
||||||
|
$insurance = new Insurance();
|
||||||
|
$insurance->id = (string) $id;
|
||||||
|
$insurance->label = $label;
|
||||||
|
$insurance->price = $price;
|
||||||
|
$insurance->subType = $subType;
|
||||||
|
$insurance->package = false;
|
||||||
|
$insurance->complementary = false;
|
||||||
|
|
||||||
|
return $insurance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findServiceGroup(array $groups, string $groupName): ?array
|
||||||
|
{
|
||||||
|
foreach ($groups as $group) {
|
||||||
|
if ($group['groupName'] === $groupName) {
|
||||||
|
return $group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ use App\Form\Model\BookingSummaryDto;
|
|||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Form\Model\RoomSelectionDto;
|
use App\Form\Model\RoomSelectionDto;
|
||||||
use App\Service\BookingPriceCalculator;
|
use App\Service\BookingPriceCalculator;
|
||||||
|
use App\Service\BookingPricingAssembler;
|
||||||
use App\Service\BookingSummaryAssembler;
|
use App\Service\BookingSummaryAssembler;
|
||||||
use App\Service\CmsDataProvider;
|
use App\Service\CmsDataProvider;
|
||||||
use App\BusProNet\DataProvider\CountryDataProvider;
|
use App\BusProNet\DataProvider\CountryDataProvider;
|
||||||
@@ -73,7 +74,9 @@ class BookingSummaryAssemblerTest extends TestCase
|
|||||||
{
|
{
|
||||||
$priceCalculator = $this->createMock(BookingPriceCalculator::class);
|
$priceCalculator = $this->createMock(BookingPriceCalculator::class);
|
||||||
$priceCalculator->method('calculateAllParticipantIndividualPrices')->willReturn([]);
|
$priceCalculator->method('calculateAllParticipantIndividualPrices')->willReturn([]);
|
||||||
$priceCalculator->method('getPricingBreakdown')->willReturn([
|
|
||||||
|
$pricingAssembler = $this->createMock(BookingPricingAssembler::class);
|
||||||
|
$pricingAssembler->method('getPricingBreakdown')->willReturn([
|
||||||
'rooms' => [],
|
'rooms' => [],
|
||||||
'services' => [],
|
'services' => [],
|
||||||
'surcharges' => null,
|
'surcharges' => null,
|
||||||
@@ -82,6 +85,7 @@ class BookingSummaryAssemblerTest extends TestCase
|
|||||||
|
|
||||||
return new BookingSummaryAssembler(
|
return new BookingSummaryAssembler(
|
||||||
$priceCalculator,
|
$priceCalculator,
|
||||||
|
$pricingAssembler,
|
||||||
$this->createMock(CmsDataProvider::class),
|
$this->createMock(CmsDataProvider::class),
|
||||||
$this->createMock(HotelLoader::class),
|
$this->createMock(HotelLoader::class),
|
||||||
$this->createMock(CountryDataProvider::class),
|
$this->createMock(CountryDataProvider::class),
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Booking;
|
||||||
|
use App\BusProNet\Model\Room;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Form\Model\RoomSelectionDto;
|
||||||
|
use App\Service\RoomPricingCalculator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class RoomPricingCalculatorTest extends TestCase
|
||||||
|
{
|
||||||
|
private RoomPricingCalculator $calculator;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->calculator = new RoomPricingCalculator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateRoomPricingWithParticipantBasedCalculation(): void
|
||||||
|
{
|
||||||
|
$room = new Room();
|
||||||
|
$room->id = 1;
|
||||||
|
$room->price = 100.0;
|
||||||
|
$room->minPax = 2;
|
||||||
|
$room->label = 'Double Room';
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->rooms = [$room];
|
||||||
|
|
||||||
|
$roomSelection = new RoomSelectionDto();
|
||||||
|
$roomSelection->id = 1;
|
||||||
|
$roomSelection->quantity = 2;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
$bookingDto->roomSelections = [$roomSelection];
|
||||||
|
$bookingDto->participants = [
|
||||||
|
$this->participantInRoom(1),
|
||||||
|
$this->participantInRoom(1),
|
||||||
|
$this->participantInRoom(1),
|
||||||
|
$this->participantInRoom(1),
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->calculator->calculateRoomPricing($bookingDto);
|
||||||
|
|
||||||
|
// 4 assigned participants × €100 = €400 total
|
||||||
|
$this->assertCount(1, $result);
|
||||||
|
$this->assertEquals(1, $result[0]['roomId']);
|
||||||
|
$this->assertEquals('Double Room', $result[0]['label']);
|
||||||
|
$this->assertEquals(2, $result[0]['quantity']);
|
||||||
|
$this->assertEquals(4, $result[0]['participantCount']);
|
||||||
|
$this->assertEquals(100.0, $result[0]['unitPrice']);
|
||||||
|
$this->assertEquals(400.0, $result[0]['totalPrice']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateRoomPricingWithSingleRoomSelection(): void
|
||||||
|
{
|
||||||
|
$room = new Room();
|
||||||
|
$room->id = 2;
|
||||||
|
$room->price = 150.0;
|
||||||
|
$room->minPax = 3;
|
||||||
|
$room->label = 'Triple Room';
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->rooms = [$room];
|
||||||
|
|
||||||
|
$roomSelection = new RoomSelectionDto();
|
||||||
|
$roomSelection->id = 2;
|
||||||
|
$roomSelection->quantity = 1;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
$bookingDto->roomSelections = [$roomSelection];
|
||||||
|
$bookingDto->participants = [
|
||||||
|
$this->participantInRoom(2),
|
||||||
|
$this->participantInRoom(2),
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->calculator->calculateRoomPricing($bookingDto);
|
||||||
|
|
||||||
|
// 2 assigned participants × €150 = €300 total
|
||||||
|
$this->assertCount(1, $result);
|
||||||
|
$this->assertEquals(2, $result[0]['roomId']);
|
||||||
|
$this->assertEquals('Triple Room', $result[0]['label']);
|
||||||
|
$this->assertEquals(1, $result[0]['quantity']);
|
||||||
|
$this->assertEquals(2, $result[0]['participantCount']);
|
||||||
|
$this->assertEquals(150.0, $result[0]['unitPrice']);
|
||||||
|
$this->assertEquals(300.0, $result[0]['totalPrice']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateRoomPricingWithMultipleRoomTypes(): void
|
||||||
|
{
|
||||||
|
$singleRoom = new Room();
|
||||||
|
$singleRoom->id = 1;
|
||||||
|
$singleRoom->price = 80.0;
|
||||||
|
$singleRoom->minPax = 1;
|
||||||
|
$singleRoom->label = 'Single Room';
|
||||||
|
|
||||||
|
$doubleRoom = new Room();
|
||||||
|
$doubleRoom->id = 2;
|
||||||
|
$doubleRoom->price = 120.0;
|
||||||
|
$doubleRoom->minPax = 2;
|
||||||
|
$doubleRoom->label = 'Double Room';
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->rooms = [$singleRoom, $doubleRoom];
|
||||||
|
|
||||||
|
$singleRoomSelection = new RoomSelectionDto();
|
||||||
|
$singleRoomSelection->id = 1;
|
||||||
|
$singleRoomSelection->quantity = 1;
|
||||||
|
|
||||||
|
$doubleRoomSelection = new RoomSelectionDto();
|
||||||
|
$doubleRoomSelection->id = 2;
|
||||||
|
$doubleRoomSelection->quantity = 2;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
$bookingDto->roomSelections = [$singleRoomSelection, $doubleRoomSelection];
|
||||||
|
$bookingDto->participants = [
|
||||||
|
$this->participantInRoom(1),
|
||||||
|
$this->participantInRoom(2),
|
||||||
|
$this->participantInRoom(2),
|
||||||
|
$this->participantInRoom(2),
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->calculator->calculateRoomPricing($bookingDto);
|
||||||
|
|
||||||
|
// Single: 1 participant × €80 = €80
|
||||||
|
// Double: 3 participants × €120 = €360
|
||||||
|
$this->assertCount(2, $result);
|
||||||
|
|
||||||
|
$this->assertEquals(1, $result[0]['roomId']);
|
||||||
|
$this->assertEquals(1, $result[0]['quantity']);
|
||||||
|
$this->assertEquals(1, $result[0]['participantCount']);
|
||||||
|
$this->assertEquals(80.0, $result[0]['unitPrice']);
|
||||||
|
$this->assertEquals(80.0, $result[0]['totalPrice']);
|
||||||
|
|
||||||
|
$this->assertEquals(2, $result[1]['roomId']);
|
||||||
|
$this->assertEquals(2, $result[1]['quantity']);
|
||||||
|
$this->assertEquals(3, $result[1]['participantCount']);
|
||||||
|
$this->assertEquals(120.0, $result[1]['unitPrice']);
|
||||||
|
$this->assertEquals(360.0, $result[1]['totalPrice']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateRoomPricingSkipsRoomsWithNullPrice(): void
|
||||||
|
{
|
||||||
|
$room = new Room();
|
||||||
|
$room->id = 1;
|
||||||
|
$room->price = null;
|
||||||
|
$room->minPax = 2;
|
||||||
|
$room->label = 'Free Room';
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->rooms = [$room];
|
||||||
|
|
||||||
|
$roomSelection = new RoomSelectionDto();
|
||||||
|
$roomSelection->id = 1;
|
||||||
|
$roomSelection->quantity = 1;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
$bookingDto->roomSelections = [$roomSelection];
|
||||||
|
|
||||||
|
$result = $this->calculator->calculateRoomPricing($bookingDto);
|
||||||
|
|
||||||
|
$this->assertEmpty($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateRoomPricingWithZeroQuantityRooms(): void
|
||||||
|
{
|
||||||
|
$room = new Room();
|
||||||
|
$room->id = 1;
|
||||||
|
$room->price = 100.0;
|
||||||
|
$room->minPax = 2;
|
||||||
|
$room->label = 'Double Room';
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->rooms = [$room];
|
||||||
|
|
||||||
|
$roomSelection = new RoomSelectionDto();
|
||||||
|
$roomSelection->id = 1;
|
||||||
|
$roomSelection->quantity = 0;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
$bookingDto->roomSelections = [$roomSelection];
|
||||||
|
|
||||||
|
$result = $this->calculator->calculateRoomPricing($bookingDto);
|
||||||
|
|
||||||
|
$this->assertEmpty($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateRoomPricingShowsSelectedRoomWithZeroAssignments(): void
|
||||||
|
{
|
||||||
|
$room = new Room();
|
||||||
|
$room->id = 1;
|
||||||
|
$room->price = 100.0;
|
||||||
|
$room->label = 'Double Room';
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->rooms = [$room];
|
||||||
|
|
||||||
|
$roomSelection = new RoomSelectionDto();
|
||||||
|
$roomSelection->id = 1;
|
||||||
|
$roomSelection->quantity = 1;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
$bookingDto->roomSelections = [$roomSelection];
|
||||||
|
$bookingDto->participants = [];
|
||||||
|
|
||||||
|
$result = $this->calculator->calculateRoomPricing($bookingDto);
|
||||||
|
|
||||||
|
$this->assertCount(1, $result);
|
||||||
|
$this->assertEquals(1, $result[0]['roomId']);
|
||||||
|
$this->assertEquals(1, $result[0]['quantity']);
|
||||||
|
$this->assertEquals(0, $result[0]['participantCount']);
|
||||||
|
$this->assertEquals(0.0, $result[0]['totalPrice']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateRoomPricingUsesSelectionModeForStepOneSummary(): void
|
||||||
|
{
|
||||||
|
$room = new Room();
|
||||||
|
$room->id = 3;
|
||||||
|
$room->price = 229.0;
|
||||||
|
$room->minPax = 2;
|
||||||
|
$room->label = 'Doppelzimmer Dusche/WC';
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->rooms = [$room];
|
||||||
|
|
||||||
|
$roomSelection = new RoomSelectionDto();
|
||||||
|
$roomSelection->id = 3;
|
||||||
|
$roomSelection->quantity = 1;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
$bookingDto->roomSelections = [$roomSelection];
|
||||||
|
$bookingDto->participants = [
|
||||||
|
$this->participantInRoom(3),
|
||||||
|
$this->participantInRoom(3),
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->calculator->calculateRoomPricing($bookingDto, RoomPricingCalculator::PRICING_MODE_SELECTION);
|
||||||
|
|
||||||
|
$this->assertCount(1, $result);
|
||||||
|
$this->assertEquals(2, $result[0]['participantCount']);
|
||||||
|
$this->assertEquals(458.0, $result[0]['totalPrice']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateRoomPricingInEditModeUsesStoredIndividualPrices(): void
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
$bookingDto = new BookingDto($travel, 2);
|
||||||
|
|
||||||
|
$participantA = new ParticipantDto();
|
||||||
|
$participantA->assignedRoomId = 10;
|
||||||
|
$participantB = new ParticipantDto();
|
||||||
|
$participantB->assignedRoomId = 10;
|
||||||
|
$bookingDto->participants = [$participantA, $participantB];
|
||||||
|
|
||||||
|
$booking = new Booking();
|
||||||
|
$room = new Room();
|
||||||
|
$room->id = 10;
|
||||||
|
$room->label = 'Stored Room';
|
||||||
|
$room->mapping = [0, 1];
|
||||||
|
$room->individualPrice = [0 => 200.0, 1 => 220.0];
|
||||||
|
$room->totalCount = 1;
|
||||||
|
$booking->rooms = [$room];
|
||||||
|
$bookingDto->booking = $booking;
|
||||||
|
|
||||||
|
$result = $this->calculator->calculateRoomPricing($bookingDto);
|
||||||
|
|
||||||
|
$this->assertCount(1, $result);
|
||||||
|
$this->assertEquals(10, $result[0]['roomId']);
|
||||||
|
$this->assertEquals(2, $result[0]['participantCount']);
|
||||||
|
$this->assertEquals(210.0, $result[0]['unitPrice']);
|
||||||
|
$this->assertEquals(420.0, $result[0]['totalPrice']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function participantInRoom(int $roomId): ParticipantDto
|
||||||
|
{
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->assignedRoomId = $roomId;
|
||||||
|
|
||||||
|
return $participant;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user