feat: refactoring and cleanup
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\Form\Model\BookingDto;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
/**
|
||||
* Handles loading and initializing booking data for edit mode.
|
||||
*
|
||||
* Encapsulates the logic for loading booking data from session or API,
|
||||
* refreshing availability data, and detecting session staleness.
|
||||
*/
|
||||
class BookingEditDataLoaderService
|
||||
{
|
||||
private const STALENESS_THRESHOLD_SECONDS = 300; // 5 minutes
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly BookingFingerprintService $fingerprintService,
|
||||
private readonly TravelDataService $travelDataService,
|
||||
private readonly CacheInterface $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads booking data from session or initializes from API on first load.
|
||||
*
|
||||
* @return array{bookingDto: BookingDto|null, stalenessWarning: string|null}
|
||||
*/
|
||||
public function loadFormData(Request $request, int $bookingId, string $email, string $password): array
|
||||
{
|
||||
$formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT);
|
||||
|
||||
if (null === $formData) {
|
||||
$bookingDto = $this->initializeFromApi($request, $bookingId, $email, $password);
|
||||
|
||||
return [
|
||||
'bookingDto' => $bookingDto,
|
||||
'stalenessWarning' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->refreshFromSession($formData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes booking data from API on first load and stores in session.
|
||||
*/
|
||||
public function initializeFromApi(Request $request, int $bookingId, string $email, string $password): ?BookingDto
|
||||
{
|
||||
$bookingData = $this->fetchBookingData($email, $password, $bookingId);
|
||||
|
||||
if (null === $bookingData || $bookingData instanceof Notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$travelData = $this->travelDataService->getTravelData($bookingData->dateId);
|
||||
if (null === $travelData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId);
|
||||
$availabilities = $this->travelDataService->getAvailabilityData($bookingData->dateId);
|
||||
|
||||
if (null === $mutableData || null === $availabilities) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->travelDataService->patchAvailabilities($travelData, $availabilities);
|
||||
$this->travelDataService->patchMutability($travelData, $mutableData);
|
||||
|
||||
$formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData);
|
||||
|
||||
// Set original fingerprint for dirty state detection
|
||||
$formData->originalFingerprint = $this->fingerprintService->generateFingerprint($formData, true);
|
||||
|
||||
$this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
|
||||
|
||||
return $formData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes booking data loaded from session with latest availability.
|
||||
*
|
||||
* @return array{bookingDto: BookingDto, stalenessWarning: string|null}
|
||||
*/
|
||||
public function refreshFromSession(BookingDto $formData): array
|
||||
{
|
||||
// Refresh availability data
|
||||
$this->travelDataService->enrichWithFreshAvailabilities($formData->travel);
|
||||
|
||||
// Check for staleness
|
||||
$stalenessWarning = $this->getStalenessWarning($formData);
|
||||
|
||||
return [
|
||||
'bookingDto' => $formData,
|
||||
'stalenessWarning' => $stalenessWarning,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches booking data from API with caching.
|
||||
*/
|
||||
public function fetchBookingData(string $email, string $password, int $bookingId): Booking|Notification|null
|
||||
{
|
||||
$cacheKey = sprintf('bpn_booking_%d', $bookingId);
|
||||
try {
|
||||
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($email, $password, $bookingId) {
|
||||
$item->expiresAfter(300);
|
||||
|
||||
return $this->apiClient->getBooking($email, $password, $bookingId);
|
||||
});
|
||||
} catch (InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates the booking cache after successful update.
|
||||
*/
|
||||
public function invalidateBookingCache(int $bookingId): void
|
||||
{
|
||||
try {
|
||||
$cacheKey = sprintf('bpn_booking_%d', $bookingId);
|
||||
$this->cache->delete($cacheKey);
|
||||
} catch (InvalidArgumentException) {
|
||||
// Ignore cache deletion errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the staleness timer on the booking DTO.
|
||||
*/
|
||||
public function resetStalenessTimer(Request $request, BookingDto $bookingDto): void
|
||||
{
|
||||
$bookingDto->lastSessionUpdate = new \DateTimeImmutable();
|
||||
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a staleness warning message if session is older than threshold.
|
||||
*/
|
||||
private function getStalenessWarning(BookingDto $formData): ?string
|
||||
{
|
||||
if (null === $formData->lastSessionUpdate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ageInSeconds = (new \DateTimeImmutable())->getTimestamp() - $formData->lastSessionUpdate->getTimestamp();
|
||||
|
||||
if ($ageInSeconds <= self::STALENESS_THRESHOLD_SECONDS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$minutes = (int) ceil($ageInSeconds / 60);
|
||||
|
||||
return sprintf('Sie bearbeiten diese Buchung seit %d Minuten. Die Daten könnten veraltet sein.', $minutes);
|
||||
}
|
||||
}
|
||||
@@ -4,28 +4,21 @@ 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\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
|
||||
/**
|
||||
* Calculates pricing for booking components including rooms and services.
|
||||
* Facade for booking pricing calculations.
|
||||
*
|
||||
* 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.
|
||||
* Provides comprehensive pricing calculations for the booking system by
|
||||
* coordinating specialized calculators for rooms, services, and participants.
|
||||
* Returns structured pricing data for display in forms and summaries.
|
||||
*/
|
||||
class BookingPriceCalculatorService
|
||||
{
|
||||
/** @var array<string, float> Request-scoped cache for participant prices */
|
||||
private array $participantPriceCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ParticipantEligibilityService $participantEligibilityService,
|
||||
private readonly InsuranceService $insuranceService,
|
||||
private readonly RoomPricingCalculator $roomPricingCalculator,
|
||||
private readonly ServicePricingCalculator $servicePricingCalculator,
|
||||
private readonly ParticipantPricingCalculator $participantPricingCalculator,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -38,8 +31,8 @@ class BookingPriceCalculatorService
|
||||
*/
|
||||
public function getPricingBreakdown(BookingDto $bookingDto): array
|
||||
{
|
||||
$roomPricing = $this->calculateRoomPricing($bookingDto);
|
||||
$servicePricing = $this->calculateServicePricing($bookingDto);
|
||||
$roomPricing = $this->roomPricingCalculator->calculateRoomPricing($bookingDto);
|
||||
$servicePricing = $this->servicePricingCalculator->calculateServicePricing($bookingDto);
|
||||
$grandTotal = $this->calculateGrandTotal($bookingDto);
|
||||
|
||||
$result = [
|
||||
@@ -73,97 +66,7 @@ class BookingPriceCalculatorService
|
||||
*/
|
||||
public function calculateRoomPricing(BookingDto $bookingDto): array
|
||||
{
|
||||
$roomPricing = [];
|
||||
|
||||
// In edit mode, use room data from the booking entity
|
||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode() && null !== $bookingDto->booking) {
|
||||
return $this->calculateRoomPricingFromBooking($bookingDto);
|
||||
}
|
||||
|
||||
// In create mode, use room selections from the form
|
||||
$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 room pricing from booking entity data (edit mode).
|
||||
*
|
||||
* In edit mode, room prices come from the booking entity's individualPrice arrays.
|
||||
* Each participant has their room price stored in the room's individualPrice array.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data with booking entity
|
||||
*
|
||||
* @return array Array of room pricing data with labels, quantities, and totals
|
||||
*/
|
||||
private function calculateRoomPricingFromBooking(BookingDto $bookingDto): array
|
||||
{
|
||||
$roomPricing = [];
|
||||
$roomGroups = [];
|
||||
|
||||
// Group participants by room and sum their individual prices
|
||||
foreach ($bookingDto->booking->rooms as $room) {
|
||||
if (false === isset($roomGroups[$room->id])) {
|
||||
$roomGroups[$room->id] = [
|
||||
'room' => $room,
|
||||
'participantCount' => 0,
|
||||
'totalPrice' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
// Sum individual prices for all participants in this room
|
||||
foreach ($room->mapping as $participantIndex) {
|
||||
$individualPrice = $room->individualPrice[$participantIndex] ?? 0.0;
|
||||
$roomGroups[$room->id]['totalPrice'] += $individualPrice;
|
||||
++$roomGroups[$room->id]['participantCount'];
|
||||
}
|
||||
}
|
||||
|
||||
// Build pricing array
|
||||
foreach ($roomGroups as $roomId => $data) {
|
||||
$room = $data['room'];
|
||||
$participantCount = $data['participantCount'];
|
||||
$totalPrice = $data['totalPrice'];
|
||||
|
||||
// Calculate average unit price (price per person)
|
||||
$unitPrice = $participantCount > 0 ? $totalPrice / $participantCount : 0.0;
|
||||
|
||||
$roomPricing[] = [
|
||||
'roomId' => $room->id,
|
||||
'label' => $room->label,
|
||||
'quantity' => $room->totalCount,
|
||||
'participantCount' => $participantCount,
|
||||
'unitPrice' => $unitPrice,
|
||||
'totalPrice' => $totalPrice,
|
||||
];
|
||||
}
|
||||
|
||||
return $roomPricing;
|
||||
return $this->roomPricingCalculator->calculateRoomPricing($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,32 +80,7 @@ class BookingPriceCalculatorService
|
||||
*/
|
||||
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);
|
||||
return $this->servicePricingCalculator->calculateServicePricing($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,8 +92,8 @@ class BookingPriceCalculatorService
|
||||
*/
|
||||
public function calculateGrandTotal(BookingDto $bookingDto): float
|
||||
{
|
||||
$roomTotal = $this->calculateRoomTotal($bookingDto);
|
||||
$serviceTotal = $this->calculateServiceTotal($bookingDto);
|
||||
$roomTotal = $this->roomPricingCalculator->calculateRoomTotal($bookingDto);
|
||||
$serviceTotal = $this->servicePricingCalculator->calculateServiceTotal($bookingDto);
|
||||
|
||||
return $roomTotal + $serviceTotal;
|
||||
}
|
||||
@@ -225,9 +103,7 @@ class BookingPriceCalculatorService
|
||||
*/
|
||||
public function calculateRoomTotal(BookingDto $bookingDto): float
|
||||
{
|
||||
$roomPricing = $this->calculateRoomPricing($bookingDto);
|
||||
|
||||
return array_sum(array_column($roomPricing, 'totalPrice'));
|
||||
return $this->roomPricingCalculator->calculateRoomTotal($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,9 +111,7 @@ class BookingPriceCalculatorService
|
||||
*/
|
||||
public function calculateServiceTotal(BookingDto $bookingDto): float
|
||||
{
|
||||
$servicePricing = $this->calculateServicePricing($bookingDto);
|
||||
|
||||
return array_sum(array_column($servicePricing, 'groupTotal'));
|
||||
return $this->servicePricingCalculator->calculateServiceTotal($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,26 +176,7 @@ class BookingPriceCalculatorService
|
||||
*/
|
||||
public function calculateIndividualParticipantPrice(BookingDto $bookingDto, int $participantIndex): float
|
||||
{
|
||||
$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 (with booking context for bulk insurance)
|
||||
$totalPrice += $this->calculateParticipantServiceTotal($participant, true, $bookingDto);
|
||||
|
||||
return $totalPrice;
|
||||
return $this->participantPricingCalculator->calculateIndividualParticipantPrice($bookingDto, $participantIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -333,14 +188,7 @@ class BookingPriceCalculatorService
|
||||
*/
|
||||
public function calculateAllParticipantIndividualPrices(BookingDto $bookingDto): array
|
||||
{
|
||||
$participantPrices = [];
|
||||
$participants = $bookingDto->getParticipants();
|
||||
|
||||
foreach ($participants as $index => $participant) {
|
||||
$participantPrices[$index] = $this->calculateIndividualParticipantPrice($bookingDto, $index);
|
||||
}
|
||||
|
||||
return $participantPrices;
|
||||
return $this->participantPricingCalculator->calculateAllParticipantIndividualPrices($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,7 +198,7 @@ class BookingPriceCalculatorService
|
||||
* where insurance selection affects travel price which affects insurance eligibility.
|
||||
*
|
||||
* Only includes services where versicherungsberechnung='J' in the XML. Services with
|
||||
* versicherungsberechnung='N' (like CO² compensation) are excluded from the calculation
|
||||
* 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
|
||||
@@ -362,538 +210,9 @@ class BookingPriceCalculatorService
|
||||
BookingDto $bookingDto,
|
||||
int $participantIndex,
|
||||
): float {
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
if (null === $participant) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Generate cache key based on participant state that affects pricing
|
||||
$stateComponents = [
|
||||
'room' => $participant->assignedRoomId ?? 'none',
|
||||
'skiPass' => $participant->skiPass?->id ?? 'none',
|
||||
'rentalInsurance' => $participant->rentalInsurance?->id ?? 'none',
|
||||
'transportationOut' => $participant->transportationOutbound?->id ?? 'none',
|
||||
'transportationIn' => $participant->transportationInbound?->id ?? 'none',
|
||||
'pickup' => $participant->pickup?->id ?? 'none',
|
||||
'parking' => $participant->parkingService?->id ?? 'none',
|
||||
'courses' => implode('_', array_map(fn ($s) => $s->id, $participant->courses ?? [])),
|
||||
'additionalServices' => implode('_', array_map(fn ($s) => $s->id, $participant->additionalServices ?? [])),
|
||||
'board' => implode('_', array_map(fn ($s) => $s->id, $participant->board ?? [])),
|
||||
'rentals' => implode('_', array_map(fn ($s) => $s->id, $participant->rentals ?? [])),
|
||||
];
|
||||
|
||||
$cacheKey = sprintf(
|
||||
'participant_price_%d_%s',
|
||||
$participantIndex,
|
||||
md5(json_encode($stateComponents))
|
||||
);
|
||||
|
||||
return $this->participantPriceCache[$cacheKey] ??= (function () use ($bookingDto, $participant) {
|
||||
$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)
|
||||
// For insurance eligibility calculation, only include services marked with versicherungsberechnung='J'
|
||||
$totalPrice += $this->calculateParticipantServiceTotal($participant, false, null, true);
|
||||
|
||||
return $totalPrice;
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data containing participants
|
||||
*
|
||||
* @return array Array of transportation line items (Beförderung, 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 (unified for both directions)
|
||||
if (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) {
|
||||
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,
|
||||
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
|
||||
$insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto);
|
||||
|
||||
if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price) {
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance resolution
|
||||
* @param bool $onlyInsuranceCalculationServices Only include services with includeInInsuranceCalculation=true (default: false)
|
||||
*
|
||||
* @return float The total service cost for this participant
|
||||
*/
|
||||
private function calculateParticipantServiceTotal(
|
||||
ParticipantDto $participant,
|
||||
bool $includeInsurance = true,
|
||||
?BookingDto $bookingDto = null,
|
||||
bool $onlyInsuranceCalculationServices = false,
|
||||
): float {
|
||||
$serviceTotal = 0.0;
|
||||
|
||||
// Single service selections
|
||||
if (null !== $participant->skiPass && null !== $participant->skiPass->price) {
|
||||
if (false === $onlyInsuranceCalculationServices || true === $participant->skiPass->includeInInsuranceCalculation) {
|
||||
$serviceTotal += $participant->skiPass->price;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) {
|
||||
if (false === $onlyInsuranceCalculationServices || true === $participant->rentalInsurance->includeInInsuranceCalculation) {
|
||||
$serviceTotal += $participant->rentalInsurance->price;
|
||||
}
|
||||
}
|
||||
|
||||
// Get effective insurance (considering bulk insurance for dependent participants)
|
||||
$effectiveInsurance = $this->getEffectiveInsurance($participant, $bookingDto);
|
||||
if ($includeInsurance && null !== $effectiveInsurance && null !== $effectiveInsurance->price) {
|
||||
$serviceTotal += $effectiveInsurance->price;
|
||||
}
|
||||
|
||||
// Transportation services
|
||||
if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) {
|
||||
if (false === $onlyInsuranceCalculationServices || true === $participant->transportationOutbound->includeInInsuranceCalculation) {
|
||||
$serviceTotal += $participant->transportationOutbound->price;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) {
|
||||
if (false === $onlyInsuranceCalculationServices || true === $participant->transportationInbound->includeInInsuranceCalculation) {
|
||||
$serviceTotal += $participant->transportationInbound->price;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $participant->pickup && null !== $participant->pickup->price) {
|
||||
$serviceTotal += $participant->pickup->price;
|
||||
}
|
||||
|
||||
if (null !== $participant->parkingService && null !== $participant->parkingService->price) {
|
||||
if (false === $onlyInsuranceCalculationServices || true === $participant->parkingService->includeInInsuranceCalculation) {
|
||||
$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) {
|
||||
if (false === $onlyInsuranceCalculationServices || true === $service->includeInInsuranceCalculation) {
|
||||
$serviceTotal += $service->price;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $serviceTotal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a room by ID from the booking's travel data.
|
||||
*/
|
||||
private function getRoomById(BookingDto $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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the effective insurance for a participant, considering bulk insurance assignment.
|
||||
*
|
||||
* When bulk insurance is active and the participant is a dependent (index > 0),
|
||||
* returns the applicant's insurance. Otherwise returns the participant's own insurance.
|
||||
*
|
||||
* This method is used for pricing calculations to show correct prices when bulk
|
||||
* insurance is enabled, even though the actual assignment happens in the processor.
|
||||
*
|
||||
* @param ParticipantDto $participant The participant to get insurance for
|
||||
* @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance check
|
||||
*
|
||||
* @return Insurance|null The effective insurance for pricing purposes
|
||||
*/
|
||||
private function getEffectiveInsurance(ParticipantDto $participant, ?BookingDto $bookingDto): ?Insurance
|
||||
{
|
||||
// If no booking context, use participant's own insurance
|
||||
if (null === $bookingDto) {
|
||||
return $participant->insurance;
|
||||
}
|
||||
|
||||
// Applicant always uses their own insurance
|
||||
if (0 === $participant->index) {
|
||||
return $participant->insurance;
|
||||
}
|
||||
|
||||
// Check if bulk insurance is active
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
if (null === $applicant || false === $applicant->bulkInsuranceBooking) {
|
||||
return $participant->insurance;
|
||||
}
|
||||
|
||||
// Bulk insurance is active - use applicant's insurance for dependent participants
|
||||
return $applicant->insurance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participant->index);
|
||||
|
||||
// Get eligible insurances for THIS participant (price tier adjusted)
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances(
|
||||
$sameTypeInsurances,
|
||||
$participant,
|
||||
return $this->participantPricingCalculator->calculateIndividualParticipantPriceExcludingInsurance(
|
||||
$bookingDto,
|
||||
$travelPrice
|
||||
$participantIndex
|
||||
);
|
||||
|
||||
// Return first eligible insurance (sorted by price)
|
||||
return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\BusProNet\Model\Travel;
|
||||
use App\Exception\BookingSessionNotFoundException;
|
||||
use App\Exception\NoRoomsAvailableException;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -37,7 +38,7 @@ class BookingService
|
||||
$baselineKey = self::BOOKING_CREATE_BASELINE_KEY;
|
||||
|
||||
if (!$request->getSession()->has($baselineKey) || $request->query->has('reset_baseline')) {
|
||||
$baseline = $this->createRoomSelectionSnapshot($bookingCreateDto);
|
||||
$baseline = $bookingCreateDto->createRoomSelectionSnapshot();
|
||||
$request->getSession()->set($baselineKey, $baseline);
|
||||
|
||||
return $baseline;
|
||||
@@ -143,31 +144,6 @@ class BookingService
|
||||
return BookingDto::MODE_EDIT === $mode ? self::BOOKING_EDIT_KEY : self::BOOKING_CREATE_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the booking creation DTO to the session.
|
||||
*
|
||||
* Persists the current booking state to the session for retrieval
|
||||
* across multiple HTTP requests during the booking flow.
|
||||
*
|
||||
* @param Request $request The HTTP request with session
|
||||
* @param BookingDto $bookingCreateDto The booking DTO to persist
|
||||
*/
|
||||
public function saveBookingCreateDto(Request $request, BookingDto $bookingCreateDto): void
|
||||
{
|
||||
$this->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the booking creation DTO from the session.
|
||||
*
|
||||
* This method removes only the booking DTO while preserving other session data.
|
||||
* Used after successful booking submission to clear the booking flow state.
|
||||
*/
|
||||
public function clearBookingCreateDto(Request $request): void
|
||||
{
|
||||
$request->getSession()->remove(self::BOOKING_CREATE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all booking-related session data.
|
||||
*
|
||||
@@ -229,7 +205,7 @@ class BookingService
|
||||
$bookingCreateDto->agencyId = $agencyId;
|
||||
$bookingCreateDto->bookingStatus = $bookingStatus;
|
||||
|
||||
$this->saveBookingCreateDto($request, $bookingCreateDto);
|
||||
$this->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
|
||||
return $bookingCreateDto;
|
||||
}
|
||||
@@ -286,17 +262,12 @@ class BookingService
|
||||
* Calculates the number of participants assigned to each room ID.
|
||||
*
|
||||
* @return array<int, int> an array where the key is the room ID and the value is the count of assigned participants
|
||||
*
|
||||
* @deprecated Use BookingDto::getRoomAssignmentCounts() instead
|
||||
*/
|
||||
public function getRoomAssignmentCounts(BookingDto $bookingDto): array
|
||||
{
|
||||
$counts = [];
|
||||
foreach ($bookingDto->getParticipants() as $participant) {
|
||||
if (null !== $participant->assignedRoomId) {
|
||||
$counts[$participant->assignedRoomId] = ($counts[$participant->assignedRoomId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
return $bookingDto->getRoomAssignmentCounts();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,48 +351,40 @@ class BookingService
|
||||
/**
|
||||
* Resets all participant room assignments in the DTO.
|
||||
*
|
||||
* Clears room assignments when room selections change to prevent
|
||||
* invalid assignments. Called when users modify their room selections
|
||||
* in step 1 to ensure participants are reassigned appropriately.
|
||||
*
|
||||
* @param BookingDto $dto The booking DTO to reset assignments for
|
||||
*
|
||||
* @deprecated Use BookingDto::resetParticipantAssignments() instead
|
||||
*/
|
||||
public function resetParticipantAssignments(BookingDto $dto): void
|
||||
{
|
||||
foreach ($dto->participants as $participant) {
|
||||
$participant->assignedRoomId = null;
|
||||
}
|
||||
$dto->resetParticipantAssignments();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a snapshot of the current room selection state.
|
||||
*
|
||||
* @return array<int, array{int, int}> Array of [roomId, quantity] pairs
|
||||
*
|
||||
* @deprecated Use BookingDto::createRoomSelectionSnapshot() instead
|
||||
*/
|
||||
public function createRoomSelectionSnapshot(BookingDto $dto): array
|
||||
{
|
||||
return array_map(
|
||||
fn ($roomSelection) => [(int) $roomSelection->roomId, (int) $roomSelection->quantity],
|
||||
$dto->roomSelections
|
||||
);
|
||||
return $dto->createRoomSelectionSnapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if room selection has changed compared to a previous snapshot.
|
||||
*
|
||||
* Compares the current room selection state with a baseline snapshot
|
||||
* to detect changes that would require participant reassignment.
|
||||
*
|
||||
* @param array $oldSnapshot The baseline room selection snapshot
|
||||
* @param BookingDto $newDto The current booking DTO
|
||||
*
|
||||
* @return bool True if room selections have changed, false otherwise
|
||||
*
|
||||
* @deprecated Use BookingDto::hasRoomSelectionChanged() instead
|
||||
*/
|
||||
public function hasRoomSelectionChanged(array $oldSnapshot, BookingDto $newDto): bool
|
||||
{
|
||||
$newSnapshot = $this->createRoomSelectionSnapshot($newDto);
|
||||
|
||||
return $oldSnapshot !== $newSnapshot;
|
||||
return $newDto->hasRoomSelectionChanged($oldSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,4 +508,48 @@ class BookingService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the booking DTO has the correct number of participant objects.
|
||||
*
|
||||
* Creates or removes ParticipantDto objects based on room selections.
|
||||
* Preserves existing participant data when adjusting the count.
|
||||
* Optionally prepopulates the applicant (index 0) from an authenticated user.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking DTO to update
|
||||
* @param \Symfony\Component\Security\Core\User\UserInterface|null $user Optional authenticated user for prepopulation
|
||||
* @param callable|null $prepopulateCallback Callback to prepopulate applicant: fn(UserInterface, ParticipantDto): ParticipantDto
|
||||
*/
|
||||
public function ensureCorrectNumberOfParticipants(
|
||||
BookingDto $bookingDto,
|
||||
?\Symfony\Component\Security\Core\User\UserInterface $user = null,
|
||||
?callable $prepopulateCallback = null,
|
||||
): void {
|
||||
$participantsCount = $this->getParticipantsCount($bookingDto->roomSelections, $bookingDto->travel);
|
||||
|
||||
$existingParticipants = $bookingDto->participants;
|
||||
$bookingDto->participants = [];
|
||||
|
||||
for ($i = 0; $i < $participantsCount; ++$i) {
|
||||
$participant = $existingParticipants[$i] ?? new ParticipantDto();
|
||||
$participant->index = $i;
|
||||
|
||||
// Prepopulate applicant from authenticated user (index 0 only)
|
||||
if (0 === $i && null !== $user && null !== $prepopulateCallback && $this->shouldPrepopulate($participant)) {
|
||||
$participant = $prepopulateCallback($user, $participant);
|
||||
}
|
||||
|
||||
$bookingDto->participants[$i] = $participant;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a participant should be prepopulated.
|
||||
*
|
||||
* Only prepopulates if the participant is "fresh" (no name set yet).
|
||||
*/
|
||||
private function shouldPrepopulate(ParticipantDto $participant): bool
|
||||
{
|
||||
return null === $participant->firstName || '' === $participant->firstName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\BookingSummaryDto;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
@@ -27,18 +28,8 @@ class BookingSummaryDataService
|
||||
|
||||
/**
|
||||
* Get complete summary data for booking sidebar.
|
||||
*
|
||||
* @return array{
|
||||
* selectedRooms: array,
|
||||
* participantCount: int,
|
||||
* totalPrice: string,
|
||||
* groupedSelectedRooms: array,
|
||||
* assignmentCounts: array,
|
||||
* pricingData: array,
|
||||
* cmsData: array|null
|
||||
* }
|
||||
*/
|
||||
public function getSummaryData(BookingDto $bookingDto): array
|
||||
public function getSummaryData(BookingDto $bookingDto): BookingSummaryDto
|
||||
{
|
||||
// Get selected rooms (for Step1 controller compatibility)
|
||||
$selectedRooms = $bookingDto->getSelectedRooms();
|
||||
@@ -57,7 +48,7 @@ class BookingSummaryDataService
|
||||
}
|
||||
}
|
||||
|
||||
// Group selected rooms with counts
|
||||
// Group selected rooms with counts (for display)
|
||||
$groupedSelectedRooms = [];
|
||||
foreach ($roomCounts as $roomId => $count) {
|
||||
$room = $bookingDto->travel->getRoomById($roomId);
|
||||
@@ -78,15 +69,15 @@ class BookingSummaryDataService
|
||||
// Calculate participant count from room capacity (source of truth)
|
||||
$participantCount = $this->calculateParticipantCountFromRooms($bookingDto);
|
||||
|
||||
return [
|
||||
'selectedRooms' => $selectedRooms,
|
||||
'participantCount' => $participantCount,
|
||||
'totalPrice' => number_format($totalPrice, 2, ',', '.').' €',
|
||||
'groupedSelectedRooms' => $groupedSelectedRooms,
|
||||
'assignmentCounts' => $roomCounts,
|
||||
'pricingData' => $pricingData,
|
||||
'cmsData' => $cmsData,
|
||||
];
|
||||
return new BookingSummaryDto(
|
||||
selectedRooms: $selectedRooms,
|
||||
participantCount: $participantCount,
|
||||
totalPrice: number_format($totalPrice, 2, ',', '.').' €',
|
||||
groupedSelectedRooms: $groupedSelectedRooms,
|
||||
assignmentCounts: $roomCounts,
|
||||
pricingData: $pricingData,
|
||||
cmsData: $cmsData,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
|
||||
/**
|
||||
* Calculates pricing for individual participants in a booking.
|
||||
*
|
||||
* Handles room allocation costs, service selections, and provides
|
||||
* both inclusive and exclusive insurance calculations for eligibility.
|
||||
*/
|
||||
class ParticipantPricingCalculator
|
||||
{
|
||||
/** @var array<string, float> Request-scoped cache for participant prices */
|
||||
private array $participantPriceCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly RoomPricingCalculator $roomPricingCalculator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$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->roomPricingCalculator->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 (with booking context for bulk insurance)
|
||||
$totalPrice += $this->calculateParticipantServiceTotal($participant, true, $bookingDto);
|
||||
|
||||
return $totalPrice;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public function calculateAllParticipantIndividualPrices(BookingDto $bookingDto): array
|
||||
{
|
||||
$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.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
public function calculateIndividualParticipantPriceExcludingInsurance(
|
||||
BookingDto $bookingDto,
|
||||
int $participantIndex,
|
||||
): float {
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
if (null === $participant) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Generate cache key based on participant state that affects pricing
|
||||
$stateComponents = [
|
||||
'room' => $participant->assignedRoomId ?? 'none',
|
||||
'skiPass' => $participant->skiPass?->id ?? 'none',
|
||||
'rentalInsurance' => $participant->rentalInsurance?->id ?? 'none',
|
||||
'transportationOut' => $participant->transportationOutbound?->id ?? 'none',
|
||||
'transportationIn' => $participant->transportationInbound?->id ?? 'none',
|
||||
'pickup' => $participant->pickup?->id ?? 'none',
|
||||
'parking' => $participant->parkingService?->id ?? 'none',
|
||||
'courses' => implode('_', array_map(fn ($s) => $s->id, $participant->courses ?? [])),
|
||||
'additionalServices' => implode('_', array_map(fn ($s) => $s->id, $participant->additionalServices ?? [])),
|
||||
'board' => implode('_', array_map(fn ($s) => $s->id, $participant->board ?? [])),
|
||||
'rentals' => implode('_', array_map(fn ($s) => $s->id, $participant->rentals ?? [])),
|
||||
];
|
||||
|
||||
$cacheKey = sprintf(
|
||||
'participant_price_%d_%s',
|
||||
$participantIndex,
|
||||
md5(json_encode($stateComponents))
|
||||
);
|
||||
|
||||
return $this->participantPriceCache[$cacheKey] ??= (function () use ($bookingDto, $participant) {
|
||||
$totalPrice = 0.0;
|
||||
|
||||
// Add room price if participant is assigned to a room
|
||||
if (null !== $participant->assignedRoomId) {
|
||||
$room = $this->roomPricingCalculator->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)
|
||||
// For insurance eligibility calculation, only include services marked with versicherungsberechnung='J'
|
||||
$totalPrice += $this->calculateParticipantServiceTotal($participant, false, null, true);
|
||||
|
||||
return $totalPrice;
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the effective insurance for a participant, considering bulk insurance assignment.
|
||||
*
|
||||
* When bulk insurance is active and the participant is a dependent (index > 0),
|
||||
* returns the applicant's insurance. Otherwise returns the participant's own insurance.
|
||||
*
|
||||
* This method is used for pricing calculations to show correct prices when bulk
|
||||
* insurance is enabled, even though the actual assignment happens in the processor.
|
||||
*
|
||||
* @param ParticipantDto $participant The participant to get insurance for
|
||||
* @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance check
|
||||
*
|
||||
* @return Insurance|null The effective insurance for pricing purposes
|
||||
*/
|
||||
public function getEffectiveInsurance(ParticipantDto $participant, ?BookingDto $bookingDto): ?Insurance
|
||||
{
|
||||
// If no booking context, use participant's own insurance
|
||||
if (null === $bookingDto) {
|
||||
return $participant->insurance;
|
||||
}
|
||||
|
||||
// Applicant always uses their own insurance
|
||||
if (0 === $participant->index) {
|
||||
return $participant->insurance;
|
||||
}
|
||||
|
||||
// Check if bulk insurance is active
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
if (null === $applicant || false === $applicant->bulkInsuranceBooking) {
|
||||
return $participant->insurance;
|
||||
}
|
||||
|
||||
// Bulk insurance is active - use applicant's insurance for dependent participants
|
||||
return $applicant->insurance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance resolution
|
||||
* @param bool $onlyInsuranceCalculationServices Only include services with includeInInsuranceCalculation=true (default: false)
|
||||
*
|
||||
* @return float The total service cost for this participant
|
||||
*/
|
||||
public function calculateParticipantServiceTotal(
|
||||
ParticipantDto $participant,
|
||||
bool $includeInsurance = true,
|
||||
?BookingDto $bookingDto = null,
|
||||
bool $onlyInsuranceCalculationServices = false,
|
||||
): float {
|
||||
$serviceTotal = 0.0;
|
||||
|
||||
// Single service selections
|
||||
if (null !== $participant->skiPass && null !== $participant->skiPass->price) {
|
||||
if (false === $onlyInsuranceCalculationServices || true === $participant->skiPass->includeInInsuranceCalculation) {
|
||||
$serviceTotal += $participant->skiPass->price;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) {
|
||||
if (false === $onlyInsuranceCalculationServices || true === $participant->rentalInsurance->includeInInsuranceCalculation) {
|
||||
$serviceTotal += $participant->rentalInsurance->price;
|
||||
}
|
||||
}
|
||||
|
||||
// Get effective insurance (considering bulk insurance for dependent participants)
|
||||
$effectiveInsurance = $this->getEffectiveInsurance($participant, $bookingDto);
|
||||
if ($includeInsurance && null !== $effectiveInsurance && null !== $effectiveInsurance->price) {
|
||||
$serviceTotal += $effectiveInsurance->price;
|
||||
}
|
||||
|
||||
// Transportation services
|
||||
if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) {
|
||||
if (false === $onlyInsuranceCalculationServices || true === $participant->transportationOutbound->includeInInsuranceCalculation) {
|
||||
$serviceTotal += $participant->transportationOutbound->price;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $participant->transportationInbound && null !== $participant->transportationInbound->price) {
|
||||
if (false === $onlyInsuranceCalculationServices || true === $participant->transportationInbound->includeInInsuranceCalculation) {
|
||||
$serviceTotal += $participant->transportationInbound->price;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $participant->pickup && null !== $participant->pickup->price) {
|
||||
$serviceTotal += $participant->pickup->price;
|
||||
}
|
||||
|
||||
if (null !== $participant->parkingService && null !== $participant->parkingService->price) {
|
||||
if (false === $onlyInsuranceCalculationServices || true === $participant->parkingService->includeInInsuranceCalculation) {
|
||||
$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) {
|
||||
if (false === $onlyInsuranceCalculationServices || true === $service->includeInInsuranceCalculation) {
|
||||
$serviceTotal += $service->price;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $serviceTotal;
|
||||
}
|
||||
}
|
||||
@@ -86,4 +86,28 @@ class RoomAssignmentService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns rooms to participants if any participants need room assignment.
|
||||
*
|
||||
* Checks if any participants have null room assignments, and if so,
|
||||
* triggers the auto-assignment process. This is a convenience method
|
||||
* that combines the check and assignment into a single call.
|
||||
*
|
||||
* @param BookingDto $dto The booking DTO to update
|
||||
*
|
||||
* @return bool True if assignment was performed, false if not needed
|
||||
*/
|
||||
public function assignRoomsIfNeeded(BookingDto $dto): bool
|
||||
{
|
||||
foreach ($dto->participants as $participant) {
|
||||
if (null === $participant->assignedRoomId) {
|
||||
$this->assignParticipantsToRooms($dto);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\Form\Model\BookingDto;
|
||||
|
||||
/**
|
||||
* Calculates pricing for room allocations in bookings.
|
||||
*
|
||||
* Handles room pricing in both create mode (from room selections) and
|
||||
* edit mode (from booking entity data with individual participant prices).
|
||||
*/
|
||||
class RoomPricingCalculator
|
||||
{
|
||||
/**
|
||||
* 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): array
|
||||
{
|
||||
// In edit mode, use room data from the booking entity
|
||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode() && null !== $bookingDto->booking) {
|
||||
return $this->calculateRoomPricingFromBooking($bookingDto);
|
||||
}
|
||||
|
||||
// In create mode, use room selections from the form
|
||||
return $this->calculateRoomPricingFromSelections($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total price for all rooms.
|
||||
*/
|
||||
public function calculateRoomTotal(BookingDto $bookingDto): float
|
||||
{
|
||||
$roomPricing = $this->calculateRoomPricing($bookingDto);
|
||||
|
||||
return array_sum(array_column($roomPricing, 'totalPrice'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a room by ID from the booking's travel data.
|
||||
*/
|
||||
public function getRoomById(BookingDto $bookingDto, ?int $roomId): ?Room
|
||||
{
|
||||
if (null === $roomId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($bookingDto->travel->rooms as $room) {
|
||||
if ($room->id === $roomId) {
|
||||
return $room;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates room pricing from form selections (create mode).
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data containing room selections
|
||||
*
|
||||
* @return array Array of room pricing data
|
||||
*/
|
||||
private function calculateRoomPricingFromSelections(BookingDto $bookingDto): array
|
||||
{
|
||||
$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 room pricing from booking entity data (edit mode).
|
||||
*
|
||||
* In edit mode, room prices come from the booking entity's individualPrice arrays.
|
||||
* Each participant has their room price stored in the room's individualPrice array.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking data with booking entity
|
||||
*
|
||||
* @return array Array of room pricing data with labels, quantities, and totals
|
||||
*/
|
||||
private function calculateRoomPricingFromBooking(BookingDto $bookingDto): array
|
||||
{
|
||||
$roomPricing = [];
|
||||
$roomGroups = [];
|
||||
|
||||
// Group participants by room and sum their individual prices
|
||||
foreach ($bookingDto->booking->rooms as $room) {
|
||||
if (false === isset($roomGroups[$room->id])) {
|
||||
$roomGroups[$room->id] = [
|
||||
'room' => $room,
|
||||
'participantCount' => 0,
|
||||
'totalPrice' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
// Sum individual prices for all participants in this room
|
||||
foreach ($room->mapping as $participantIndex) {
|
||||
$individualPrice = $room->individualPrice[$participantIndex] ?? 0.0;
|
||||
$roomGroups[$room->id]['totalPrice'] += $individualPrice;
|
||||
++$roomGroups[$room->id]['participantCount'];
|
||||
}
|
||||
}
|
||||
|
||||
// Build pricing array
|
||||
foreach ($roomGroups as $roomId => $data) {
|
||||
$room = $data['room'];
|
||||
$participantCount = $data['participantCount'];
|
||||
$totalPrice = $data['totalPrice'];
|
||||
|
||||
// Calculate average unit price (price per person)
|
||||
$unitPrice = $participantCount > 0 ? $totalPrice / $participantCount : 0.0;
|
||||
|
||||
$roomPricing[] = [
|
||||
'roomId' => $room->id,
|
||||
'label' => $room->label,
|
||||
'quantity' => $room->totalCount,
|
||||
'participantCount' => $participantCount,
|
||||
'unitPrice' => $unitPrice,
|
||||
'totalPrice' => $totalPrice,
|
||||
];
|
||||
}
|
||||
|
||||
return $roomPricing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
<?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 (unified for both directions)
|
||||
if (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) {
|
||||
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,
|
||||
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
|
||||
$insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto);
|
||||
|
||||
if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price) {
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -606,6 +606,25 @@ class TravelDataService
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches travel data with fresh availability information from the API.
|
||||
*
|
||||
* Fetches cached availability data and patches it onto the travel object.
|
||||
* Used by controllers to ensure availability data is up-to-date before
|
||||
* rendering forms or processing submissions.
|
||||
*
|
||||
* @param Travel $travel The travel object to enrich
|
||||
* @param bool $cached Whether to use cached availability data (default: true)
|
||||
*/
|
||||
public function enrichWithFreshAvailabilities(Travel $travel, bool $cached = true): void
|
||||
{
|
||||
$availabilities = $this->getAvailabilityData($travel->id, $cached);
|
||||
|
||||
if (null !== $availabilities) {
|
||||
$this->patchAvailabilities($travel, $availabilities);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich travel data with additional information.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user