fix: resolve stale dependent selections after booking family insurance

This commit is contained in:
Björn Fromme
2026-08-24 16:58:09 +02:00
parent 35826088cb
commit c9c28792e5
11 changed files with 490 additions and 173 deletions
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Form\Model\BookingDto;
/**
* Propagates the applicant's insurance selection to the other participants.
*
* This runs during the request cycle rather than only at payload build time, so the
* session DTO always reflects the coverage that will actually be booked. Every consumer
* that reads ParticipantDto::$insurance - the sidebar summary, the price totals, the
* voucher math - is then correct by construction, instead of having to re-derive the
* effective insurance for itself.
*/
class ApplicantInsuranceCascade
{
public function __construct(
private readonly InsuranceManager $insuranceService,
private readonly BookingPriceCalculator $priceCalculatorService,
) {
}
/**
* Applies the applicant's insurance to other participants if their selection requires it.
*
* Runs when either:
* - Bulk insurance is active (applicant's bulkInsuranceBooking = true): assigns the
* applicant's insurance TYPE to all dependent participants with automatic price tier
* adjustment based on each participant's individual cost.
* - The applicant selected a family insurance: family insurance covers the whole family
* under a single policy, so dependents must never hold their own insurance, regardless
* of whether the bulk checkbox was checked.
*
* IMPORTANT: In edit mode, non-family bulk insurance only applies to participants who:
* 1. Currently have NO insurance assigned (insurance === null)
* 2. OR whose insurance was already assigned via previous bulk operation
*
* This prevents overriding individually selected insurances that are locked. Family
* insurance is exempt from this protection: dependents can never legitimately hold
* their own policy alongside it, so their insurance is always cleared, in edit mode too.
*
* The assignment is deterministic, so applying it repeatedly across successive requests
* converges on the same result.
*
* @param BookingDto $bookingDto The booking DTO (create or edit flow)
*/
public function apply(BookingDto $bookingDto): void
{
$applicant = $bookingDto->getParticipant(0);
if (null === $applicant || null === $applicant->insurance) {
return;
}
$isFamilyInsurance = true === $applicant->insurance->familyInsurance;
// Check if bulk insurance is enabled OR the applicant's selection is a family
// insurance (which always applies to dependents, independent of the bulk checkbox)
if (false === $applicant->bulkInsuranceBooking && false === $isFamilyInsurance) {
return;
}
// Get selectable (non-complementary) insurances with caching
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
// Calculate travel prices for all participants (excluding insurance)
$participantPrices = [];
foreach ($bookingDto->getParticipants() as $index => $participant) {
$participantPrices[$index] = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index);
}
// Use InsuranceManager for proper type-based assignment with price tier matching
$assignments = $this->insuranceService->batchAssignInsuranceToParticipants(
$selectableInsurances,
$applicant->insurance,
$bookingDto,
$participantPrices
);
// Apply assignments to all participants; for non-family insurance skip the applicant
// (they already hold the insurance they selected themselves)
foreach ($assignments as $index => $insurance) {
if (0 === $index && false === $isFamilyInsurance) {
continue;
}
$participant = $bookingDto->getParticipant($index);
if (null === $participant) {
continue;
}
// In edit mode: non-family bulk assignment must not overwrite an individually
// locked insurance. Family insurance always overrides, since dependents can
// never legitimately keep their own policy alongside it.
if (BookingDto::MODE_EDIT === $bookingDto->getMode() && false === $isFamilyInsurance && null !== $participant->insurance) {
continue; // Skip participants with existing insurance assignment
}
$participant->insurance = $insurance;
}
}
}
+3 -37
View File
@@ -23,8 +23,6 @@ class BookingPricingAssembler
public function __construct(
private readonly RoomPricingCalculator $roomPricingCalculator,
private readonly ParticipantEligibilityChecker $participantEligibilityChecker,
private readonly InsuranceManager $insuranceService,
private readonly ParticipantPricingCalculator $participantPricingCalculator,
) {
}
@@ -327,7 +325,9 @@ class BookingPricingAssembler
$this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance);
}
$insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto);
// The session DTO is kept truthful by ApplicantInsuranceCascade, which materialises
// bulk and family assignments as they are made - so this is simply what the participant holds.
$insuranceToAggregate = $participant->insurance;
if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price && false === $insuranceToAggregate->isNoInsurance()) {
$this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate);
}
@@ -404,38 +404,4 @@ class BookingPricingAssembler
'subType' => $serviceData['subType'] ?? null,
];
}
private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance
{
if (null !== $participant->insurance) {
return $participant->insurance;
}
$applicant = $bookingDto->getParticipant(0);
if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) {
return null;
}
if (0 === $participant->index) {
return $participant->insurance;
}
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
$sameTypeInsurances = $this->insuranceService->filterByType(
$selectableInsurances,
$applicant->insurance
);
$travelPrice = $this->participantPricingCalculator->calculateIndividualParticipantPriceExcludingInsurance(
$bookingDto,
$participant->index
);
$eligibleInsurances = $this->insuranceService->getEligibleInsurances(
$sameTypeInsurances,
$participant,
$bookingDto,
$travelPrice
);
return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
}
}
+8 -45
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Insurance;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
@@ -54,7 +53,7 @@ class ParticipantPricingCalculator
}
// Add service prices for this participant (with booking context for bulk insurance)
$totalPrice += $this->calculateParticipantServiceTotal($participant, true, $bookingDto);
$totalPrice += $this->calculateParticipantServiceTotal($participant, true);
return $totalPrice;
}
@@ -138,62 +137,24 @@ class ParticipantPricingCalculator
// 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);
$totalPrice += $this->calculateParticipantServiceTotal($participant, false, 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)
* @param ParticipantDto $participant The participant to calculate services for
* @param bool $includeInsurance Whether to include insurance pricing (default: true)
* @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;
@@ -212,7 +173,9 @@ class ParticipantPricingCalculator
}
// Get effective insurance (considering bulk insurance for dependent participants)
$effectiveInsurance = $this->getEffectiveInsurance($participant, $bookingDto);
// ApplicantInsuranceCascade materialises bulk and family assignments into the DTO as
// they happen, so the participant's own field is already the effective insurance.
$effectiveInsurance = $participant->insurance;
if ($includeInsurance && null !== $effectiveInsurance && null !== $effectiveInsurance->price) {
$serviceTotal += $effectiveInsurance->price;
}