fix: reconcile assigned family insurance rate on updated participant
This commit is contained in:
@@ -6,6 +6,7 @@ namespace App\BusProNet\DataProcessor;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
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;
|
||||
@@ -362,12 +363,97 @@ class BookingDataProcessor
|
||||
*/
|
||||
public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array
|
||||
{
|
||||
// Re-validate each participant's insurance against current eligibility (price tier,
|
||||
// family constraint, etc.) before submission - selections can go stale between the
|
||||
// moment they were chosen and final submission, since users can freely revisit
|
||||
// earlier steps and change composition/services without ever re-opening the
|
||||
// insurance field. Must run before the applicant-to-dependents propagation below,
|
||||
// so that propagation is based on an already-corrected applicant selection.
|
||||
$this->reconcileInsuranceEligibility($bookingDto);
|
||||
|
||||
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||
$this->applyApplicantInsuranceToParticipants($bookingDto);
|
||||
|
||||
return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-validates every participant's currently selected insurance against present-day
|
||||
* eligibility (price tier, family constraint, age, travel/booking dates, duration) and
|
||||
* corrects any selection that's gone stale.
|
||||
*
|
||||
* Insurance eligibility is normally re-checked when a participant's own card is
|
||||
* resubmitted (see ParticipantInsuranceFieldHandler::processField()), but nothing forces
|
||||
* that to happen again if the user changes composition or services on an earlier step
|
||||
* after insurance was already chosen, then jumps straight to submission. Left unchecked,
|
||||
* a stale insurance ID (e.g. a family-tariff price tier that no longer matches the
|
||||
* current total booking price) would reach the BPN API and be rejected as a mismatch.
|
||||
*
|
||||
* Mirrors the reassign-or-clear logic already established in
|
||||
* ParticipantInsuranceFieldHandler for the resubmission case, applied here across all
|
||||
* participants right before the payload is built.
|
||||
*/
|
||||
private function reconcileInsuranceEligibility(BookingDto $bookingDto): bool
|
||||
{
|
||||
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
||||
$changed = false;
|
||||
|
||||
foreach ($bookingDto->getParticipants() as $index => $participant) {
|
||||
$currentInsurance = $participant->insurance;
|
||||
if (null === $currentInsurance || $currentInsurance->isNoInsurance()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $index, $currentInsurance);
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice);
|
||||
|
||||
if ($this->isInsuranceInList($currentInsurance, $eligibleInsurances)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reassignedInsurance = $this->insuranceService->reassignInsuranceForPriceChange(
|
||||
$selectableInsurances,
|
||||
$currentInsurance,
|
||||
$participant,
|
||||
$bookingDto,
|
||||
$travelPrice
|
||||
);
|
||||
|
||||
if (null !== $reassignedInsurance) {
|
||||
$participant->addNotification(
|
||||
'warning',
|
||||
sprintf('Versicherung vor der Übermittlung automatisch angepasst: %s', $reassignedInsurance->label)
|
||||
);
|
||||
} else {
|
||||
$participant->addNotification(
|
||||
'warning',
|
||||
'Die gewählte Versicherung ist für diese Buchung nicht mehr verfügbar und wurde entfernt. Bitte erneut auswählen.'
|
||||
);
|
||||
}
|
||||
|
||||
$participant->insurance = $reassignedInsurance;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific insurance exists in a list of insurances (by id).
|
||||
*
|
||||
* @param array<Insurance> $insuranceList
|
||||
*/
|
||||
private function isInsuranceInList(Insurance $targetInsurance, array $insuranceList): bool
|
||||
{
|
||||
foreach ($insuranceList as $insurance) {
|
||||
if ($insurance->id === $targetInsurance->id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the applicant's insurance to other participants if their selection requires it.
|
||||
*
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\BookingPriceMismatchAnalyzer;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\ParticipantFormSupport;
|
||||
use App\Service\RoomPricingCalculator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -40,6 +41,7 @@ class Step3Controller extends AbstractBookingCreateController
|
||||
private readonly BookingPriceCalculator $priceCalculator,
|
||||
private readonly BookingPriceMismatchAnalyzer $priceMismatchDiagnostics,
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly ParticipantFormSupport $participantFormSupportService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -76,6 +78,12 @@ class Step3Controller extends AbstractBookingCreateController
|
||||
// Validate booking data with API by submitting an inquiry booking
|
||||
$inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto);
|
||||
|
||||
// Surface any insurance corrections made during payload building (e.g. a
|
||||
// stale price tier reassigned/cleared right before submission)
|
||||
foreach ($this->participantFormSupportService->collectAndClearNotifications($bookingCreateDto) as $notification) {
|
||||
$this->addFlash($notification['type'], $notification['message']);
|
||||
}
|
||||
|
||||
if ($inquiryResponse instanceof Notification) {
|
||||
$this->handleApiError(
|
||||
$this->logger,
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\NewsletterManager;
|
||||
use App\Service\ParticipantFormSupport;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -41,6 +42,7 @@ class Step4Controller extends AbstractBookingCreateController
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly NewsletterManager $doubleOptInService,
|
||||
private readonly ParticipantFormSupport $participantFormSupportService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -78,6 +80,12 @@ class Step4Controller extends AbstractBookingCreateController
|
||||
// Submit final booking (already validated in Step 3)
|
||||
$bookingResponse = $this->apiClient->createBooking($bookingCreateDto);
|
||||
|
||||
// Surface any insurance corrections made during payload building (e.g. a
|
||||
// stale price tier reassigned/cleared right before submission)
|
||||
foreach ($this->participantFormSupportService->collectAndClearNotifications($bookingCreateDto) as $notification) {
|
||||
$this->addFlash($notification['type'], $notification['message']);
|
||||
}
|
||||
|
||||
if ($bookingResponse instanceof Notification) {
|
||||
$this->handleApiError(
|
||||
$this->logger,
|
||||
|
||||
@@ -70,6 +70,21 @@ class BookingDto
|
||||
*/
|
||||
public ?string $originalFingerprint = null;
|
||||
|
||||
/**
|
||||
* Whether the applicant has already been notified that family insurance became
|
||||
* available after they picked a non-family insurance while dependents were incomplete.
|
||||
* Prevents re-showing the same hint on every subsequent participant card submission.
|
||||
*/
|
||||
public bool $familyInsuranceHintShown = false;
|
||||
|
||||
/**
|
||||
* Whether the applicant's current insurance choice was made while family insurance
|
||||
* was not yet eligible (i.e. before all dependents' dates of birth were known).
|
||||
* Set at selection time; used to distinguish "family insurance just became
|
||||
* available" from "it was available all along and the applicant chose otherwise".
|
||||
*/
|
||||
public bool $applicantInsuranceChosenWhileFamilyIneligible = false;
|
||||
|
||||
public function __construct(public Travel $travel, public int $hotelId)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -67,9 +67,9 @@ class ParticipantEditDto
|
||||
* Validates that insurance is selected when required.
|
||||
*
|
||||
* Insurance is required for all participants in create mode, EXCEPT for dependent
|
||||
* participants when the applicant has enabled bulk insurance booking. In that case,
|
||||
* the insurance field is hidden and will be automatically assigned by the bulk
|
||||
* insurance handler.
|
||||
* participants when the applicant has enabled bulk insurance booking, or when the
|
||||
* applicant has a family insurance selected. In both cases, the insurance field is
|
||||
* hidden and coverage is derived from the applicant instead.
|
||||
*
|
||||
* In edit submissions, this validation is skipped entirely because insurance
|
||||
* data is readonly and preserved as-is from the BPN API.
|
||||
@@ -90,6 +90,10 @@ class ParticipantEditDto
|
||||
if (true === $applicant?->bulkInsuranceBooking) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $applicant?->insurance && true === $applicant->insurance->familyInsurance) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $this->participant->insurance) {
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Form\Service;
|
||||
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\InsuranceManager;
|
||||
@@ -124,6 +125,19 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
* @param int $participantIndex The index of the participant being processed
|
||||
*/
|
||||
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
|
||||
{
|
||||
$this->applyInsuranceSelection($submittedData, $bookingDto, $participantIndex);
|
||||
|
||||
// Runs after the selection above is fully resolved, so a submission that itself
|
||||
// switches the applicant to family insurance doesn't trigger a stale "please
|
||||
// recheck" notification about the choice it just made.
|
||||
$this->notifyApplicantIfFamilyInsuranceNewlyAvailable($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $submittedData The submitted participant form data
|
||||
*/
|
||||
private function applyInsuranceSelection(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
|
||||
{
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
if (null === $participant) {
|
||||
@@ -152,6 +166,8 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
$noInsurance->price = 0.0;
|
||||
$participant->insurance = $noInsurance;
|
||||
|
||||
$this->recordFamilyIneligibilityAtSelectionTime($bookingDto, $participantIndex, $noInsurance);
|
||||
|
||||
return; // Skip all other processing for "no insurance"
|
||||
}
|
||||
|
||||
@@ -188,10 +204,15 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
// Insurance not found - clear selection
|
||||
$participant->insurance = null;
|
||||
}
|
||||
|
||||
$this->recordFamilyIneligibilityAtSelectionTime($bookingDto, $participantIndex, $participant->insurance);
|
||||
}
|
||||
|
||||
// Handle form resubmission with existing insurance (automatic reassignment check)
|
||||
if (null !== $currentInsurance && null !== $selectedInsuranceId) {
|
||||
// Only applies when the selection didn't just change above - otherwise this would
|
||||
// re-validate the now-stale $currentInsurance and could overwrite the fresh selection
|
||||
// the block above already made in this same request.
|
||||
if (!$isNewSelection && null !== $currentInsurance && null !== $selectedInsuranceId) {
|
||||
// Preserve "no insurance" selection during resubmission
|
||||
if ($currentInsurance->isNoInsurance()) {
|
||||
return;
|
||||
@@ -260,6 +281,95 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Records whether the applicant's insurance choice was made while family insurance
|
||||
* was NOT yet eligible, so the notification below can tell "just became available"
|
||||
* apart from "was available all along and the applicant chose non-family anyway".
|
||||
*
|
||||
* Only called when the applicant (index 0) makes a genuine new selection - not on
|
||||
* mere resubmission/price-tier reassignment of an already-standing choice.
|
||||
*/
|
||||
private function recordFamilyIneligibilityAtSelectionTime(BookingDto $bookingDto, int $participantIndex, ?Insurance $insurance): void
|
||||
{
|
||||
if (0 !== $participantIndex || null === $insurance || true === $insurance->familyInsurance) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
if (null === $applicant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible = !$this->hasEligibleFamilyInsurance($bookingDto, $applicant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a family insurance is actually eligible right now - not just whether
|
||||
* the participant composition qualifies as a family booking, but whether a selectable
|
||||
* family insurance product actually matches the current total booking price.
|
||||
*
|
||||
* Used by both the selection-time recording above and the notification below, so they
|
||||
* can never disagree on what "family insurance is eligible" means.
|
||||
*/
|
||||
private function hasEligibleFamilyInsurance(BookingDto $bookingDto, ParticipantDto $applicant): bool
|
||||
{
|
||||
if (!$bookingDto->isFamilyBooking()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
||||
$familyInsurances = array_values(array_filter($selectableInsurances, static fn (Insurance $i) => true === $i->familyInsurance));
|
||||
|
||||
if ([] === $familyInsurances) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$totalPrice = $this->priceCalculatorService->calculateTotalBookingPriceExcludingInsurance($bookingDto);
|
||||
|
||||
return [] !== $this->insuranceService->getEligibleInsurances($familyInsurances, $applicant, $bookingDto, $totalPrice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the applicant when family insurance becomes available after the fact.
|
||||
*
|
||||
* Participants are entered one card at a time, in any order, so the applicant is
|
||||
* likely to pick a non-family insurance before any dependent's date of birth makes
|
||||
* the booking eligible for family insurance. Once that happens, nothing prompts the
|
||||
* applicant to revisit their earlier choice. This surfaces a one-time hint via the
|
||||
* existing notification/toast pipeline (App\Form\Model\ParticipantDto::addNotification()),
|
||||
* mirroring the cross-participant side effect already used by
|
||||
* ParticipantAssignedRoomFieldHandler::resolveRoomCapacityConflict() for room conflicts.
|
||||
*
|
||||
* Requires `applicantInsuranceChosenWhileFamilyIneligible` to be true - i.e. the
|
||||
* choice was actually made before family insurance was possible - not just that
|
||||
* family insurance happens to be eligible now (it may have been all along).
|
||||
*/
|
||||
private function notifyApplicantIfFamilyInsuranceNewlyAvailable(BookingDto $bookingDto): void
|
||||
{
|
||||
if ($bookingDto->familyInsuranceHintShown) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$bookingDto->applicantInsuranceChosenWhileFamilyIneligible) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
if (null === $applicant || null === $applicant->insurance || true === $applicant->insurance->familyInsurance) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->hasEligibleFamilyInsurance($bookingDto, $applicant)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applicant->addNotification(
|
||||
'info',
|
||||
'Für eure Konstellation ist auch eine Familienversicherung verfügbar und kann über die anmeldende Person gebucht werden.'
|
||||
);
|
||||
$bookingDto->familyInsuranceHintShown = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an insurance by ID from the available insurances array.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user