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\Insurance;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\BusProNet\Utility\DirectionMapper;
|
use App\BusProNet\Utility\DirectionMapper;
|
||||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
|
||||||
use App\Form\Model\BankAccountDto;
|
use App\Form\Model\BankAccountDto;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Form\Model\RoomSelectionDto;
|
use App\Form\Model\RoomSelectionDto;
|
||||||
|
use App\Service\ApplicantInsuranceCascade;
|
||||||
use App\Service\BookingPriceCalculator;
|
use App\Service\BookingPriceCalculator;
|
||||||
use App\Service\InsuranceManager;
|
use App\Service\InsuranceManager;
|
||||||
|
|
||||||
use function Symfony\Component\String\u;
|
use function Symfony\Component\String\u;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +35,7 @@ class BookingDataProcessor
|
|||||||
private readonly ParticipantServiceProcessor $serviceProcessor,
|
private readonly ParticipantServiceProcessor $serviceProcessor,
|
||||||
private readonly BookingPayloadBuilder $payloadBuilder,
|
private readonly BookingPayloadBuilder $payloadBuilder,
|
||||||
private readonly PersonalDataSynchronizer $personalDataSynchronizer,
|
private readonly PersonalDataSynchronizer $personalDataSynchronizer,
|
||||||
|
private readonly ApplicantInsuranceCascade $applicantInsuranceCascade,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +305,7 @@ class BookingDataProcessor
|
|||||||
public function createUpdateRequestPayload(?BookingDto $formData): array
|
public function createUpdateRequestPayload(?BookingDto $formData): array
|
||||||
{
|
{
|
||||||
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||||
$this->applyApplicantInsuranceToParticipants($formData);
|
$this->applicantInsuranceCascade->apply($formData);
|
||||||
|
|
||||||
$bookingData = $formData->booking;
|
$bookingData = $formData->booking;
|
||||||
$travelData = $formData->travel;
|
$travelData = $formData->travel;
|
||||||
@@ -372,7 +374,7 @@ class BookingDataProcessor
|
|||||||
$this->reconcileInsuranceEligibility($bookingDto);
|
$this->reconcileInsuranceEligibility($bookingDto);
|
||||||
|
|
||||||
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||||
$this->applyApplicantInsuranceToParticipants($bookingDto);
|
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||||
|
|
||||||
return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType);
|
return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType);
|
||||||
}
|
}
|
||||||
@@ -453,81 +455,4 @@ class BookingDataProcessor
|
|||||||
|
|
||||||
return false;
|
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\BookingParticipantType;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Htmx\HxTrait;
|
use App\Htmx\HxTrait;
|
||||||
|
use App\Service\ApplicantInsuranceCascade;
|
||||||
use App\Service\BookingConfigurator;
|
use App\Service\BookingConfigurator;
|
||||||
use App\Service\BookingCreateContextFactory;
|
use App\Service\BookingCreateContextFactory;
|
||||||
use App\Service\BookingSessionManager;
|
use App\Service\BookingSessionManager;
|
||||||
@@ -34,6 +35,7 @@ class Step2ParticipantController extends AbstractController
|
|||||||
private readonly TravelDataProvider $travelDataService,
|
private readonly TravelDataProvider $travelDataService,
|
||||||
private readonly ParticipantDataPrefiller $prepopulationService,
|
private readonly ParticipantDataPrefiller $prepopulationService,
|
||||||
private readonly ParticipantFormSupport $participantFormSupportService,
|
private readonly ParticipantFormSupport $participantFormSupportService,
|
||||||
|
private readonly ApplicantInsuranceCascade $applicantInsuranceCascade,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ class Step2ParticipantController extends AbstractController
|
|||||||
$this->prepopulationService->fillDummyParticipant($bookingDto->participants[$index], $index);
|
$this->prepopulationService->fillDummyParticipant($bookingDto->participants[$index], $index);
|
||||||
|
|
||||||
$this->bookingService->preselectDefaultServices($bookingDto);
|
$this->bookingService->preselectDefaultServices($bookingDto);
|
||||||
|
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||||
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
||||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||||
|
|
||||||
@@ -80,6 +83,7 @@ class Step2ParticipantController extends AbstractController
|
|||||||
|
|
||||||
if (true === $isSubmitted) {
|
if (true === $isSubmitted) {
|
||||||
$this->bookingService->preselectDefaultServices($bookingDto);
|
$this->bookingService->preselectDefaultServices($bookingDto);
|
||||||
|
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||||
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +199,7 @@ class Step2ParticipantController extends AbstractController
|
|||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
$this->bookingService->preselectDefaultServices($bookingDto);
|
$this->bookingService->preselectDefaultServices($bookingDto);
|
||||||
|
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||||
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ use App\Entity\User;
|
|||||||
use App\Form\BookingParticipantType;
|
use App\Form\BookingParticipantType;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Htmx\HxTrait;
|
use App\Htmx\HxTrait;
|
||||||
|
use App\Service\ApplicantInsuranceCascade;
|
||||||
|
use App\Service\BookingConfigurator;
|
||||||
use App\Service\BookingEditContextFactory;
|
use App\Service\BookingEditContextFactory;
|
||||||
use App\Service\BookingEditDataLoader;
|
use App\Service\BookingEditDataLoader;
|
||||||
use App\Service\BookingEditDraftManager;
|
use App\Service\BookingEditDraftManager;
|
||||||
use App\Service\BookingConfigurator;
|
|
||||||
use App\Service\ParticipantDataPrefiller;
|
|
||||||
use App\Service\BookingSessionManager;
|
use App\Service\BookingSessionManager;
|
||||||
|
use App\Service\ParticipantDataPrefiller;
|
||||||
use App\Service\ParticipantFormSupport;
|
use App\Service\ParticipantFormSupport;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -40,6 +41,7 @@ class ParticipantController extends AbstractController
|
|||||||
private readonly BookingSessionManager $bookingSessionService,
|
private readonly BookingSessionManager $bookingSessionService,
|
||||||
private readonly ParticipantDataPrefiller $prepopulationService,
|
private readonly ParticipantDataPrefiller $prepopulationService,
|
||||||
private readonly ParticipantFormSupport $participantFormSupportService,
|
private readonly ParticipantFormSupport $participantFormSupportService,
|
||||||
|
private readonly ApplicantInsuranceCascade $applicantInsuranceCascade,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -85,6 +87,7 @@ class ParticipantController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->editContextFactory->prepareBookingDto($bookingDto);
|
$this->editContextFactory->prepareBookingDto($bookingDto);
|
||||||
|
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||||
|
|
||||||
$form = $this->createParticipantForm($bookingDto, $index);
|
$form = $this->createParticipantForm($bookingDto, $index);
|
||||||
|
|
||||||
@@ -156,6 +159,7 @@ class ParticipantController extends AbstractController
|
|||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
$this->bookingConfigurator->preselectDefaultServices($bookingDto);
|
$this->bookingConfigurator->preselectDefaultServices($bookingDto);
|
||||||
|
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||||
|
|
||||||
$form = $this->createForm(
|
$form = $this->createForm(
|
||||||
@@ -221,7 +225,6 @@ class ParticipantController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Booking|null $bookingData
|
|
||||||
* @param FormInterface<mixed> $form
|
* @param FormInterface<mixed> $form
|
||||||
*/
|
*/
|
||||||
private function renderParticipantForm(
|
private function renderParticipantForm(
|
||||||
@@ -230,8 +233,7 @@ class ParticipantController extends AbstractController
|
|||||||
BookingDto $bookingDto,
|
BookingDto $bookingDto,
|
||||||
int $bookingId,
|
int $bookingId,
|
||||||
?Booking $bookingData,
|
?Booking $bookingData,
|
||||||
): Response
|
): Response {
|
||||||
{
|
|
||||||
$context = $this->editContextFactory->createParticipantContext($bookingDto, $bookingData);
|
$context = $this->editContextFactory->createParticipantContext($bookingDto, $bookingData);
|
||||||
|
|
||||||
return $this->render('booking/edit/participant.html.twig', [
|
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(
|
public function __construct(
|
||||||
private readonly RoomPricingCalculator $roomPricingCalculator,
|
private readonly RoomPricingCalculator $roomPricingCalculator,
|
||||||
private readonly ParticipantEligibilityChecker $participantEligibilityChecker,
|
private readonly ParticipantEligibilityChecker $participantEligibilityChecker,
|
||||||
private readonly InsuranceManager $insuranceService,
|
|
||||||
private readonly ParticipantPricingCalculator $participantPricingCalculator,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +325,9 @@ class BookingPricingAssembler
|
|||||||
$this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance);
|
$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()) {
|
if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price && false === $insuranceToAggregate->isNoInsurance()) {
|
||||||
$this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate);
|
$this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate);
|
||||||
}
|
}
|
||||||
@@ -404,38 +404,4 @@ class BookingPricingAssembler
|
|||||||
'subType' => $serviceData['subType'] ?? null,
|
'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;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\BusProNet\Model\Insurance;
|
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ class ParticipantPricingCalculator
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add service prices for this participant (with booking context for bulk insurance)
|
// 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;
|
return $totalPrice;
|
||||||
}
|
}
|
||||||
@@ -138,62 +137,24 @@ class ParticipantPricingCalculator
|
|||||||
|
|
||||||
// Add service prices for this participant (excluding insurance)
|
// Add service prices for this participant (excluding insurance)
|
||||||
// For insurance eligibility calculation, only include services marked with versicherungsberechnung='J'
|
// 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;
|
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.
|
* Calculates the total service cost for a single participant.
|
||||||
*
|
*
|
||||||
* @param ParticipantDto $participant The participant to calculate services for
|
* @param ParticipantDto $participant The participant to calculate services for
|
||||||
* @param bool $includeInsurance Whether to include insurance pricing (default: true)
|
* @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 bool $onlyInsuranceCalculationServices Only include services with includeInInsuranceCalculation=true (default: false)
|
|
||||||
*
|
*
|
||||||
* @return float The total service cost for this participant
|
* @return float The total service cost for this participant
|
||||||
*/
|
*/
|
||||||
public function calculateParticipantServiceTotal(
|
public function calculateParticipantServiceTotal(
|
||||||
ParticipantDto $participant,
|
ParticipantDto $participant,
|
||||||
bool $includeInsurance = true,
|
bool $includeInsurance = true,
|
||||||
?BookingDto $bookingDto = null,
|
|
||||||
bool $onlyInsuranceCalculationServices = false,
|
bool $onlyInsuranceCalculationServices = false,
|
||||||
): float {
|
): float {
|
||||||
$serviceTotal = 0.0;
|
$serviceTotal = 0.0;
|
||||||
@@ -212,7 +173,9 @@ class ParticipantPricingCalculator
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get effective insurance (considering bulk insurance for dependent participants)
|
// 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) {
|
if ($includeInsurance && null !== $effectiveInsurance && null !== $effectiveInsurance->price) {
|
||||||
$serviceTotal += $effectiveInsurance->price;
|
$serviceTotal += $effectiveInsurance->price;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use App\BusProNet\Model\Service;
|
|||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Service\ApplicantInsuranceCascade;
|
||||||
use App\Service\BookingPriceCalculator;
|
use App\Service\BookingPriceCalculator;
|
||||||
use App\Service\InsuranceManager;
|
use App\Service\InsuranceManager;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -63,6 +64,7 @@ class BookingDataProcessorTest extends TestCase
|
|||||||
$serviceProcessor,
|
$serviceProcessor,
|
||||||
$payloadBuilder,
|
$payloadBuilder,
|
||||||
$personalDataSynchronizer,
|
$personalDataSynchronizer,
|
||||||
|
new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,6 +878,7 @@ class BookingDataProcessorTest extends TestCase
|
|||||||
new ParticipantServiceProcessor(new NullLogger()),
|
new ParticipantServiceProcessor(new NullLogger()),
|
||||||
$payloadBuilder,
|
$payloadBuilder,
|
||||||
new PersonalDataSynchronizer(),
|
new PersonalDataSynchronizer(),
|
||||||
|
new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService),
|
||||||
);
|
);
|
||||||
|
|
||||||
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
||||||
@@ -925,6 +928,7 @@ class BookingDataProcessorTest extends TestCase
|
|||||||
new ParticipantServiceProcessor(new NullLogger()),
|
new ParticipantServiceProcessor(new NullLogger()),
|
||||||
new BookingPayloadBuilder(new ServiceMappingCollector()),
|
new BookingPayloadBuilder(new ServiceMappingCollector()),
|
||||||
new PersonalDataSynchronizer(),
|
new PersonalDataSynchronizer(),
|
||||||
|
new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService),
|
||||||
);
|
);
|
||||||
|
|
||||||
$processor->createUpdateRequestPayload($formData);
|
$processor->createUpdateRequestPayload($formData);
|
||||||
@@ -978,6 +982,7 @@ class BookingDataProcessorTest extends TestCase
|
|||||||
new ParticipantServiceProcessor(new NullLogger()),
|
new ParticipantServiceProcessor(new NullLogger()),
|
||||||
$payloadBuilder,
|
$payloadBuilder,
|
||||||
new PersonalDataSynchronizer(),
|
new PersonalDataSynchronizer(),
|
||||||
|
new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService),
|
||||||
);
|
);
|
||||||
|
|
||||||
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
||||||
@@ -1024,6 +1029,7 @@ class BookingDataProcessorTest extends TestCase
|
|||||||
new ParticipantServiceProcessor(new NullLogger()),
|
new ParticipantServiceProcessor(new NullLogger()),
|
||||||
$payloadBuilder,
|
$payloadBuilder,
|
||||||
new PersonalDataSynchronizer(),
|
new PersonalDataSynchronizer(),
|
||||||
|
new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService),
|
||||||
);
|
);
|
||||||
|
|
||||||
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
||||||
@@ -1069,6 +1075,7 @@ class BookingDataProcessorTest extends TestCase
|
|||||||
new ParticipantServiceProcessor(new NullLogger()),
|
new ParticipantServiceProcessor(new NullLogger()),
|
||||||
$payloadBuilder,
|
$payloadBuilder,
|
||||||
new PersonalDataSynchronizer(),
|
new PersonalDataSynchronizer(),
|
||||||
|
new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService),
|
||||||
);
|
);
|
||||||
|
|
||||||
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Controller\Booking\Edit;
|
namespace App\Tests\Controller\Booking\Edit;
|
||||||
|
|
||||||
|
use App\BusProNet\ApiClient;
|
||||||
use App\BusProNet\Model\Booking;
|
use App\BusProNet\Model\Booking;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\BusProNet\ApiClient;
|
|
||||||
use App\Controller\Booking\Edit\ParticipantController;
|
use App\Controller\Booking\Edit\ParticipantController;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Form\BookingParticipantType;
|
use App\Form\BookingParticipantType;
|
||||||
@@ -15,6 +15,8 @@ use App\Form\Model\BookingEditContext;
|
|||||||
use App\Form\Model\BookingSummaryDto;
|
use App\Form\Model\BookingSummaryDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Form\Model\ParticipantEditDto;
|
use App\Form\Model\ParticipantEditDto;
|
||||||
|
use App\Security\Crypt;
|
||||||
|
use App\Service\ApplicantInsuranceCascade;
|
||||||
use App\Service\BookingConfigurator;
|
use App\Service\BookingConfigurator;
|
||||||
use App\Service\BookingEditContextFactory;
|
use App\Service\BookingEditContextFactory;
|
||||||
use App\Service\BookingEditDataLoader;
|
use App\Service\BookingEditDataLoader;
|
||||||
@@ -22,7 +24,6 @@ use App\Service\BookingEditDraftManager;
|
|||||||
use App\Service\BookingSessionManager;
|
use App\Service\BookingSessionManager;
|
||||||
use App\Service\ParticipantDataPrefiller;
|
use App\Service\ParticipantDataPrefiller;
|
||||||
use App\Service\ParticipantFormSupport;
|
use App\Service\ParticipantFormSupport;
|
||||||
use App\Security\Crypt;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -133,6 +134,7 @@ class ParticipantControllerTest extends TestCase
|
|||||||
$bookingSessionService,
|
$bookingSessionService,
|
||||||
$prepopulationService,
|
$prepopulationService,
|
||||||
$participantFormSupportService,
|
$participantFormSupportService,
|
||||||
|
$this->createMock(ApplicantInsuranceCascade::class),
|
||||||
$user,
|
$user,
|
||||||
$form,
|
$form,
|
||||||
);
|
);
|
||||||
@@ -241,6 +243,7 @@ class ParticipantControllerTest extends TestCase
|
|||||||
$bookingSessionService,
|
$bookingSessionService,
|
||||||
$prepopulationService,
|
$prepopulationService,
|
||||||
$participantFormSupportService,
|
$participantFormSupportService,
|
||||||
|
$this->createMock(ApplicantInsuranceCascade::class),
|
||||||
$user,
|
$user,
|
||||||
$form,
|
$form,
|
||||||
);
|
);
|
||||||
@@ -317,6 +320,7 @@ final class TestableParticipantController extends ParticipantController
|
|||||||
BookingSessionManager $bookingSessionService,
|
BookingSessionManager $bookingSessionService,
|
||||||
ParticipantDataPrefiller $prepopulationService,
|
ParticipantDataPrefiller $prepopulationService,
|
||||||
ParticipantFormSupport $participantFormSupportService,
|
ParticipantFormSupport $participantFormSupportService,
|
||||||
|
ApplicantInsuranceCascade $applicantInsuranceCascade,
|
||||||
private readonly User $user,
|
private readonly User $user,
|
||||||
FormInterface $form,
|
FormInterface $form,
|
||||||
) {
|
) {
|
||||||
@@ -330,6 +334,7 @@ final class TestableParticipantController extends ParticipantController
|
|||||||
$bookingSessionService,
|
$bookingSessionService,
|
||||||
$prepopulationService,
|
$prepopulationService,
|
||||||
$participantFormSupportService,
|
$participantFormSupportService,
|
||||||
|
$applicantInsuranceCascade,
|
||||||
new NullLogger(),
|
new NullLogger(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Booking;
|
||||||
|
use App\BusProNet\Model\Insurance;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Service\ApplicantInsuranceCascade;
|
||||||
|
use App\Service\BookingPriceCalculator;
|
||||||
|
use App\Service\InsuranceManager;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ApplicantInsuranceCascadeTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Regression for the production report: an adult and two children each picked their own
|
||||||
|
* insurance, then the applicant switched to the family insurance that had become available.
|
||||||
|
* The dependents' own selections used to survive in the session DTO until submit, which is
|
||||||
|
* what left the sidebar summary listing two superseded policies.
|
||||||
|
*/
|
||||||
|
public function testFamilyInsuranceClearsDependentsOwnSelections(): void
|
||||||
|
{
|
||||||
|
$familyInsurance = $this->createInsurance('family-1', true);
|
||||||
|
$childInsuranceA = $this->createInsurance('child-a', false);
|
||||||
|
$childInsuranceB = $this->createInsurance('child-b', false);
|
||||||
|
|
||||||
|
$bookingDto = $this->createBooking([
|
||||||
|
$this->createParticipant(0, $familyInsurance),
|
||||||
|
$this->createParticipant(1, $childInsuranceA),
|
||||||
|
$this->createParticipant(2, $childInsuranceB),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cascade = $this->createCascade([
|
||||||
|
0 => $familyInsurance,
|
||||||
|
1 => null,
|
||||||
|
2 => null,
|
||||||
|
], [$familyInsurance, $childInsuranceA, $childInsuranceB]);
|
||||||
|
|
||||||
|
$cascade->apply($bookingDto);
|
||||||
|
|
||||||
|
$this->assertSame($familyInsurance, $bookingDto->participants[0]->insurance);
|
||||||
|
$this->assertNull($bookingDto->participants[1]->insurance, 'First child must lose their own policy');
|
||||||
|
$this->assertNull($bookingDto->participants[2]->insurance, 'Second child must lose their own policy');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cascade runs on every request, so applying it repeatedly must converge rather than
|
||||||
|
* drift - otherwise a simple page refresh could change the booking.
|
||||||
|
*/
|
||||||
|
public function testApplyingTwiceIsIdempotent(): void
|
||||||
|
{
|
||||||
|
$familyInsurance = $this->createInsurance('family-1', true);
|
||||||
|
$childInsurance = $this->createInsurance('child-a', false);
|
||||||
|
|
||||||
|
$bookingDto = $this->createBooking([
|
||||||
|
$this->createParticipant(0, $familyInsurance),
|
||||||
|
$this->createParticipant(1, $childInsurance),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cascade = $this->createCascade([
|
||||||
|
0 => $familyInsurance,
|
||||||
|
1 => null,
|
||||||
|
], [$familyInsurance, $childInsurance]);
|
||||||
|
|
||||||
|
$cascade->apply($bookingDto);
|
||||||
|
$cascade->apply($bookingDto);
|
||||||
|
|
||||||
|
$this->assertSame($familyInsurance, $bookingDto->participants[0]->insurance);
|
||||||
|
$this->assertNull($bookingDto->participants[1]->insurance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Family insurance propagates on its own merit - the bulk checkbox is irrelevant to it.
|
||||||
|
*/
|
||||||
|
public function testFamilyInsurancePropagatesWithoutBulkCheckbox(): void
|
||||||
|
{
|
||||||
|
$familyInsurance = $this->createInsurance('family-1', true);
|
||||||
|
$childInsurance = $this->createInsurance('child-a', false);
|
||||||
|
|
||||||
|
$applicant = $this->createParticipant(0, $familyInsurance);
|
||||||
|
$applicant->bulkInsuranceBooking = false;
|
||||||
|
|
||||||
|
$bookingDto = $this->createBooking([$applicant, $this->createParticipant(1, $childInsurance)]);
|
||||||
|
|
||||||
|
$cascade = $this->createCascade([0 => $familyInsurance, 1 => null], [$familyInsurance, $childInsurance]);
|
||||||
|
$cascade->apply($bookingDto);
|
||||||
|
|
||||||
|
$this->assertNull($bookingDto->participants[1]->insurance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk booking assigns the applicant's insurance type to dependents, re-tiered per
|
||||||
|
* participant by InsuranceManager.
|
||||||
|
*/
|
||||||
|
public function testBulkInsuranceAssignsTierToDependents(): void
|
||||||
|
{
|
||||||
|
$applicantInsurance = $this->createInsurance('tier-high', false);
|
||||||
|
$dependentTier = $this->createInsurance('tier-low', false);
|
||||||
|
|
||||||
|
$applicant = $this->createParticipant(0, $applicantInsurance);
|
||||||
|
$applicant->bulkInsuranceBooking = true;
|
||||||
|
|
||||||
|
$dependent = $this->createParticipant(1, null);
|
||||||
|
|
||||||
|
$bookingDto = $this->createBooking([$applicant, $dependent]);
|
||||||
|
|
||||||
|
$cascade = $this->createCascade([
|
||||||
|
0 => $applicantInsurance,
|
||||||
|
1 => $dependentTier,
|
||||||
|
], [$applicantInsurance, $dependentTier]);
|
||||||
|
|
||||||
|
$cascade->apply($bookingDto);
|
||||||
|
|
||||||
|
$this->assertSame($applicantInsurance, $bookingDto->participants[0]->insurance, 'Applicant keeps their own choice');
|
||||||
|
$this->assertSame($dependentTier, $bookingDto->participants[1]->insurance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neither bulk nor family: every participant keeps whatever they chose individually.
|
||||||
|
*/
|
||||||
|
public function testNoPropagationWithoutBulkOrFamily(): void
|
||||||
|
{
|
||||||
|
$applicantInsurance = $this->createInsurance('own-a', false);
|
||||||
|
$dependentInsurance = $this->createInsurance('own-b', false);
|
||||||
|
|
||||||
|
$bookingDto = $this->createBooking([
|
||||||
|
$this->createParticipant(0, $applicantInsurance),
|
||||||
|
$this->createParticipant(1, $dependentInsurance),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$insuranceService = $this->createMock(InsuranceManager::class);
|
||||||
|
$insuranceService->expects($this->never())->method('batchAssignInsuranceToParticipants');
|
||||||
|
|
||||||
|
$cascade = new ApplicantInsuranceCascade(
|
||||||
|
$insuranceService,
|
||||||
|
$this->createMock(BookingPriceCalculator::class)
|
||||||
|
);
|
||||||
|
|
||||||
|
$cascade->apply($bookingDto);
|
||||||
|
|
||||||
|
$this->assertSame($applicantInsurance, $bookingDto->participants[0]->insurance);
|
||||||
|
$this->assertSame($dependentInsurance, $bookingDto->participants[1]->insurance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In edit mode a dependent's individually selected, locked-in insurance must survive a
|
||||||
|
* non-family bulk assignment.
|
||||||
|
*/
|
||||||
|
public function testEditModeKeepsIndividuallySelectedInsuranceForNonFamilyBulk(): void
|
||||||
|
{
|
||||||
|
$applicantInsurance = $this->createInsurance('tier-high', false);
|
||||||
|
$dependentOwn = $this->createInsurance('dependent-own', false);
|
||||||
|
$wouldBeAssigned = $this->createInsurance('tier-low', false);
|
||||||
|
|
||||||
|
$applicant = $this->createParticipant(0, $applicantInsurance);
|
||||||
|
$applicant->bulkInsuranceBooking = true;
|
||||||
|
|
||||||
|
$bookingDto = $this->createBooking([$applicant, $this->createParticipant(1, $dependentOwn)]);
|
||||||
|
$bookingDto->booking = new Booking(); // BookingDto derives MODE_EDIT from a present booking
|
||||||
|
|
||||||
|
$cascade = $this->createCascade([
|
||||||
|
0 => $applicantInsurance,
|
||||||
|
1 => $wouldBeAssigned,
|
||||||
|
], [$applicantInsurance, $dependentOwn, $wouldBeAssigned]);
|
||||||
|
|
||||||
|
$cascade->apply($bookingDto);
|
||||||
|
|
||||||
|
$this->assertSame($dependentOwn, $bookingDto->participants[1]->insurance, 'Locked individual selection must survive');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Family insurance is exempt from the edit-mode protection: a dependent can never
|
||||||
|
* legitimately hold their own policy alongside it.
|
||||||
|
*/
|
||||||
|
public function testEditModeFamilyInsuranceStillClearsDependents(): void
|
||||||
|
{
|
||||||
|
$familyInsurance = $this->createInsurance('family-1', true);
|
||||||
|
$dependentOwn = $this->createInsurance('dependent-own', false);
|
||||||
|
|
||||||
|
$bookingDto = $this->createBooking([
|
||||||
|
$this->createParticipant(0, $familyInsurance),
|
||||||
|
$this->createParticipant(1, $dependentOwn),
|
||||||
|
]);
|
||||||
|
$bookingDto->booking = new Booking(); // BookingDto derives MODE_EDIT from a present booking
|
||||||
|
|
||||||
|
$cascade = $this->createCascade([0 => $familyInsurance, 1 => null], [$familyInsurance, $dependentOwn]);
|
||||||
|
$cascade->apply($bookingDto);
|
||||||
|
|
||||||
|
$this->assertNull($bookingDto->participants[1]->insurance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, Insurance|null> $assignments
|
||||||
|
* @param array<Insurance> $selectable
|
||||||
|
*/
|
||||||
|
private function createCascade(array $assignments, array $selectable): ApplicantInsuranceCascade
|
||||||
|
{
|
||||||
|
$insuranceService = $this->createMock(InsuranceManager::class);
|
||||||
|
$insuranceService->method('getSelectableInsurances')->willReturn($selectable);
|
||||||
|
$insuranceService->method('batchAssignInsuranceToParticipants')->willReturn($assignments);
|
||||||
|
|
||||||
|
$priceCalculator = $this->createMock(BookingPriceCalculator::class);
|
||||||
|
$priceCalculator->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0);
|
||||||
|
|
||||||
|
return new ApplicantInsuranceCascade($insuranceService, $priceCalculator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createInsurance(string $id, bool $familyInsurance): Insurance
|
||||||
|
{
|
||||||
|
$insurance = new Insurance();
|
||||||
|
$insurance->id = $id;
|
||||||
|
$insurance->label = 'Insurance '.$id;
|
||||||
|
$insurance->price = 50.0;
|
||||||
|
$insurance->subType = 'RRV';
|
||||||
|
$insurance->package = false;
|
||||||
|
$insurance->complementary = false;
|
||||||
|
$insurance->familyInsurance = $familyInsurance;
|
||||||
|
|
||||||
|
return $insurance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createParticipant(int $index, ?Insurance $insurance): ParticipantDto
|
||||||
|
{
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->index = $index;
|
||||||
|
$participant->insurance = $insurance;
|
||||||
|
|
||||||
|
return $participant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<ParticipantDto> $participants */
|
||||||
|
private function createBooking(array $participants): BookingDto
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->id = 1;
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('2026-02-01');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('2026-02-08');
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, count($participants));
|
||||||
|
$bookingDto->participants = $participants;
|
||||||
|
|
||||||
|
return $bookingDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,8 +36,6 @@ class BookingPriceCalculatorTest extends TestCase
|
|||||||
$this->pricingAssembler = new BookingPricingAssembler(
|
$this->pricingAssembler = new BookingPricingAssembler(
|
||||||
$this->roomPricingCalculator,
|
$this->roomPricingCalculator,
|
||||||
$eligibilityChecker,
|
$eligibilityChecker,
|
||||||
new InsuranceManager(),
|
|
||||||
$this->participantPricingCalculator,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->service = new BookingPriceCalculator(
|
$this->service = new BookingPriceCalculator(
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use App\BusProNet\Model\Insurance;
|
|||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Service\ApplicantInsuranceCascade;
|
||||||
|
use App\Service\BookingPriceCalculator;
|
||||||
use App\Service\BookingPricingAssembler;
|
use App\Service\BookingPricingAssembler;
|
||||||
use App\Service\InsuranceManager;
|
use App\Service\InsuranceManager;
|
||||||
use App\Service\ParticipantEligibilityChecker;
|
use App\Service\ParticipantEligibilityChecker;
|
||||||
@@ -83,6 +85,10 @@ class BookingPricingAssemblerTest extends TestCase
|
|||||||
$bookingDto = new BookingDto($travel, 3);
|
$bookingDto = new BookingDto($travel, 3);
|
||||||
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
||||||
|
|
||||||
|
// Bulk assignment is materialised into the DTO by the cascade; the assembler then
|
||||||
|
// simply reports what each participant holds.
|
||||||
|
$this->createCascade()->apply($bookingDto);
|
||||||
|
|
||||||
$result = $this->assembler->calculateServicePricing($bookingDto);
|
$result = $this->assembler->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
// 3× same insurance should aggregate into one line item
|
// 3× same insurance should aggregate into one line item
|
||||||
@@ -207,6 +213,8 @@ class BookingPricingAssemblerTest extends TestCase
|
|||||||
$bookingDto = new BookingDto($travel, 3);
|
$bookingDto = new BookingDto($travel, 3);
|
||||||
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
||||||
|
|
||||||
|
$this->createCascade()->apply($bookingDto);
|
||||||
|
|
||||||
$result = $this->assembler->calculateServicePricing($bookingDto);
|
$result = $this->assembler->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
@@ -216,8 +224,93 @@ class BookingPricingAssemblerTest extends TestCase
|
|||||||
$this->assertEquals(3, $totalParticipants, 'Should count all 3 participants across all tiers');
|
$this->assertEquals(3, $totalParticipants, 'Should count all 3 participants across all tiers');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression for the production report: an adult and two children each picked their own
|
||||||
|
* insurance, then the applicant switched to the family insurance that had become
|
||||||
|
* available. The sidebar kept listing the two children's superseded policies, and the
|
||||||
|
* grand total stayed inflated by their premiums.
|
||||||
|
*
|
||||||
|
* The sidebar reports whatever the DTO holds, so this asserts the end-to-end contract:
|
||||||
|
* once ApplicantInsuranceCascade has run, only the family policy remains to aggregate.
|
||||||
|
*/
|
||||||
|
public function testFamilyInsuranceLeavesOnlyOneInsuranceLineItem(): void
|
||||||
|
{
|
||||||
|
$travel = $this->createFamilyTravel();
|
||||||
|
|
||||||
|
$familyInsurance = $this->createInsurance(1, 'Familienversicherung', 120.0);
|
||||||
|
$familyInsurance->familyInsurance = true;
|
||||||
|
|
||||||
|
$applicant = $this->createParticipantWithAge(0, '1985-06-15');
|
||||||
|
$applicant->insurance = $familyInsurance;
|
||||||
|
$applicant->bulkInsuranceBooking = false;
|
||||||
|
|
||||||
|
$childOne = $this->createParticipantWithAge(1, '2016-01-01');
|
||||||
|
$childOne->insurance = $this->createInsurance(2, 'Insurance A', 50.0);
|
||||||
|
|
||||||
|
$childTwo = $this->createParticipantWithAge(2, '2018-01-01');
|
||||||
|
$childTwo->insurance = $this->createInsurance(3, 'Insurance B', 75.0);
|
||||||
|
|
||||||
|
$travel->insurances = [$familyInsurance];
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 3);
|
||||||
|
$bookingDto->participants = [$applicant, $childOne, $childTwo];
|
||||||
|
|
||||||
|
$this->createCascade()->apply($bookingDto);
|
||||||
|
|
||||||
|
$result = $this->assembler->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
|
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||||
|
$this->assertCount(
|
||||||
|
1,
|
||||||
|
$insuranceGroup['services'],
|
||||||
|
'Only the family policy may be listed - the children\'s superseded selections must be gone'
|
||||||
|
);
|
||||||
|
|
||||||
|
$lineItem = array_values($insuranceGroup['services'])[0];
|
||||||
|
$this->assertSame('Familienversicherung', $lineItem['label']);
|
||||||
|
$this->assertEquals(1, $lineItem['participantCount'], 'Family policy is held once, by the applicant');
|
||||||
|
$this->assertEqualsWithDelta(120.0, $insuranceGroup['groupTotal'], 0.001, 'Total must not include the children\'s former premiums');
|
||||||
|
}
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
||||||
|
private function createCascade(): ApplicantInsuranceCascade
|
||||||
|
{
|
||||||
|
$roomPricingCalculator = new RoomPricingCalculator();
|
||||||
|
$participantPricingCalculator = new ParticipantPricingCalculator($roomPricingCalculator);
|
||||||
|
$insuranceManager = new InsuranceManager();
|
||||||
|
|
||||||
|
return new ApplicantInsuranceCascade(
|
||||||
|
$insuranceManager,
|
||||||
|
new BookingPriceCalculator(
|
||||||
|
$roomPricingCalculator,
|
||||||
|
$this->createAssembler($this->eligibilityChecker),
|
||||||
|
$participantPricingCalculator
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createFamilyTravel(): Travel
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->id = 1;
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('2026-02-01');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('2026-02-08');
|
||||||
|
$travel->insurances = [];
|
||||||
|
|
||||||
|
return $travel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createParticipantWithAge(int $index, string $dateOfBirth): ParticipantDto
|
||||||
|
{
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->index = $index;
|
||||||
|
$participant->dateOfBirth = new \DateTimeImmutable($dateOfBirth);
|
||||||
|
|
||||||
|
return $participant;
|
||||||
|
}
|
||||||
|
|
||||||
private function createAssembler(ParticipantEligibilityChecker $eligibilityChecker): BookingPricingAssembler
|
private function createAssembler(ParticipantEligibilityChecker $eligibilityChecker): BookingPricingAssembler
|
||||||
{
|
{
|
||||||
$roomPricingCalculator = new RoomPricingCalculator();
|
$roomPricingCalculator = new RoomPricingCalculator();
|
||||||
@@ -225,8 +318,6 @@ class BookingPricingAssemblerTest extends TestCase
|
|||||||
return new BookingPricingAssembler(
|
return new BookingPricingAssembler(
|
||||||
$roomPricingCalculator,
|
$roomPricingCalculator,
|
||||||
$eligibilityChecker,
|
$eligibilityChecker,
|
||||||
new InsuranceManager(),
|
|
||||||
new ParticipantPricingCalculator($roomPricingCalculator),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user