426 lines
15 KiB
PHP
426 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use App\BusProNet\Constants;
|
|
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
|
|
{
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
$totalPrice = $roomSelection->quantity * $room->price;
|
|
|
|
$roomPricing[] = [
|
|
'roomId' => $room->id,
|
|
'label' => $room->label,
|
|
'quantity' => $roomSelection->quantity,
|
|
'unitPrice' => $room->price,
|
|
'totalPrice' => $totalPrice,
|
|
];
|
|
}
|
|
|
|
return $roomPricing;
|
|
}
|
|
|
|
/**
|
|
* Calculates pricing for all selected services across all participants, grouped by subtype.
|
|
*
|
|
* @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 participants
|
|
$serviceAggregation = [];
|
|
|
|
foreach ($participants as $participant) {
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* 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 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 = 'rentals'; // Normalize all rental 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
|
|
{
|
|
$groupMapping = [
|
|
Constants::TOKEN_COURSES => 'Kurse',
|
|
Constants::TOKEN_SKI_PASS => 'Skipässe',
|
|
Constants::TOKEN_ADDITIONAL => 'Zusatzleistungen',
|
|
Constants::TOKEN_BOARD => 'Verpflegung',
|
|
Constants::TOKEN_RENTAL_INSURANCE => 'Leihmaterial-Versicherung',
|
|
'transportation' => 'Beförderung',
|
|
'rentals' => 'Leihmaterial', // Normalized rental subtype
|
|
];
|
|
|
|
// Handle rentals array (keep for backward compatibility with non-normalized subtypes)
|
|
if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) {
|
|
return 'Leihmaterial';
|
|
}
|
|
|
|
return $groupMapping[$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);
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|