feat: move display-assembly out of BookingPriceCalculator
This commit is contained in:
@@ -15,7 +15,6 @@ use App\Form\Model\BookingDto;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Entity\User;
|
||||
use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingSessionStore;
|
||||
use App\Service\MailjetApiClient;
|
||||
@@ -41,7 +40,6 @@ class Step4Controller extends AbstractController
|
||||
private readonly BookingConfigurator $bookingService,
|
||||
private readonly BookingSessionStore $bookingSessionService,
|
||||
private readonly BookingCreateContextFactory $createContextFactory,
|
||||
private readonly BookingPriceCalculator $priceCalculator,
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly MailjetApiClient $newsletterService,
|
||||
|
||||
@@ -4,132 +4,32 @@ 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;
|
||||
|
||||
/**
|
||||
* Facade for booking pricing calculations.
|
||||
* Computes numeric price totals for bookings and individual participants.
|
||||
*
|
||||
* Provides comprehensive pricing calculations for the booking system by
|
||||
* coordinating room and participant calculations and handling service aggregation directly.
|
||||
* Returns structured pricing data for display in forms and summaries.
|
||||
* All methods return floats or arrays of floats — no display formatting.
|
||||
* For display-ready pricing breakdowns, use BookingPricingAssembler.
|
||||
*/
|
||||
class BookingPriceCalculator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RoomPricingCalculator $roomPricingCalculator,
|
||||
private readonly ParticipantEligibilityChecker $participantEligibilityService,
|
||||
private readonly InsuranceManager $insuranceService,
|
||||
private readonly BookingPricingAssembler $pricingAssembler,
|
||||
private readonly ParticipantPricingCalculator $participantPricingCalculator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates comprehensive pricing breakdown for a booking.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data to calculate pricing for
|
||||
*
|
||||
* @return array{rooms: array, services: array, grandTotal: float} Complete pricing breakdown
|
||||
*/
|
||||
public function getPricingBreakdown(
|
||||
BookingDto $bookingDto,
|
||||
string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT,
|
||||
): array
|
||||
{
|
||||
$roomPricing = $this->roomPricingCalculator->calculateRoomPricing($bookingDto, $roomPricingMode);
|
||||
$servicePricing = $this->calculateServicePricing($bookingDto);
|
||||
$grandTotal = $this->calculateGrandTotal($bookingDto, $roomPricingMode);
|
||||
|
||||
$result = [
|
||||
'rooms' => $roomPricing,
|
||||
'services' => $servicePricing,
|
||||
'grandTotal' => $grandTotal,
|
||||
];
|
||||
|
||||
// Add surcharges if in edit mode with booking data
|
||||
if (null !== $bookingDto->booking && [] !== $bookingDto->booking->surcharges) {
|
||||
$surchargePricing = $this->calculateSurchargePricing($bookingDto->booking);
|
||||
|
||||
if ([] !== $surchargePricing) {
|
||||
$result['surcharges'] = $surchargePricing;
|
||||
|
||||
// Add surcharge total to grand total
|
||||
$surchargeTotal = array_sum(array_column($surchargePricing, 'totalPrice'));
|
||||
$result['grandTotal'] += $surchargeTotal;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates pricing for all selected rooms.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data containing room selections
|
||||
*
|
||||
* @return array Array of room pricing data with labels, quantities, and totals
|
||||
*/
|
||||
public function calculateRoomPricing(
|
||||
BookingDto $bookingDto,
|
||||
string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT,
|
||||
): array
|
||||
{
|
||||
return $this->roomPricingCalculator->calculateRoomPricing($bookingDto, $roomPricingMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<int, array<string, mixed>> Array of service groups with each group containing services of the same subtype
|
||||
*/
|
||||
public function calculateServicePricing(BookingDto $bookingDto): array
|
||||
{
|
||||
$participants = $bookingDto->getParticipants();
|
||||
|
||||
if ([] === $participants) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$serviceAggregation = [];
|
||||
|
||||
foreach ($participants as $participantIndex => $participant) {
|
||||
if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->aggregateParticipantServices($participant, $serviceAggregation, $bookingDto);
|
||||
}
|
||||
|
||||
foreach ($this->aggregateTransportationServices($bookingDto) as $key => $transportationItem) {
|
||||
$serviceAggregation[$key] = $transportationItem;
|
||||
}
|
||||
|
||||
return $this->groupServicesBySubtype($serviceAggregation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the grand total for the entire booking.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data to calculate total for
|
||||
*
|
||||
* @return float The grand total price
|
||||
*/
|
||||
public function calculateGrandTotal(
|
||||
BookingDto $bookingDto,
|
||||
string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT,
|
||||
): float
|
||||
{
|
||||
$roomTotal = $this->roomPricingCalculator->calculateRoomTotal($bookingDto, $roomPricingMode);
|
||||
$serviceTotal = $this->calculateServiceTotal($bookingDto);
|
||||
|
||||
return $roomTotal + $serviceTotal;
|
||||
): float {
|
||||
return $this->roomPricingCalculator->calculateRoomTotal($bookingDto, $roomPricingMode)
|
||||
+ $this->calculateServiceTotal($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,387 +38,28 @@ class BookingPriceCalculator
|
||||
public function calculateRoomTotal(
|
||||
BookingDto $bookingDto,
|
||||
string $roomPricingMode = RoomPricingCalculator::PRICING_MODE_ASSIGNMENT,
|
||||
): float
|
||||
{
|
||||
): float {
|
||||
return $this->roomPricingCalculator->calculateRoomTotal($bookingDto, $roomPricingMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total price for all services.
|
||||
* Calculates total price for all services across all eligible participants.
|
||||
*
|
||||
* Delegates to BookingPricingAssembler to guarantee the same insurance-tier
|
||||
* resolution used in the display breakdown (including bulk-family-insurance
|
||||
* price-tier adjustment per dependent). This keeps the numeric total and the
|
||||
* displayed total in sync.
|
||||
*/
|
||||
public function calculateServiceTotal(BookingDto $bookingDto): float
|
||||
{
|
||||
$servicePricing = $this->calculateServicePricing($bookingDto);
|
||||
|
||||
$total = 0.0;
|
||||
foreach ($servicePricing as $group) {
|
||||
$total += $group['groupTotal'];
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates surcharge pricing grouped by label (edit mode only).
|
||||
*
|
||||
* Returns surcharges in a format similar to services for display in summary.
|
||||
* Groups surcharges by label and counts participant assignments.
|
||||
*
|
||||
* @param \App\BusProNet\Model\Booking $bookingData The booking entity with surcharge information
|
||||
*
|
||||
* @return array Array of surcharge data with labels, counts, and totals
|
||||
*/
|
||||
public function calculateSurchargePricing(\App\BusProNet\Model\Booking $bookingData): array
|
||||
{
|
||||
$surchargePricing = [];
|
||||
|
||||
foreach ($bookingData->surcharges as $surcharge) {
|
||||
$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:
|
||||
* - 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<string, array<string, mixed>> Array of transportation line items
|
||||
*/
|
||||
private function aggregateTransportationServices(BookingDto $bookingDto): array
|
||||
{
|
||||
$participants = $bookingDto->getParticipants();
|
||||
|
||||
$transportationPositiveTotal = 0.0;
|
||||
$transportationDiscountTotal = 0.0;
|
||||
$parkingTotal = 0.0;
|
||||
|
||||
$transportationParticipants = 0;
|
||||
$discountParticipants = 0;
|
||||
$parkingParticipants = 0;
|
||||
|
||||
foreach ($participants as $participantIndex => $participant) {
|
||||
if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$participantTransportationPositiveCost = 0.0;
|
||||
$participantTransportationDiscountCost = 0.0;
|
||||
$participantParkingCost = 0.0;
|
||||
|
||||
if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) {
|
||||
if ($participant->transportationOutbound->price < 0) {
|
||||
$participantTransportationDiscountCost += $participant->transportationOutbound->price;
|
||||
} else {
|
||||
$participantTransportationPositiveCost += $participant->transportationOutbound->price;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) {
|
||||
if ($participant->transportationInbound->price < 0) {
|
||||
$participantTransportationDiscountCost += $participant->transportationInbound->price;
|
||||
} else {
|
||||
$participantTransportationPositiveCost += $participant->transportationInbound->price;
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $participant->dropOff && null !== $participant->dropOff->price) {
|
||||
if ($participant->dropOff->price < 0) {
|
||||
$participantTransportationDiscountCost += $participant->dropOff->price;
|
||||
} else {
|
||||
$participantTransportationPositiveCost += $participant->dropOff->price;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $participant->parkingService && null !== $participant->parkingService->price) {
|
||||
$participantParkingCost += $participant->parkingService->price;
|
||||
}
|
||||
|
||||
if ($participantTransportationPositiveCost > 0) {
|
||||
$transportationPositiveTotal += $participantTransportationPositiveCost;
|
||||
++$transportationParticipants;
|
||||
}
|
||||
if ($participantTransportationDiscountCost < 0) {
|
||||
$transportationDiscountTotal += $participantTransportationDiscountCost;
|
||||
++$discountParticipants;
|
||||
}
|
||||
if ($participantParkingCost > 0) {
|
||||
$parkingTotal += $participantParkingCost;
|
||||
++$parkingParticipants;
|
||||
}
|
||||
}
|
||||
|
||||
$transportationItems = [];
|
||||
|
||||
if ($transportationPositiveTotal > 0) {
|
||||
$transportationItems['transportation_positive'] = [
|
||||
'serviceId' => 'transportation_positive',
|
||||
'label' => 'Beförderung',
|
||||
'unitPrice' => null,
|
||||
'participantCount' => $transportationParticipants,
|
||||
'totalPrice' => $transportationPositiveTotal,
|
||||
'subType' => Constants::GROUP_TRANSPORTATION,
|
||||
];
|
||||
}
|
||||
|
||||
if ($transportationDiscountTotal < 0) {
|
||||
$transportationItems['transportation_discount'] = [
|
||||
'serviceId' => 'transportation_discount',
|
||||
'label' => 'Beförderung - Rabatt',
|
||||
'unitPrice' => null,
|
||||
'participantCount' => $discountParticipants,
|
||||
'totalPrice' => $transportationDiscountTotal,
|
||||
'subType' => Constants::GROUP_TRANSPORTATION.'_discount',
|
||||
];
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $serviceAggregation
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function groupServicesBySubtype(array $serviceAggregation): array
|
||||
{
|
||||
$groupedServices = [];
|
||||
$servicesBySubtypeAndSign = [];
|
||||
|
||||
foreach ($serviceAggregation as $serviceData) {
|
||||
$subType = $serviceData['subType'] ?? 'other';
|
||||
if (null === $subType || '' === $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;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
$insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto);
|
||||
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) {
|
||||
if (true === is_array($serviceArray)) {
|
||||
foreach ($serviceArray as $service) {
|
||||
if ($service instanceof Service && null !== $service->price) {
|
||||
$this->addToServiceAggregation($serviceAggregation, $service);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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<string, mixed> $serviceData
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance
|
||||
{
|
||||
if (null !== $participant->insurance) {
|
||||
return $participant->insurance;
|
||||
}
|
||||
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (0 === $participant->index) {
|
||||
return $participant->insurance;
|
||||
}
|
||||
|
||||
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
||||
$sameTypeInsurances = $this->insuranceService->filterByType(
|
||||
$selectableInsurances,
|
||||
$applicant->insurance
|
||||
);
|
||||
$travelPrice = $this->participantPricingCalculator->calculateIndividualParticipantPriceExcludingInsurance(
|
||||
$bookingDto,
|
||||
$participant->index
|
||||
);
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances(
|
||||
$sameTypeInsurances,
|
||||
$participant,
|
||||
$bookingDto,
|
||||
$travelPrice
|
||||
);
|
||||
|
||||
return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
|
||||
return array_sum(array_column(
|
||||
$this->pricingAssembler->calculateServicePricing($bookingDto),
|
||||
'groupTotal'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 BookingDto $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(BookingDto $bookingDto, int $participantIndex): float
|
||||
{
|
||||
@@ -528,9 +69,7 @@ class BookingPriceCalculator
|
||||
/**
|
||||
* Calculates individual prices for all participants in a booking.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data containing all participants
|
||||
*
|
||||
* @return array Array indexed by participant index containing individual prices
|
||||
* @return array<int, float>
|
||||
*/
|
||||
public function calculateAllParticipantIndividualPrices(BookingDto $bookingDto): array
|
||||
{
|
||||
@@ -540,17 +79,12 @@ class BookingPriceCalculator
|
||||
/**
|
||||
* 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.
|
||||
* Used for insurance eligibility filtering to avoid circular dependency where
|
||||
* insurance selection affects travel price which affects insurance eligibility.
|
||||
*
|
||||
* Only includes services where versicherungsberechnung='J' in the XML. Services with
|
||||
* versicherungsberechnung='N' (like CO2 compensation) are excluded from the calculation
|
||||
* as per BPN API requirements for insurance tier determination.
|
||||
*
|
||||
* @param BookingDto $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 and non-calculated services
|
||||
* versicherungsberechnung='N' (like CO2 compensation) are excluded as per BPN API
|
||||
* requirements for insurance tier determination.
|
||||
*/
|
||||
public function calculateIndividualParticipantPriceExcludingInsurance(
|
||||
BookingDto $bookingDto,
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
|
||||
/**
|
||||
* Assembles display-ready pricing breakdowns for booking summaries and diagnostics.
|
||||
*
|
||||
* Transforms raw booking data into structured arrays of rooms, service groups,
|
||||
* and surcharges ready for rendering in Twig templates or diagnostic payloads.
|
||||
* For numeric price totals only, use BookingPriceCalculator.
|
||||
*/
|
||||
class BookingPricingAssembler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RoomPricingCalculator $roomPricingCalculator,
|
||||
private readonly ParticipantEligibilityChecker $participantEligibilityChecker,
|
||||
private readonly InsuranceManager $insuranceService,
|
||||
private readonly ParticipantPricingCalculator $participantPricingCalculator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 eligible participants (those with available skipasses for their age).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function calculateServicePricing(BookingDto $bookingDto): array
|
||||
{
|
||||
$participants = $bookingDto->getParticipants();
|
||||
|
||||
if ([] === $participants) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$serviceAggregation = [];
|
||||
|
||||
foreach ($participants as $participantIndex => $participant) {
|
||||
if (false === $this->participantEligibilityChecker->isParticipantEligible($bookingDto, $participantIndex)) {
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
public function calculateSurchargePricing(Booking $bookingData): array
|
||||
{
|
||||
$surchargePricing = [];
|
||||
|
||||
foreach ($bookingData->surcharges as $surcharge) {
|
||||
$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 pickup prices and base transportation costs
|
||||
* - Beförderung - Rabatt: Sum of all negative transportation prices (discounts)
|
||||
* - Parkplatz: Sum of all parking service prices
|
||||
*
|
||||
* Only includes transportation costs from eligible participants.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function aggregateTransportationServices(BookingDto $bookingDto): array
|
||||
{
|
||||
$participants = $bookingDto->getParticipants();
|
||||
|
||||
$transportationPositiveTotal = 0.0;
|
||||
$transportationDiscountTotal = 0.0;
|
||||
$parkingTotal = 0.0;
|
||||
|
||||
$transportationParticipants = 0;
|
||||
$discountParticipants = 0;
|
||||
$parkingParticipants = 0;
|
||||
|
||||
foreach ($participants as $participantIndex => $participant) {
|
||||
if (false === $this->participantEligibilityChecker->isParticipantEligible($bookingDto, $participantIndex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$participantTransportationPositiveCost = 0.0;
|
||||
$participantTransportationDiscountCost = 0.0;
|
||||
$participantParkingCost = 0.0;
|
||||
|
||||
if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) {
|
||||
if ($participant->transportationOutbound->price < 0) {
|
||||
$participantTransportationDiscountCost += $participant->transportationOutbound->price;
|
||||
} else {
|
||||
$participantTransportationPositiveCost += $participant->transportationOutbound->price;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) {
|
||||
if ($participant->transportationInbound->price < 0) {
|
||||
$participantTransportationDiscountCost += $participant->transportationInbound->price;
|
||||
} else {
|
||||
$participantTransportationPositiveCost += $participant->transportationInbound->price;
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $participant->dropOff && null !== $participant->dropOff->price) {
|
||||
if ($participant->dropOff->price < 0) {
|
||||
$participantTransportationDiscountCost += $participant->dropOff->price;
|
||||
} else {
|
||||
$participantTransportationPositiveCost += $participant->dropOff->price;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $participant->parkingService && null !== $participant->parkingService->price) {
|
||||
$participantParkingCost += $participant->parkingService->price;
|
||||
}
|
||||
|
||||
if ($participantTransportationPositiveCost > 0) {
|
||||
$transportationPositiveTotal += $participantTransportationPositiveCost;
|
||||
++$transportationParticipants;
|
||||
}
|
||||
if ($participantTransportationDiscountCost < 0) {
|
||||
$transportationDiscountTotal += $participantTransportationDiscountCost;
|
||||
++$discountParticipants;
|
||||
}
|
||||
if ($participantParkingCost > 0) {
|
||||
$parkingTotal += $participantParkingCost;
|
||||
++$parkingParticipants;
|
||||
}
|
||||
}
|
||||
|
||||
$transportationItems = [];
|
||||
|
||||
if ($transportationPositiveTotal > 0) {
|
||||
$transportationItems['transportation_positive'] = [
|
||||
'serviceId' => 'transportation_positive',
|
||||
'label' => 'Beförderung',
|
||||
'unitPrice' => null,
|
||||
'participantCount' => $transportationParticipants,
|
||||
'totalPrice' => $transportationPositiveTotal,
|
||||
'subType' => Constants::GROUP_TRANSPORTATION,
|
||||
];
|
||||
}
|
||||
|
||||
if ($transportationDiscountTotal < 0) {
|
||||
$transportationItems['transportation_discount'] = [
|
||||
'serviceId' => 'transportation_discount',
|
||||
'label' => 'Beförderung - Rabatt',
|
||||
'unitPrice' => null,
|
||||
'participantCount' => $discountParticipants,
|
||||
'totalPrice' => $transportationDiscountTotal,
|
||||
'subType' => Constants::GROUP_TRANSPORTATION.'_discount',
|
||||
];
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $serviceAggregation
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function groupServicesBySubtype(array $serviceAggregation): array
|
||||
{
|
||||
$groupedServices = [];
|
||||
$servicesBySubtypeAndSign = [];
|
||||
|
||||
foreach ($serviceAggregation as $serviceData) {
|
||||
$subType = $serviceData['subType'] ?? 'other';
|
||||
if (null === $subType || '' === $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;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
$insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto);
|
||||
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) {
|
||||
if (true === is_array($serviceArray)) {
|
||||
foreach ($serviceArray as $service) {
|
||||
if ($service instanceof Service && null !== $service->price) {
|
||||
$this->addToServiceAggregation($serviceAggregation, $service);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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<string, mixed> $serviceData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance
|
||||
{
|
||||
if (null !== $participant->insurance) {
|
||||
return $participant->insurance;
|
||||
}
|
||||
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (0 === $participant->index) {
|
||||
return $participant->insurance;
|
||||
}
|
||||
|
||||
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
||||
$sameTypeInsurances = $this->insuranceService->filterByType(
|
||||
$selectableInsurances,
|
||||
$applicant->insurance
|
||||
);
|
||||
$travelPrice = $this->participantPricingCalculator->calculateIndividualParticipantPriceExcludingInsurance(
|
||||
$bookingDto,
|
||||
$participant->index
|
||||
);
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances(
|
||||
$sameTypeInsurances,
|
||||
$participant,
|
||||
$bookingDto,
|
||||
$travelPrice
|
||||
);
|
||||
|
||||
return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class BookingSummaryAssembler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BookingPriceCalculator $priceCalculator,
|
||||
private readonly BookingPricingAssembler $pricingAssembler,
|
||||
private readonly CmsDataProvider $cmsDataService,
|
||||
private readonly HotelLoader $hotelLoader,
|
||||
private readonly CountryDataProvider $countryDataProvider,
|
||||
@@ -58,7 +59,7 @@ class BookingSummaryAssembler
|
||||
}
|
||||
|
||||
// Get detailed pricing breakdown
|
||||
$pricingData = $this->priceCalculator->getPricingBreakdown($bookingDto, $roomPricingMode);
|
||||
$pricingData = $this->pricingAssembler->getPricingBreakdown($bookingDto, $roomPricingMode);
|
||||
|
||||
// Fetch CMS data (images, etc.)
|
||||
$productCode = $bookingDto->travel->productCode;
|
||||
|
||||
Reference in New Issue
Block a user