calculateRoomPricing($bookingDto); $servicePricing = $this->calculateServicePricing($bookingDto); $grandTotal = $this->calculateGrandTotal($bookingDto); return [ 'rooms' => $roomPricing, 'services' => $servicePricing, 'grandTotal' => $grandTotal, ]; } /** * 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): array { $roomPricing = []; // In edit mode, use room data from the booking entity if (BookingDto::MODE_EDIT === $bookingDto->getMode() && null !== $bookingDto->booking) { return $this->calculateRoomPricingFromBooking($bookingDto); } // In create mode, use room selections from the form $selectedRooms = $bookingDto->getSelectedRooms(); if (true === empty($selectedRooms)) { return $roomPricing; } foreach ($selectedRooms as $roomSelection) { $room = $this->getRoomById($bookingDto, $roomSelection->roomId); if (null === $room || null === $room->price) { continue; } // Calculate participant count for this room selection $participantCount = $room->minPax * $roomSelection->quantity; // Each participant pays the full room price $totalPrice = $participantCount * $room->price; $roomPricing[] = [ 'roomId' => $room->id, 'label' => $room->label, 'quantity' => $roomSelection->quantity, 'participantCount' => $participantCount, 'unitPrice' => $room->price, 'totalPrice' => $totalPrice, ]; } return $roomPricing; } /** * Calculates room pricing from booking entity data (edit mode). * * In edit mode, room prices come from the booking entity's individualPrice arrays. * Each participant has their room price stored in the room's individualPrice array. * * @param BookingDto $bookingDto The booking data with booking entity * * @return array Array of room pricing data with labels, quantities, and totals */ private function calculateRoomPricingFromBooking(BookingDto $bookingDto): array { $roomPricing = []; $roomGroups = []; // Group participants by room and sum their individual prices foreach ($bookingDto->booking->rooms as $room) { if (false === isset($roomGroups[$room->id])) { $roomGroups[$room->id] = [ 'room' => $room, 'participantCount' => 0, 'totalPrice' => 0.0, ]; } // Sum individual prices for all participants in this room foreach ($room->mapping as $participantIndex) { $individualPrice = $room->individualPrice[$participantIndex] ?? 0.0; $roomGroups[$room->id]['totalPrice'] += $individualPrice; ++$roomGroups[$room->id]['participantCount']; } } // Build pricing array foreach ($roomGroups as $roomId => $data) { $room = $data['room']; $participantCount = $data['participantCount']; $totalPrice = $data['totalPrice']; // Calculate average unit price (price per person) $unitPrice = $participantCount > 0 ? $totalPrice / $participantCount : 0.0; $roomPricing[] = [ 'roomId' => $room->id, 'label' => $room->label, 'quantity' => $room->totalCount, 'participantCount' => $participantCount, 'unitPrice' => $unitPrice, 'totalPrice' => $totalPrice, ]; } return $roomPricing; } /** * 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 (true === empty($participants)) { return []; } // Aggregate service selections across all eligible participants $serviceAggregation = []; foreach ($participants as $participantIndex => $participant) { // Skip ineligible participants (no skipasses available for their age) if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { continue; } $this->aggregateParticipantServices($participant, $serviceAggregation, $bookingDto); } // Add transportation services as separate line items (Zustieg, Rabatt, Parkplatz) $transportationItems = $this->aggregateTransportationServices($bookingDto); foreach ($transportationItems as $key => $transportationItem) { $serviceAggregation[$key] = $transportationItem; } // Group services by subtype and convert to pricing format 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): float { $roomTotal = $this->calculateRoomTotal($bookingDto); $serviceTotal = $this->calculateServiceTotal($bookingDto); return $roomTotal + $serviceTotal; } /** * Calculates total price for all rooms. */ public function calculateRoomTotal(BookingDto $bookingDto): float { $roomPricing = $this->calculateRoomPricing($bookingDto); return array_sum(array_column($roomPricing, 'totalPrice')); } /** * Calculates total price for all services. */ public function calculateServiceTotal(BookingDto $bookingDto): float { $servicePricing = $this->calculateServicePricing($bookingDto); return array_sum(array_column($servicePricing, 'groupTotal')); } /** * Formats a price value for display with proper German formatting. * * @param float $price The price to format * * @return string Formatted price string (e.g., "123,45") */ public function formatPrice(float $price): string { return number_format($price, 2, ',', '.'); } /** * Formats a price with Euro symbol for display. * * @param float $price The price to format * * @return string Formatted price string with Euro symbol (e.g., "€123,45") */ public function formatPriceWithSymbol(float $price): string { return '€'.$this->formatPrice($price); } /** * 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 { $participant = $bookingDto->getParticipant($participantIndex); if (null === $participant) { return 0.0; } $totalPrice = 0.0; // Add room price if participant is assigned to a room if (null !== $participant->assignedRoomId) { $room = $this->getRoomById($bookingDto, $participant->assignedRoomId); if (null !== $room && null !== $room->price) { // Each participant pays the full room price $totalPrice += $room->price; } } // Add service prices for this participant (with booking context for bulk insurance) $totalPrice += $this->calculateParticipantServiceTotal($participant, true, $bookingDto); return $totalPrice; } /** * 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 */ public function calculateAllParticipantIndividualPrices(BookingDto $bookingDto): array { $participantPrices = []; $participants = $bookingDto->getParticipants(); foreach ($participants as $index => $participant) { $participantPrices[$index] = $this->calculateIndividualParticipantPrice($bookingDto, $index); } return $participantPrices; } /** * 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. * * Only includes services where versicherungsberechnung='J' in the XML. Services with * versicherungsberechnung='N' (like CO² 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 */ public function calculateIndividualParticipantPriceExcludingInsurance(BookingDto $bookingDto, int $participantIndex): float { $participant = $bookingDto->getParticipant($participantIndex); if (null === $participant) { return 0.0; } $totalPrice = 0.0; // Add room price if participant is assigned to a room if (null !== $participant->assignedRoomId) { $room = $this->getRoomById($bookingDto, $participant->assignedRoomId); if (null !== $room && null !== $room->price) { // Each participant pays the full room price $totalPrice += $room->price; } } // Add service prices for this participant (excluding insurance) // For insurance eligibility calculation, only include services marked with versicherungsberechnung='J' $totalPrice += $this->calculateParticipantServiceTotal($participant, false, null, true); return $totalPrice; } /** * Aggregates all transportation-related services and pricing into separate line items. * * Creates separate entries for: * - Zustieg: Sum of all pickup prices and base transportation costs (positive and negative) * - Parkplatz: Sum of all parking service prices * * Only includes transportation costs from eligible participants. * * Note: Transportation discounts will be handled generically by groupServicesBySubtype as "Beförderung - Rabatt" * * @param BookingDto $bookingDto The booking data containing participants * * @return array Array of transportation line items (Zustieg, Parkplatz) */ private function aggregateTransportationServices(BookingDto $bookingDto): array { $participants = $bookingDto->getParticipants(); $pickupTotal = 0.0; // All pickup prices and base transportation costs $parkingTotal = 0.0; // Parking service costs $pickupParticipants = 0; $parkingParticipants = 0; foreach ($participants as $participantIndex => $participant) { // Skip ineligible participants (no skipasses available for their age) if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { continue; } $participantPickupCost = 0.0; $participantParkingCost = 0.0; // Transportation service pricing (outbound) if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) { $participantPickupCost += $participant->transportationOutbound->price; } // Transportation service pricing (inbound) if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) { $participantPickupCost += $participant->transportationInbound->price; } // Pickup pricing (unified for both directions) if (null !== $participant->pickup && null !== $participant->pickup->price) { $participantPickupCost += $participant->pickup->price; } // Parking service pricing if (null !== $participant->parkingService && null !== $participant->parkingService->price) { $participantParkingCost += $participant->parkingService->price; } // Aggregate participant totals (count participants who have any transportation costs) if (0.0 !== $participantPickupCost) { $pickupTotal += $participantPickupCost; ++$pickupParticipants; } if ($participantParkingCost > 0) { $parkingTotal += $participantParkingCost; ++$parkingParticipants; } } $transportationItems = []; // Add pickup entry (if any transportation/pickup costs exist) if (0.0 !== $pickupTotal) { $transportationItems['transportation_pickup'] = [ 'serviceId' => 'transportation_pickup', 'label' => 'Zustieg', 'unitPrice' => null, 'participantCount' => $pickupParticipants, 'totalPrice' => $pickupTotal, 'subType' => Constants::GROUP_TRANSPORTATION, ]; } // Add parking entry (only if positive costs) 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. * * Creates separate entries for positive costs and negative costs (discounts) within each service group. * For example: "Kurse" and "Kurse - Rabatt" if there are both positive and negative priced course services. * * @param array $serviceAggregation Aggregated service data * * @return array Grouped services by subtype with separate discount entries */ private function groupServicesBySubtype(array $serviceAggregation): array { $groupedServices = []; // First pass: separate positive and negative prices by subtype $servicesBySubtypeAndSign = []; foreach ($serviceAggregation as $serviceData) { if (0.0 === $serviceData['totalPrice']) { continue; // Skip zero-price services } $subType = $serviceData['subType'] ?? 'other'; // Normalize rental subtypes to avoid duplicate sections if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) { $subType = Constants::GROUP_RENTALS; // Normalize all rental subtypes to a single key } // Normalize insurance subtypes to avoid duplicate sections if (true === in_array($subType, Constants::TOKEN_INSURANCES, true)) { $subType = Constants::GROUP_INSURANCE; // Normalize all insurance subtypes to a single key } $isDiscount = $serviceData['totalPrice'] < 0; // Create separate buckets for positive costs and discounts $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']; } // Second pass: create display groups foreach ($servicesBySubtypeAndSign as $bucketData) { $baseGroupName = $this->getGroupNameForSubtype($bucketData['subType']); $groupName = $bucketData['isDiscount'] ? $baseGroupName.' - Rabatt' : $baseGroupName; $groupedServices[] = [ 'groupName' => $groupName, 'services' => $bucketData['services'], 'groupTotal' => $bucketData['total'], ]; } return $groupedServices; } /** * Maps service subtypes to user-friendly group names. */ private function getGroupNameForSubtype(string $subType): string { // Handle rentals array (keep for backward compatibility with non-normalized subtypes) if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) { return Constants::SERVICE_LABELS[Constants::GROUP_RENTALS]; } return Constants::SERVICE_LABELS[$subType] ?? 'Sonstige Leistungen'; } /** * Aggregates service selections from a single participant into the service aggregation array. */ private function aggregateParticipantServices( ParticipantDto $participant, array &$serviceAggregation, BookingDto $bookingDto, ): void { // Handle single service selections (skiPass, rentalInsurance) if (null !== $participant->skiPass && null !== $participant->skiPass->price) { $this->addToServiceAggregation($serviceAggregation, $participant->skiPass, 1); } if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) { $this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance, 1); } // Resolve insurance: use price-tier-adjusted insurance for bulk assignment $insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto); if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price) { $this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate, 1); } // Handle multiple service selections $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, 1); } } } } } /** * Adds a service to the aggregation array, incrementing count and updating total price. */ private function addToServiceAggregation(array &$serviceAggregation, Service $service, int $quantity): 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; } /** * Calculates the total service cost for a single participant. * * @param ParticipantDto $participant The participant to calculate services for * @param bool $includeInsurance Whether to include insurance pricing (default: true) * @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance resolution * @param bool $onlyInsuranceCalculationServices Only include services with includeInInsuranceCalculation=true (default: false) * * @return float The total service cost for this participant */ private function calculateParticipantServiceTotal(ParticipantDto $participant, bool $includeInsurance = true, ?BookingDto $bookingDto = null, bool $onlyInsuranceCalculationServices = false): float { $serviceTotal = 0.0; // Single service selections if (null !== $participant->skiPass && null !== $participant->skiPass->price) { if (false === $onlyInsuranceCalculationServices || true === $participant->skiPass->includeInInsuranceCalculation) { $serviceTotal += $participant->skiPass->price; } } if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) { if (false === $onlyInsuranceCalculationServices || true === $participant->rentalInsurance->includeInInsuranceCalculation) { $serviceTotal += $participant->rentalInsurance->price; } } // Get effective insurance (considering bulk insurance for dependent participants) $effectiveInsurance = $this->getEffectiveInsurance($participant, $bookingDto); if ($includeInsurance && null !== $effectiveInsurance && null !== $effectiveInsurance->price) { $serviceTotal += $effectiveInsurance->price; } // Transportation services if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) { if (false === $onlyInsuranceCalculationServices || true === $participant->transportationOutbound->includeInInsuranceCalculation) { $serviceTotal += $participant->transportationOutbound->price; } } if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) { if (false === $onlyInsuranceCalculationServices || true === $participant->transportationInbound->includeInInsuranceCalculation) { $serviceTotal += $participant->transportationInbound->price; } } if (null !== $participant->pickup && null !== $participant->pickup->price) { $serviceTotal += $participant->pickup->price; } if (null !== $participant->parkingService && null !== $participant->parkingService->price) { if (false === $onlyInsuranceCalculationServices || true === $participant->parkingService->includeInInsuranceCalculation) { $serviceTotal += $participant->parkingService->price; } } // Multiple service selections $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) { if (false === $onlyInsuranceCalculationServices || true === $service->includeInInsuranceCalculation) { $serviceTotal += $service->price; } } } } } return $serviceTotal; } /** * Retrieves a room by ID from the booking's travel data. */ private function getRoomById(BookingDto $bookingDto, ?int $roomId): ?Room { if (null === $roomId) { return null; } foreach ($bookingDto->travel->rooms as $room) { if ($room->id === $roomId) { return $room; } } return null; } /** * Adds an insurance to the service aggregation array. * * @param array $serviceAggregation The service aggregation array to update * @param Insurance $insurance The insurance to add * @param int $quantity The quantity of the insurance */ private function addInsuranceToServiceAggregation(array &$serviceAggregation, Insurance $insurance, int $quantity): 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; } /** * Gets the effective insurance for a participant, considering bulk insurance assignment. * * When bulk insurance is active and the participant is a dependent (index > 0), * returns the applicant's insurance. Otherwise returns the participant's own insurance. * * This method is used for pricing calculations to show correct prices when bulk * insurance is enabled, even though the actual assignment happens in the processor. * * @param ParticipantDto $participant The participant to get insurance for * @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance check * * @return Insurance|null The effective insurance for pricing purposes */ private function getEffectiveInsurance(ParticipantDto $participant, ?BookingDto $bookingDto): ?Insurance { // If no booking context, use participant's own insurance if (null === $bookingDto) { return $participant->insurance; } // Applicant always uses their own insurance if (0 === $participant->index) { return $participant->insurance; } // Check if bulk insurance is active $applicant = $bookingDto->getParticipant(0); if (null === $applicant || false === $applicant->bulkInsuranceBooking) { return $participant->insurance; } // Bulk insurance is active - use applicant's insurance for dependent participants return $applicant->insurance; } /** * Resolves the insurance to use for aggregation, handling bulk insurance with price tiers. * * When bulk insurance is active, dependent participants get price-tier-adjusted insurance * based on their individual travel price, matching the logic used in API submission. * * @param ParticipantDto $participant The participant to resolve insurance for * @param BookingDto $bookingDto The booking context * * @return Insurance|null The insurance to aggregate (null if none) */ private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance { // If participant already has insurance assigned, use it if (null !== $participant->insurance) { return $participant->insurance; } // Check if bulk insurance is active $applicant = $bookingDto->getParticipant(0); if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) { return null; // No bulk insurance active } // Applicant always uses their own insurance if (0 === $participant->index) { return $participant->insurance; } // For dependent participants: calculate price-tier-adjusted insurance // This mirrors the logic in BookingDataProcessor::applyBulkInsuranceIfActive() $availableInsurances = $bookingDto->travel->insurances; // Exclude complementary insurances (only available as part of packages) $availableInsurances = $this->insuranceTypeFilterService->filterNonComplementary($availableInsurances); // Get insurances of the same type as applicant's selection $sameTypeInsurances = $this->insuranceTypeFilterService->filterByType( $availableInsurances, $applicant->insurance ); // Calculate participant's travel price (excluding insurance) $travelPrice = $this->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participant->index); // Get eligible insurances for THIS participant (price tier adjusted) $eligibleInsurances = $this->insuranceEligibilityService->getEligibleInsurances( $sameTypeInsurances, $participant, $bookingDto, $travelPrice ); // Return first eligible insurance (sorted by price) return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null; } }