Files
myep/src/Service/BookingPriceCalculatorService.php
T

634 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\ParticipantDto;
/**
* Calculates pricing for booking components including rooms and services.
*
* This service provides comprehensive pricing calculations for the booking system,
* handling room pricing based on quantities and service pricing per participant.
* It returns structured pricing data for display in forms and summaries.
*/
class BookingPriceCalculatorService
{
public function __construct(
private readonly ParticipantEligibilityService $participantEligibilityService
) {
}
/**
* Calculates comprehensive pricing breakdown for a booking.
*
* @param BookingDtoInterface $bookingDto The booking data to calculate pricing for
*
* @return array{rooms: array, services: array, grandTotal: float} Complete pricing breakdown
*/
public function getPricingBreakdown(BookingDtoInterface $bookingDto): array
{
$roomPricing = $this->calculateRoomPricing($bookingDto);
$servicePricing = $this->calculateServicePricing($bookingDto);
$grandTotal = $this->calculateGrandTotal($bookingDto);
return [
'rooms' => $roomPricing,
'services' => $servicePricing,
'grandTotal' => $grandTotal,
];
}
/**
* Calculates pricing for all selected rooms.
*
* @param BookingDtoInterface $bookingDto The booking data containing room selections
*
* @return array Array of room pricing data with labels, quantities, and totals
*/
public function calculateRoomPricing(BookingDtoInterface $bookingDto): array
{
$roomPricing = [];
if (false === $bookingDto instanceof BookingCreateDto) {
return $roomPricing;
}
$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 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 BookingDtoInterface $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(BookingDtoInterface $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);
}
// 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 BookingDtoInterface $bookingDto The booking data to calculate total for
*
* @return float The grand total price
*/
public function calculateGrandTotal(BookingDtoInterface $bookingDto): float
{
$roomTotal = $this->calculateRoomTotal($bookingDto);
$serviceTotal = $this->calculateServiceTotal($bookingDto);
return $roomTotal + $serviceTotal;
}
/**
* Calculates total price for all rooms.
*/
public function calculateRoomTotal(BookingDtoInterface $bookingDto): float
{
$roomPricing = $this->calculateRoomPricing($bookingDto);
return array_sum(array_column($roomPricing, 'totalPrice'));
}
/**
* Calculates total price for all services.
*/
public function calculateServiceTotal(BookingDtoInterface $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 BookingDtoInterface $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(BookingDtoInterface $bookingDto, int $participantIndex): float
{
if (false === $bookingDto instanceof BookingCreateDto) {
return 0.0;
}
$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
$totalPrice += $this->calculateParticipantServiceTotal($participant);
return $totalPrice;
}
/**
* Calculates individual prices for all participants in a booking.
*
* @param BookingDtoInterface $bookingDto The booking data containing all participants
*
* @return array Array indexed by participant index containing individual prices
*/
public function calculateAllParticipantIndividualPrices(BookingDtoInterface $bookingDto): array
{
if (false === $bookingDto instanceof BookingCreateDto) {
return [];
}
$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.
*
* @param BookingDtoInterface $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
*/
public function calculateIndividualParticipantPriceExcludingInsurance(BookingDtoInterface $bookingDto, int $participantIndex): float
{
if (false === $bookingDto instanceof BookingCreateDto) {
return 0.0;
}
$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)
$totalPrice += $this->calculateParticipantServiceTotal($participant, false);
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 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 $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): 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);
}
if (null !== $participant->insurance && null !== $participant->insurance->price) {
$this->addInsuranceToServiceAggregation($serviceAggregation, $participant->insurance, 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)
*
* @return float The total service cost for this participant
*/
private function calculateParticipantServiceTotal(ParticipantDto $participant, bool $includeInsurance = true): float
{
$serviceTotal = 0.0;
// Single service selections
if (null !== $participant->skiPass && null !== $participant->skiPass->price) {
$serviceTotal += $participant->skiPass->price;
}
if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) {
$serviceTotal += $participant->rentalInsurance->price;
}
if ($includeInsurance && null !== $participant->insurance && null !== $participant->insurance->price) {
$serviceTotal += $participant->insurance->price;
}
// Transportation services
if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) {
$serviceTotal += $participant->transportationOutbound->price;
}
if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) {
$serviceTotal += $participant->transportationInbound->price;
}
if (null !== $participant->pickup && null !== $participant->pickup->price) {
$serviceTotal += $participant->pickup->price;
}
if (null !== $participant->parkingService && null !== $participant->parkingService->price) {
$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) {
$serviceTotal += $service->price;
}
}
}
}
return $serviceTotal;
}
/**
* Retrieves a room by ID from the booking's travel data.
*/
private function getRoomById(BookingCreateDto $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;
}
}