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 total price for all services. */ public function calculateServiceTotal(BookingDto $bookingDto): float { $servicePricing = $this->calculateServicePricing($bookingDto); return array_sum(array_column($servicePricing, 'groupTotal')); } /** * 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 (Befoerderung, Rabatt, Parkplatz) */ private function aggregateTransportationServices(BookingDto $bookingDto): array { $participants = $bookingDto->getParticipants(); $transportationPositiveTotal = 0.0; // Positive transportation/pickup costs $transportationDiscountTotal = 0.0; // Negative transportation prices (discounts) $parkingTotal = 0.0; // Parking service costs $transportationParticipants = 0; $discountParticipants = 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; } $participantTransportationPositiveCost = 0.0; $participantTransportationDiscountCost = 0.0; $participantParkingCost = 0.0; // Transportation service pricing (outbound) if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) { if ($participant->transportationOutbound->price < 0) { $participantTransportationDiscountCost += $participant->transportationOutbound->price; } else { $participantTransportationPositiveCost += $participant->transportationOutbound->price; } } // Transportation service pricing (inbound) if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) { if ($participant->transportationInbound->price < 0) { $participantTransportationDiscountCost += $participant->transportationInbound->price; } else { $participantTransportationPositiveCost += $participant->transportationInbound->price; } } // Pickup pricing - only charged when outbound transportation is bus // BusProNet API ignores pickup surcharges for self-organized (PKW) outbound $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; } } // Parking service pricing if (null !== $participant->parkingService && null !== $participant->parkingService->price) { $participantParkingCost += $participant->parkingService->price; } // Aggregate participant totals if ($participantTransportationPositiveCost > 0) { $transportationPositiveTotal += $participantTransportationPositiveCost; ++$transportationParticipants; } if ($participantTransportationDiscountCost < 0) { $transportationDiscountTotal += $participantTransportationDiscountCost; ++$discountParticipants; } if ($participantParkingCost > 0) { $parkingTotal += $participantParkingCost; ++$parkingParticipants; } } $transportationItems = []; // Add transportation entry (only positive costs) if ($transportationPositiveTotal > 0) { $transportationItems['transportation_positive'] = [ 'serviceId' => 'transportation_positive', 'label' => 'Beförderung', 'unitPrice' => null, 'participantCount' => $transportationParticipants, 'totalPrice' => $transportationPositiveTotal, 'subType' => Constants::GROUP_TRANSPORTATION, ]; } // Add discount entry (only negative costs) if ($transportationDiscountTotal < 0) { $transportationItems['transportation_discount'] = [ 'serviceId' => 'transportation_discount', 'label' => 'Beförderung - Rabatt', 'unitPrice' => null, 'participantCount' => $discountParticipants, 'totalPrice' => $transportationDiscountTotal, 'subType' => Constants::GROUP_TRANSPORTATION.'_discount', ]; } // 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) { $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 // Skip synthetic "no insurance" option - it's a UI construct that shouldn't appear in summary $insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto); if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price && false === $insuranceToAggregate->isNoInsurance()) { $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; } /** * 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; } /** * 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() // Get selectable (non-complementary) insurances with caching $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); // Get insurances of the same type as applicant's selection $sameTypeInsurances = $this->insuranceService->filterByType( $selectableInsurances, $applicant->insurance ); // Calculate travel price for eligibility checks $travelPrice = $this->participantPricingCalculator->calculateIndividualParticipantPriceExcludingInsurance( $bookingDto, $participant->index ); // Get eligible insurances for THIS participant (price tier adjusted) $eligibleInsurances = $this->insuranceService->getEligibleInsurances( $sameTypeInsurances, $participant, $bookingDto, $travelPrice ); // Return first eligible insurance (sorted by price) return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null; } }