diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 7879b05..3398240 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -63,6 +63,9 @@ class ParticipantDto // Parking service for self-organized transportation (boolean: true if parking requested) public bool $parking = false; + // Parking service object for pricing calculation (new, contains actual service with pricing) + public ?Service $parkingService = null; + // Deprecated properties for backward compatibility - will be removed in future version public ?Service $transportationServiceTo = null; public ?Service $transportationServiceFro = null; diff --git a/src/Form/Service/ParticipantParkingFieldHandler.php b/src/Form/Service/ParticipantParkingFieldHandler.php index 1433408..e1fe7a0 100644 --- a/src/Form/Service/ParticipantParkingFieldHandler.php +++ b/src/Form/Service/ParticipantParkingFieldHandler.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Form\Service; +use App\BusProNet\Constants; +use App\BusProNet\Model\Service; use App\BusProNet\Utility\DirectionMapper; use App\Form\Model\BookingDtoInterface; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; @@ -54,14 +56,23 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler // Check if parking is applicable (outbound transportation is PKW) if (!$this->isParkingApplicable($participant)) { $participant->parking = false; // Clear parking when outbound is not PKW + $participant->parkingService = null; // Clear parking service object return; } $parkingSelected = $this->getFieldValue($submittedData, $this->getFieldName()); + $isParkingSelected = (bool) $parkingSelected; - // Store boolean value directly (true if checkbox checked, false otherwise) - $participant->parking = (bool) $parkingSelected; + // Store boolean value for backward compatibility + $participant->parking = $isParkingSelected; + + // Store parking service object for pricing calculation + if (true === $isParkingSelected) { + $participant->parkingService = $this->findParkingService($bookingDto); + } else { + $participant->parkingService = null; + } } /** @@ -77,4 +88,26 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler { return DirectionMapper::SUBTYPE_CAR_API === $participant->transportationOutbound?->subType; } + + /** + * Finds the parking service from available services. + * + * Gets the first (and typically only) parking service for pricing calculation. + * Returns null if no parking services are available. + * + * @param BookingDtoInterface $bookingDto The booking DTO containing travel data + * + * @return Service|null The parking service object, or null if not found + */ + private function findParkingService(BookingDtoInterface $bookingDto): ?Service + { + $parkingServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true); + + if (empty($parkingServices)) { + return null; + } + + // Return the first parking service (there's typically only one parking type) + return reset($parkingServices); + } } diff --git a/src/Service/BookingPriceCalculatorService.php b/src/Service/BookingPriceCalculatorService.php index 8dc1a94..309d709 100644 --- a/src/Service/BookingPriceCalculatorService.php +++ b/src/Service/BookingPriceCalculatorService.php @@ -102,6 +102,12 @@ class BookingPriceCalculatorService $this->aggregateParticipantServices($participant, $serviceAggregation); } + // 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); } @@ -166,37 +172,153 @@ class BookingPriceCalculatorService } /** - * Groups services by their subtypes for display. + * 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 + * + * Note: Transportation discounts will be handled generically by groupServicesBySubtype as "Beförderung - Rabatt" + * + * @param BookingDtoInterface $bookingDto The booking data containing participants + * + * @return array Array of transportation line items (Zustieg, Parkplatz) + */ + private function aggregateTransportationServices(BookingDtoInterface $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 $participant) { + $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 (outbound) + if (null !== $participant->pickupOutbound && null !== $participant->pickupOutbound->price) { + $participantPickupCost += $participant->pickupOutbound->price; + } + + // Pickup pricing (inbound) + if (null !== $participant->pickupInbound && null !== $participant->pickupInbound->price) { + $participantPickupCost += $participant->pickupInbound->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' => 'transportation', + ]; + } + + // Add parking entry (only if positive costs) + if ($parkingTotal > 0) { + $transportationItems['transportation_parking'] = [ + 'serviceId' => 'transportation_parking', + 'label' => 'Parkplatz', + 'unitPrice' => null, + 'participantCount' => $parkingParticipants, + 'totalPrice' => $parkingTotal, + 'subType' => '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 + * @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 ($serviceData['totalPrice'] <= 0) { - continue; + if (0.0 === $serviceData['totalPrice']) { + continue; // Skip zero-price services } $subType = $serviceData['subType'] ?? 'other'; - $groupName = $this->getGroupNameForSubtype($subType); + $isDiscount = $serviceData['totalPrice'] < 0; - if (false === isset($groupedServices[$groupName])) { - $groupedServices[$groupName] = [ - 'groupName' => $groupName, + // 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' => [], - 'groupTotal' => 0.0, + 'total' => 0.0, + 'participantCount' => 0, ]; } - $groupedServices[$groupName]['services'][] = $serviceData; - $groupedServices[$groupName]['groupTotal'] += $serviceData['totalPrice']; + $servicesBySubtypeAndSign[$bucketKey]['services'][] = $serviceData; + $servicesBySubtypeAndSign[$bucketKey]['total'] += $serviceData['totalPrice']; + $servicesBySubtypeAndSign[$bucketKey]['participantCount'] += $serviceData['participantCount']; } - return array_values($groupedServices); + // 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; } /** @@ -209,6 +331,7 @@ class BookingPriceCalculatorService Constants::TOKEN_SKI_PASS => 'Skipässe', Constants::TOKEN_ADDITIONAL => 'Zusatzleistungen', Constants::TOKEN_BOARD => 'Verpflegung', + 'transportation' => 'Beförderung', ]; // Handle rentals array