434 lines
18 KiB
PHP
434 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use App\BusProNet\Constants;
|
|
use App\BusProNet\Model\Insurance;
|
|
use App\BusProNet\Model\Service;
|
|
use App\Form\Model\BookingDto;
|
|
use App\Form\Model\ParticipantDto;
|
|
|
|
/**
|
|
* Calculates pricing for services across all participants in a booking.
|
|
*
|
|
* Handles aggregation of service selections, transportation costs, and
|
|
* insurance pricing with bulk insurance support. Groups services by
|
|
* subtype for display with separate discount entries.
|
|
*/
|
|
class ServicePricingCalculator
|
|
{
|
|
public function __construct(
|
|
private readonly ParticipantEligibilityService $participantEligibilityService,
|
|
private readonly InsuranceService $insuranceService,
|
|
private readonly ParticipantPricingCalculator $participantPricingCalculator,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* 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 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;
|
|
}
|
|
}
|