fix: resolve stale dependent selections after booking family insurance
This commit is contained in:
@@ -9,13 +9,14 @@ use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Utility\DirectionMapper;
|
||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
||||
use App\Form\Model\BankAccountDto;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use App\Service\ApplicantInsuranceCascade;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\InsuranceManager;
|
||||
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
/**
|
||||
@@ -34,6 +35,7 @@ class BookingDataProcessor
|
||||
private readonly ParticipantServiceProcessor $serviceProcessor,
|
||||
private readonly BookingPayloadBuilder $payloadBuilder,
|
||||
private readonly PersonalDataSynchronizer $personalDataSynchronizer,
|
||||
private readonly ApplicantInsuranceCascade $applicantInsuranceCascade,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -303,7 +305,7 @@ class BookingDataProcessor
|
||||
public function createUpdateRequestPayload(?BookingDto $formData): array
|
||||
{
|
||||
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||
$this->applyApplicantInsuranceToParticipants($formData);
|
||||
$this->applicantInsuranceCascade->apply($formData);
|
||||
|
||||
$bookingData = $formData->booking;
|
||||
$travelData = $formData->travel;
|
||||
@@ -372,7 +374,7 @@ class BookingDataProcessor
|
||||
$this->reconcileInsuranceEligibility($bookingDto);
|
||||
|
||||
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||
$this->applyApplicantInsuranceToParticipants($bookingDto);
|
||||
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||
|
||||
return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType);
|
||||
}
|
||||
@@ -453,81 +455,4 @@ class BookingDataProcessor
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking DTO (create or edit flow)
|
||||
*/
|
||||
private function applyApplicantInsuranceToParticipants(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Controller\Booking\Create;
|
||||
use App\Form\BookingParticipantType;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Service\ApplicantInsuranceCascade;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingSessionManager;
|
||||
@@ -34,6 +35,7 @@ class Step2ParticipantController extends AbstractController
|
||||
private readonly TravelDataProvider $travelDataService,
|
||||
private readonly ParticipantDataPrefiller $prepopulationService,
|
||||
private readonly ParticipantFormSupport $participantFormSupportService,
|
||||
private readonly ApplicantInsuranceCascade $applicantInsuranceCascade,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -70,6 +72,7 @@ class Step2ParticipantController extends AbstractController
|
||||
$this->prepopulationService->fillDummyParticipant($bookingDto->participants[$index], $index);
|
||||
|
||||
$this->bookingService->preselectDefaultServices($bookingDto);
|
||||
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
@@ -80,6 +83,7 @@ class Step2ParticipantController extends AbstractController
|
||||
|
||||
if (true === $isSubmitted) {
|
||||
$this->bookingService->preselectDefaultServices($bookingDto);
|
||||
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
||||
}
|
||||
|
||||
@@ -195,6 +199,7 @@ class Step2ParticipantController extends AbstractController
|
||||
$form->handleRequest($request);
|
||||
|
||||
$this->bookingService->preselectDefaultServices($bookingDto);
|
||||
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ use App\Entity\User;
|
||||
use App\Form\BookingParticipantType;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Service\ApplicantInsuranceCascade;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingEditContextFactory;
|
||||
use App\Service\BookingEditDataLoader;
|
||||
use App\Service\BookingEditDraftManager;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\ParticipantDataPrefiller;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\ParticipantDataPrefiller;
|
||||
use App\Service\ParticipantFormSupport;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -40,6 +41,7 @@ class ParticipantController extends AbstractController
|
||||
private readonly BookingSessionManager $bookingSessionService,
|
||||
private readonly ParticipantDataPrefiller $prepopulationService,
|
||||
private readonly ParticipantFormSupport $participantFormSupportService,
|
||||
private readonly ApplicantInsuranceCascade $applicantInsuranceCascade,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -85,6 +87,7 @@ class ParticipantController extends AbstractController
|
||||
}
|
||||
|
||||
$this->editContextFactory->prepareBookingDto($bookingDto);
|
||||
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||
|
||||
$form = $this->createParticipantForm($bookingDto, $index);
|
||||
|
||||
@@ -156,6 +159,7 @@ class ParticipantController extends AbstractController
|
||||
$form->handleRequest($request);
|
||||
|
||||
$this->bookingConfigurator->preselectDefaultServices($bookingDto);
|
||||
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||
|
||||
$form = $this->createForm(
|
||||
@@ -221,7 +225,6 @@ class ParticipantController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Booking|null $bookingData
|
||||
* @param FormInterface<mixed> $form
|
||||
*/
|
||||
private function renderParticipantForm(
|
||||
@@ -230,8 +233,7 @@ class ParticipantController extends AbstractController
|
||||
BookingDto $bookingDto,
|
||||
int $bookingId,
|
||||
?Booking $bookingData,
|
||||
): Response
|
||||
{
|
||||
): Response {
|
||||
$context = $this->editContextFactory->createParticipantContext($bookingDto, $bookingData);
|
||||
|
||||
return $this->render('booking/edit/participant.html.twig', [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user