feat: indicate family insurance eligibility
This commit is contained in:
@@ -92,7 +92,9 @@ class Step2ParticipantController extends AbstractController
|
||||
return $this->redirectToRoute('app_booking_create_step_2');
|
||||
}
|
||||
|
||||
return $this->renderParticipantForm($form, $index, $bookingDto);
|
||||
// Notifications were already cleared from the DTO above, so they have to be handed
|
||||
// to the render path too - otherwise every invalid submission silently swallows them
|
||||
return $this->renderParticipantForm($form, $index, $bookingDto, $notifications);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,17 +126,25 @@ class Step2ParticipantController extends AbstractController
|
||||
);
|
||||
}
|
||||
|
||||
/** @param FormInterface<mixed> $form */
|
||||
/**
|
||||
* @param FormInterface<mixed> $form
|
||||
* @param array<array{type: string, message: string}> $notifications
|
||||
*/
|
||||
private function renderParticipantForm(
|
||||
FormInterface $form,
|
||||
int $index,
|
||||
BookingDto $bookingDto,
|
||||
array $notifications = [],
|
||||
): Response {
|
||||
$bookingCreateContext = $this->createContextFactory->create(
|
||||
$bookingDto,
|
||||
RoomPricingCalculator::PRICING_MODE_SELECTION
|
||||
);
|
||||
|
||||
// The rendered template includes _partials/_flashes.html.twig, so flashes added here
|
||||
// are consumed by this very response instead of leaking into the next page
|
||||
$this->addNotificationsAsFlashMessages($notifications);
|
||||
|
||||
return $this->render('booking/create/step_2_participant.html.twig', [
|
||||
'form' => $form,
|
||||
'participantIndex' => $index,
|
||||
|
||||
@@ -20,6 +20,7 @@ class BookingCreateContext
|
||||
public readonly ?array $cardsData = null,
|
||||
public readonly bool $isSubmitted = false,
|
||||
public readonly ?array $participantPrices = null,
|
||||
public readonly bool $familyInsuranceUpgradeAvailable = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,18 +70,12 @@ 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".
|
||||
* was not yet eligible (i.e. before the dependents' dates of birth were known).
|
||||
* Set at selection time; used by FamilyInsuranceAvailabilityChecker to distinguish
|
||||
* "the applicant never had the chance" from "it was available all along and the
|
||||
* applicant chose otherwise".
|
||||
*/
|
||||
public bool $applicantInsuranceChosenWhileFamilyIneligible = false;
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ 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\FamilyInsuranceAvailabilityChecker;
|
||||
use App\Service\InsuranceManager;
|
||||
|
||||
/**
|
||||
@@ -38,6 +38,7 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
public function __construct(
|
||||
private readonly InsuranceManager $insuranceService,
|
||||
private readonly BookingPriceCalculator $priceCalculatorService,
|
||||
private readonly FamilyInsuranceAvailabilityChecker $familyInsuranceService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -125,19 +126,6 @@ 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) {
|
||||
@@ -283,8 +271,9 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
|
||||
/**
|
||||
* 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".
|
||||
* was NOT yet eligible, so the standing hint rendered in step 2 can tell "never had
|
||||
* the chance" 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.
|
||||
@@ -295,79 +284,11 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
return;
|
||||
}
|
||||
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
if (null === $applicant) {
|
||||
if (null === $bookingDto->getParticipant(0)) {
|
||||
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;
|
||||
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible = !$this->familyInsuranceService->hasEligibleFamilyInsurance($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ class BookingCreateContextFactory
|
||||
private readonly ParticipantCardAssembler $participantCardDataService,
|
||||
private readonly BookingSummaryAssembler $summaryDataService,
|
||||
private readonly BookingPriceCalculator $priceCalculator,
|
||||
private readonly FamilyInsuranceAvailabilityChecker $familyInsuranceService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -32,6 +33,7 @@ class BookingCreateContextFactory
|
||||
bookingDto: $bookingDto,
|
||||
summaryData: $baseContext['summaryData'],
|
||||
groupedRooms: $baseContext['groupedRooms'],
|
||||
familyInsuranceUpgradeAvailable: $baseContext['familyInsuranceUpgradeAvailable'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +47,7 @@ class BookingCreateContextFactory
|
||||
groupedRooms: $baseContext['groupedRooms'],
|
||||
cardsData: $this->participantCardDataService->getAllCardsDataWithValidation($bookingDto),
|
||||
isSubmitted: $isSubmitted,
|
||||
familyInsuranceUpgradeAvailable: $baseContext['familyInsuranceUpgradeAvailable'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,17 +60,19 @@ class BookingCreateContextFactory
|
||||
summaryData: $baseContext['summaryData'],
|
||||
groupedRooms: $baseContext['groupedRooms'],
|
||||
participantPrices: $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto),
|
||||
familyInsuranceUpgradeAvailable: $baseContext['familyInsuranceUpgradeAvailable'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{summaryData: BookingSummaryDto, groupedRooms: RoomGroupsDto}
|
||||
* @return array{summaryData: BookingSummaryDto, groupedRooms: RoomGroupsDto, familyInsuranceUpgradeAvailable: bool}
|
||||
*/
|
||||
private function buildBaseContext(BookingDto $bookingDto, string $pricingMode): array
|
||||
{
|
||||
return [
|
||||
'summaryData' => $this->summaryDataService->getSummaryData($bookingDto, $pricingMode),
|
||||
'groupedRooms' => $this->groupRoomsBySelectionType($bookingDto->travel->getAvailableRooms()),
|
||||
'familyInsuranceUpgradeAvailable' => $this->familyInsuranceService->isFamilyInsuranceUpgradeAvailable($bookingDto),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\Form\Model\BookingDto;
|
||||
|
||||
/**
|
||||
* Single source of truth for "the applicant could still upgrade to a family insurance".
|
||||
*
|
||||
* Participants are entered one card at a time, applicant first, so at the moment the
|
||||
* applicant has to pick an insurance the family constellation is not yet known and no
|
||||
* family insurance can be offered. Only the applicant may hold a family insurance, so
|
||||
* without a standing hint the opportunity is silently lost once the dependents' dates
|
||||
* of birth make the booking eligible.
|
||||
*
|
||||
* This is deliberately a derived predicate rather than a one-time event: it stays true
|
||||
* for as long as the applicant can still act on it and disappears by itself once they
|
||||
* switch to a family insurance, deliberately pick something else while eligible, or the
|
||||
* constellation stops qualifying.
|
||||
*/
|
||||
class FamilyInsuranceAvailabilityChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InsuranceManager $insuranceService,
|
||||
private readonly BookingPriceCalculator $priceCalculatorService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the applicant should be pointed at a family insurance they cannot have
|
||||
* chosen earlier.
|
||||
*
|
||||
* Requires `applicantInsuranceChosenWhileFamilyIneligible` - i.e. the standing choice
|
||||
* was actually made before family insurance was possible - so an applicant who was
|
||||
* offered a family insurance and picked something else is not nagged about it.
|
||||
*/
|
||||
public function isFamilyInsuranceUpgradeAvailable(BookingDto $bookingDto): bool
|
||||
{
|
||||
if (false === $bookingDto->applicantInsuranceChosenWhileFamilyIneligible) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
|
||||
if (null === $applicant || null === $applicant->insurance || true === $applicant->insurance->familyInsurance) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->hasEligibleFamilyInsurance($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public function hasEligibleFamilyInsurance(BookingDto $bookingDto): bool
|
||||
{
|
||||
if (false === $bookingDto->isFamilyBooking()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
if (null === $applicant) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user