From 4d86629315d2db882907522d2a5e6fa0f11f779a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 13 Apr 2026 18:15:40 +0200 Subject: [PATCH] feat: move display-assembly out of BookingPriceCalculator --- .../Booking/Create/Step4Controller.php | 2 - src/Service/BookingPriceCalculator.php | 512 +-------------- src/Service/BookingPricingAssembler.php | 437 +++++++++++++ src/Service/BookingSummaryAssembler.php | 3 +- tests/Service/BookingPriceCalculatorTest.php | 618 +++--------------- tests/Service/BookingPricingAssemblerTest.php | 256 ++++++++ tests/Service/BookingSummaryAssemblerTest.php | 6 +- tests/Service/RoomPricingCalculatorTest.php | 287 ++++++++ 8 files changed, 1093 insertions(+), 1028 deletions(-) create mode 100644 src/Service/BookingPricingAssembler.php create mode 100644 tests/Service/BookingPricingAssemblerTest.php create mode 100644 tests/Service/RoomPricingCalculatorTest.php diff --git a/src/Controller/Booking/Create/Step4Controller.php b/src/Controller/Booking/Create/Step4Controller.php index 50f117a..bc5bd73 100644 --- a/src/Controller/Booking/Create/Step4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -15,7 +15,6 @@ use App\Form\Model\BookingDto; use App\Htmx\HxTrait; use App\Entity\User; use App\Service\BookingCreateContextFactory; -use App\Service\BookingPriceCalculator; use App\Service\BookingConfigurator; use App\Service\BookingSessionStore; use App\Service\MailjetApiClient; @@ -41,7 +40,6 @@ class Step4Controller extends AbstractController private readonly BookingConfigurator $bookingService, private readonly BookingSessionStore $bookingSessionService, private readonly BookingCreateContextFactory $createContextFactory, - private readonly BookingPriceCalculator $priceCalculator, private readonly ApiClient $apiClient, private readonly CacheInterface $cache, private readonly MailjetApiClient $newsletterService, diff --git a/src/Service/BookingPriceCalculator.php b/src/Service/BookingPriceCalculator.php index 1d4114c..3ec9e7a 100644 --- a/src/Service/BookingPriceCalculator.php +++ b/src/Service/BookingPriceCalculator.php @@ -4,132 +4,32 @@ declare(strict_types=1); 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\ParticipantDto; /** - * Facade for booking pricing calculations. + * Computes numeric price totals for bookings and individual participants. * - * Provides comprehensive pricing calculations for the booking system by - * coordinating room and participant calculations and handling service aggregation directly. - * Returns structured pricing data for display in forms and summaries. + * All methods return floats or arrays of floats — no display formatting. + * For display-ready pricing breakdowns, use BookingPricingAssembler. */ class BookingPriceCalculator { public function __construct( private readonly RoomPricingCalculator $roomPricingCalculator, - private readonly ParticipantEligibilityChecker $participantEligibilityService, - private readonly InsuranceManager $insuranceService, + private readonly BookingPricingAssembler $pricingAssembler, 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> 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. - * - * @param BookingDto $bookingDto The booking data to calculate total for - * - * @return float The grand total price */ public function calculateGrandTotal( BookingDto $bookingDto, string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT, - ): float - { - $roomTotal = $this->roomPricingCalculator->calculateRoomTotal($bookingDto, $roomPricingMode); - $serviceTotal = $this->calculateServiceTotal($bookingDto); - - return $roomTotal + $serviceTotal; + ): float { + return $this->roomPricingCalculator->calculateRoomTotal($bookingDto, $roomPricingMode) + + $this->calculateServiceTotal($bookingDto); } /** @@ -138,387 +38,28 @@ class BookingPriceCalculator public function calculateRoomTotal( BookingDto $bookingDto, string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT, - ): float - { + ): float { 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 { - $servicePricing = $this->calculateServicePricing($bookingDto); - - $total = 0.0; - 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> 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> $serviceAggregation - * - * @return array> - */ - 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 $serviceData - * - * @return array - */ - 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; + return array_sum(array_column( + $this->pricingAssembler->calculateServicePricing($bookingDto), + 'groupTotal' + )); } /** * 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 { @@ -528,9 +69,7 @@ class BookingPriceCalculator /** * Calculates individual prices for all participants in a booking. * - * @param BookingDto $bookingDto The booking data containing all participants - * - * @return array Array indexed by participant index containing individual prices + * @return array */ public function calculateAllParticipantIndividualPrices(BookingDto $bookingDto): array { @@ -540,17 +79,12 @@ class BookingPriceCalculator /** * Calculates the total price for an individual participant excluding insurance. * - * This method is used for insurance eligibility filtering to avoid circular dependency - * where insurance selection affects travel price which affects insurance eligibility. + * Used for insurance eligibility filtering to avoid circular dependency where + * insurance selection affects travel price which affects insurance eligibility. * * Only includes services where versicherungsberechnung='J' in the XML. Services with - * versicherungsberechnung='N' (like CO2 compensation) are excluded from the calculation - * as per BPN API 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 + * versicherungsberechnung='N' (like CO2 compensation) are excluded as per BPN API + * requirements for insurance tier determination. */ public function calculateIndividualParticipantPriceExcludingInsurance( BookingDto $bookingDto, diff --git a/src/Service/BookingPricingAssembler.php b/src/Service/BookingPricingAssembler.php new file mode 100644 index 0000000..5357fe3 --- /dev/null +++ b/src/Service/BookingPricingAssembler.php @@ -0,0 +1,437 @@ +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> + */ + 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> + */ + 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> + */ + 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> $serviceAggregation + * @return array> + */ + 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 $serviceData + * @return array + */ + 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; + } +} diff --git a/src/Service/BookingSummaryAssembler.php b/src/Service/BookingSummaryAssembler.php index af12c9b..b233207 100644 --- a/src/Service/BookingSummaryAssembler.php +++ b/src/Service/BookingSummaryAssembler.php @@ -27,6 +27,7 @@ class BookingSummaryAssembler { public function __construct( private readonly BookingPriceCalculator $priceCalculator, + private readonly BookingPricingAssembler $pricingAssembler, private readonly CmsDataProvider $cmsDataService, private readonly HotelLoader $hotelLoader, private readonly CountryDataProvider $countryDataProvider, @@ -58,7 +59,7 @@ class BookingSummaryAssembler } // Get detailed pricing breakdown - $pricingData = $this->priceCalculator->getPricingBreakdown($bookingDto, $roomPricingMode); + $pricingData = $this->pricingAssembler->getPricingBreakdown($bookingDto, $roomPricingMode); // Fetch CMS data (images, etc.) $productCode = $bookingDto->travel->productCode; diff --git a/tests/Service/BookingPriceCalculatorTest.php b/tests/Service/BookingPriceCalculatorTest.php index 14171f0..fe3fd67 100644 --- a/tests/Service/BookingPriceCalculatorTest.php +++ b/tests/Service/BookingPriceCalculatorTest.php @@ -4,14 +4,15 @@ declare(strict_types=1); namespace App\Tests\Service; +use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Room; -use App\BusProNet\Model\Booking; use App\BusProNet\Model\Service; use App\BusProNet\Model\Travel; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Model\RoomSelectionDto; use App\Service\BookingPriceCalculator; +use App\Service\BookingPricingAssembler; use App\Service\InsuranceManager; use App\Service\ParticipantEligibilityChecker; use App\Service\ParticipantPricingCalculator; @@ -21,292 +22,33 @@ use PHPUnit\Framework\TestCase; class BookingPriceCalculatorTest extends TestCase { private BookingPriceCalculator $service; - private ParticipantEligibilityChecker $participantEligibilityService; private RoomPricingCalculator $roomPricingCalculator; private ParticipantPricingCalculator $participantPricingCalculator; + private BookingPricingAssembler $pricingAssembler; protected function setUp(): void { - $this->participantEligibilityService = $this->createMock(ParticipantEligibilityChecker::class); - $this->participantEligibilityService->method('isParticipantEligible')->willReturn(true); + $eligibilityChecker = $this->createMock(ParticipantEligibilityChecker::class); + $eligibilityChecker->method('isParticipantEligible')->willReturn(true); $this->roomPricingCalculator = new RoomPricingCalculator(); $this->participantPricingCalculator = new ParticipantPricingCalculator($this->roomPricingCalculator); + $this->pricingAssembler = new BookingPricingAssembler( + $this->roomPricingCalculator, + $eligibilityChecker, + new InsuranceManager(), + $this->participantPricingCalculator, + ); - $this->service = $this->createService($this->participantEligibilityService); - } - - public function testCalculateRoomPricingWithParticipantBasedCalculation(): void - { - // 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']); + $this->service = new BookingPriceCalculator( + $this->roomPricingCalculator, + $this->pricingAssembler, + $this->participantPricingCalculator, + ); } public function testCalculateIndividualParticipantPriceWithRoomAndServices(): void { - // Create test room $room = new Room(); $room->id = 1; $room->price = 100.0; @@ -315,14 +57,12 @@ class BookingPriceCalculatorTest extends TestCase $travel = new Travel(); $travel->rooms = [$room]; - // Create test services $skiPass = new Service(); $skiPass->price = 50.0; $course = new Service(); $course->price = 30.0; - // Create participant with room assignment and services $participant = new ParticipantDto(); $participant->assignedRoomId = 1; $participant->skiPass = $skiPass; @@ -333,7 +73,7 @@ class BookingPriceCalculatorTest extends TestCase $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); } @@ -341,13 +81,11 @@ class BookingPriceCalculatorTest extends TestCase { $travel = new Travel(); - // Create test services $skiPass = new Service(); $skiPass->price = 50.0; - // Create participant without room assignment $participant = new ParticipantDto(); - $participant->assignedRoomId = null; // No room assigned + $participant->assignedRoomId = null; $participant->skiPass = $skiPass; $bookingDto = new BookingDto($travel, 1); @@ -355,7 +93,6 @@ class BookingPriceCalculatorTest extends TestCase $result = $this->service->calculateIndividualParticipantPrice($bookingDto, 0); - // Expected: €0 (no room) + €50 (ski pass) = €50 $this->assertEquals(50.0, $result); } @@ -367,13 +104,11 @@ class BookingPriceCalculatorTest extends TestCase $result = $this->service->calculateIndividualParticipantPrice($bookingDto, 0); - // Expected: €0 for non-existent participant $this->assertEquals(0.0, $result); } public function testCalculateAllParticipantIndividualPricesWithMultipleParticipants(): void { - // Create test rooms $singleRoom = new Room(); $singleRoom->id = 1; $singleRoom->price = 80.0; @@ -387,34 +122,31 @@ class BookingPriceCalculatorTest extends TestCase $travel = new Travel(); $travel->rooms = [$singleRoom, $doubleRoom]; - // Create test services $skiPass = new Service(); $skiPass->price = 50.0; $course = new Service(); $course->price = 30.0; - // Create participants $participant1 = new ParticipantDto(); - $participant1->assignedRoomId = 1; // Single room + $participant1->assignedRoomId = 1; $participant1->skiPass = $skiPass; $participant2 = new ParticipantDto(); - $participant2->assignedRoomId = 2; // Double room + $participant2->assignedRoomId = 2; $participant2->courses = [$course]; $participant3 = new ParticipantDto(); - $participant3->assignedRoomId = null; // No room assigned + $participant3->assignedRoomId = null; $bookingDto = new BookingDto($travel, 1); $bookingDto->participants = [$participant1, $participant2, $participant3]; $result = $this->service->calculateAllParticipantIndividualPrices($bookingDto); - // Expected results: - // Participant 0: €80 (single room) + €50 (ski pass) = €130 - // Participant 1: €120 (double room) + €30 (course) = €150 - // Participant 2: €0 (no room) + €0 (no services) = €0 + // Participant 0: €80 + €50 = €130 + // Participant 1: €120 + €30 = €150 + // Participant 2: €0 $this->assertCount(3, $result); $this->assertEquals(130.0, $result[0]); $this->assertEquals(150.0, $result[1]); @@ -432,86 +164,60 @@ class BookingPriceCalculatorTest extends TestCase $this->assertEmpty($result); } - public function testServicePricingWithoutBulkInsurance(): void + public function testCalculateGrandTotalCombinesRoomsAndServices(): void { - // Test that aggregation works normally when bulk insurance is not enabled - $travel = new Travel(); - $travel->insurances = []; - - // 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; + $room = new Room(); + $room->id = 1; + $room->price = 100.0; + $room->minPax = 1; $travel = new Travel(); - $travel->insurances = [$insurance]; - $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); - $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + $travel->rooms = [$room]; - // Applicant with bulk insurance enabled - $participant1 = new ParticipantDto(); - $participant1->index = 0; - $participant1->insurance = $insurance; - $participant1->bulkInsuranceBooking = true; + $skiPass = new Service(); + $skiPass->price = 40.0; - // Dependent with no insurance (will get price-tier-adjusted version) - $participant2 = new ParticipantDto(); - $participant2->index = 1; - $participant2->insurance = null; + $participant = new ParticipantDto(); + $participant->assignedRoomId = 1; + $participant->skiPass = $skiPass; - $participant3 = new ParticipantDto(); - $participant3->index = 2; - $participant3->insurance = null; + $roomSelection = new RoomSelectionDto(); + $roomSelection->id = 1; + $roomSelection->quantity = 1; - $bookingDto = new BookingDto($travel, 3); - $bookingDto->participants = [$participant1, $participant2, $participant3]; + $bookingDto = new BookingDto($travel, 1); + $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 - $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'); + // €100 (room) + €40 (ski pass) = €140 + $this->assertEquals(140.0, $result); } - 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 - // Price tiers: Tier 1 (€0-€500): €30, Tier 2 (€501-€1000): €50 - $insuranceTier1 = $this->createInsurance(1, 'Reise-Rücktritt', 30.0, 'RRV'); + // Tier 1 (cheap): covers travel prices €0–€500 at €30/person + $insuranceTier1 = new Insurance(); + $insuranceTier1->id = '1'; + $insuranceTier1->label = 'Reise-Rücktritt'; + $insuranceTier1->price = 30.0; + $insuranceTier1->subType = 'RRV'; $insuranceTier1->travelPriceFrom = 0.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->travelPriceTo = 1000.0; @@ -520,188 +226,30 @@ class BookingPriceCalculatorTest extends TestCase $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); - // Applicant in Tier 2 (travel price €600) - $participant1 = new ParticipantDto(); - $participant1->index = 0; - $participant1->insurance = $insuranceTier2; - $participant1->bulkInsuranceBooking = true; + // Applicant: tier 2 insurance (high travel price), enables bulk insurance + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->insurance = $insuranceTier2; + $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->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 - $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 - { - // 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, + // The numeric total must match the display breakdown exactly. + $this->assertEquals($breakdownServiceTotal, $serviceTotal, + 'calculateServiceTotal() must agree with the display breakdown on bulk-insurance bookings' ); } } diff --git a/tests/Service/BookingPricingAssemblerTest.php b/tests/Service/BookingPricingAssemblerTest.php new file mode 100644 index 0000000..ba22dcd --- /dev/null +++ b/tests/Service/BookingPricingAssemblerTest.php @@ -0,0 +1,256 @@ +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; + } +} diff --git a/tests/Service/BookingSummaryAssemblerTest.php b/tests/Service/BookingSummaryAssemblerTest.php index 0f58b55..477c538 100644 --- a/tests/Service/BookingSummaryAssemblerTest.php +++ b/tests/Service/BookingSummaryAssemblerTest.php @@ -12,6 +12,7 @@ use App\Form\Model\BookingSummaryDto; use App\Form\Model\ParticipantDto; use App\Form\Model\RoomSelectionDto; use App\Service\BookingPriceCalculator; +use App\Service\BookingPricingAssembler; use App\Service\BookingSummaryAssembler; use App\Service\CmsDataProvider; use App\BusProNet\DataProvider\CountryDataProvider; @@ -73,7 +74,9 @@ class BookingSummaryAssemblerTest extends TestCase { $priceCalculator = $this->createMock(BookingPriceCalculator::class); $priceCalculator->method('calculateAllParticipantIndividualPrices')->willReturn([]); - $priceCalculator->method('getPricingBreakdown')->willReturn([ + + $pricingAssembler = $this->createMock(BookingPricingAssembler::class); + $pricingAssembler->method('getPricingBreakdown')->willReturn([ 'rooms' => [], 'services' => [], 'surcharges' => null, @@ -82,6 +85,7 @@ class BookingSummaryAssemblerTest extends TestCase return new BookingSummaryAssembler( $priceCalculator, + $pricingAssembler, $this->createMock(CmsDataProvider::class), $this->createMock(HotelLoader::class), $this->createMock(CountryDataProvider::class), diff --git a/tests/Service/RoomPricingCalculatorTest.php b/tests/Service/RoomPricingCalculatorTest.php new file mode 100644 index 0000000..03c5e7f --- /dev/null +++ b/tests/Service/RoomPricingCalculatorTest.php @@ -0,0 +1,287 @@ +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; + } +}