` carries no type attribute, so the label is all there is. */ private const BOARDING_SURCHARGE_LABEL = 'Abfahrtsortzuschlag'; public function __construct( private readonly RoomPricingCalculator $roomPricingCalculator, private readonly ParticipantEligibilityChecker $participantEligibilityChecker, ) { } /** * Assembles a complete pricing breakdown for display or diagnostic use. * * @return array{rooms: array>, services: array>, grandTotal: float, surcharges?: array>} */ public function getPricingBreakdown( BookingDto $bookingDto, string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT, ): array { $roomPricing = $this->roomPricingCalculator->calculateRoomPricing($bookingDto, $roomPricingMode); $servicePricing = $this->calculateServicePricing($bookingDto); $grandTotal = array_sum(array_column($roomPricing, 'totalPrice')) + array_sum(array_column($servicePricing, 'groupTotal')); $result = [ 'rooms' => $roomPricing, 'services' => $servicePricing, 'grandTotal' => $grandTotal, ]; if (null !== $bookingDto->booking && [] !== $bookingDto->booking->surcharges) { $surchargePricing = $this->calculateSurchargePricing($bookingDto->booking); if ([] !== $surchargePricing) { $result['surcharges'] = $surchargePricing; $result['grandTotal'] += array_sum(array_column($surchargePricing, 'totalPrice')); } } return $result; } /** * Calculates pricing for all selected services across all participants, grouped by subtype. * * Only includes services from billed participants: not cancelled, and eligible (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->isParticipantBilled($bookingDto, $participantIndex, $participant)) { 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) { // BusPro reports a Zuschlag line even when it nets to nothing, e.g. Stornokosten of 0,00 € if (0.0 === round($surcharge->totalPrice ?? 0.0, 2)) { continue; } // Listed per leg among the transportation services, from the participants' current // selection rather than the stored booking, so BusPro's copy would count it twice if (str_starts_with($surcharge->label ?? '', self::BOARDING_SURCHARGE_LABEL)) { continue; } $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 base transportation costs * - Beförderung - Rabatt: Sum of all negative base transportation prices (discounts) * - Abfahrtsortzuschlag (Hinfahrt) / (Rückfahrt): Boarding surcharges per leg, labelled as * BusPro bills them, so the breakdown reads like the invoice * - 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(); $totals = [ 'transportation_positive' => 0.0, 'transportation_discount' => 0.0, 'transportation_pickup' => 0.0, 'transportation_drop_off' => 0.0, 'transportation_parking' => 0.0, ]; $participantCounts = array_fill_keys(array_keys($totals), 0); foreach ($participants as $participantIndex => $participant) { if (false === $this->isParticipantBilled($bookingDto, $participantIndex, $participant)) { continue; } $participantCosts = array_fill_keys(array_keys($totals), 0.0); foreach ([$participant->transportationOutbound, $participant->transportationInbound] as $transportation) { if (null !== $transportation && null !== $transportation->price) { $participantCosts[$transportation->price < 0 ? 'transportation_discount' : 'transportation_positive'] += $transportation->price; } } $hasOutboundBus = null !== $participant->transportationOutbound && DirectionMapper::isBus($participant->transportationOutbound->subType); $hasInboundBus = null !== $participant->transportationInbound && DirectionMapper::isBus($participant->transportationInbound->subType); if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) { $participantCosts['transportation_pickup'] += $participant->pickup->price; } if ($hasInboundBus && null !== $participant->dropOff && null !== $participant->dropOff->price) { $participantCosts['transportation_drop_off'] += $participant->dropOff->price; } if (null !== $participant->parkingService && null !== $participant->parkingService->price) { $participantCosts['transportation_parking'] += $participant->parkingService->price; } foreach ($participantCosts as $key => $cost) { if (0.0 !== round($cost, 2)) { $totals[$key] += $cost; ++$participantCounts[$key]; } } } $definitions = [ 'transportation_positive' => ['Beförderung', Constants::GROUP_TRANSPORTATION], 'transportation_discount' => ['Beförderung - Rabatt', Constants::GROUP_TRANSPORTATION.'_discount'], 'transportation_pickup' => [self::BOARDING_SURCHARGE_LABEL.' (Hinfahrt)', Constants::GROUP_TRANSPORTATION], 'transportation_drop_off' => [self::BOARDING_SURCHARGE_LABEL.' (Rückfahrt)', Constants::GROUP_TRANSPORTATION], 'transportation_parking' => [Constants::SERVICE_LABELS[Constants::TOKEN_PARKING], Constants::GROUP_TRANSPORTATION], ]; $transportationItems = []; foreach ($definitions as $key => [$label, $subType]) { if (0 === $participantCounts[$key]) { continue; } $transportationItems[$key] = [ 'serviceId' => $key, 'label' => $label, 'unitPrice' => null, 'participantCount' => $participantCounts[$key], 'totalPrice' => $totals[$key], 'subType' => $subType, ]; } 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 ('' === $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; } /** * Whether the participant's services count towards the breakdown. * * A cancelled participant is billed only through BusPro's Stornokosten surcharge. Their services * are never sent back to BusPro, so counting them would put the breakdown above BusPro's total. */ private function isParticipantBilled(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool { return false === $participant->isCanceled() && true === $this->participantEligibilityChecker->isParticipantEligible($bookingDto, $participantIndex); } 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'; } /** @param array> $serviceAggregation */ 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); } // The session DTO is kept truthful by ApplicantInsuranceCascade, which materialises // bulk and family assignments as they are made - so this is simply what the participant holds. $insuranceToAggregate = $participant->insurance; 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) { foreach ($serviceArray as $service) { if (null !== $service->price) { $this->addToServiceAggregation($serviceAggregation, $service); } } } } /** @param array> $serviceAggregation */ 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; } /** @param array> $serviceAggregation */ 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, ]; } }