feat: bulk insurance booking by applicant

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 66dd51d75a
commit f2681af9ee
10 changed files with 402 additions and 24 deletions
+3 -1
View File
@@ -67,8 +67,9 @@ services:
arguments:
$choiceListFactory: '@form.choice_list_factory.default'
# Insurance Field Handler with dependencies
# Insurance Field Handlers with dependencies
App\Form\Service\ParticipantInsuranceFieldHandler: ~
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
# Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services
App\Form\Service\ParticipantFieldHandlerRegistry:
@@ -91,4 +92,5 @@ services:
- 'App\Form\Service\ParticipantRentalInsuranceFieldHandler'
- 'App\Form\Service\ParticipantLicensePlateFieldHandler'
# Complex handlers with dependencies - use service references
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
- '@App\Form\Service\ParticipantInsuranceFieldHandler'
+3 -1
View File
@@ -181,6 +181,7 @@ class BookingCreateParticipantType extends AbstractType
'pickupInbound',
'parking',
'licensePlate',
'bulkInsuranceBooking',
'insurance',
];
@@ -216,7 +217,7 @@ class BookingCreateParticipantType extends AbstractType
$dynamicFields = [
'assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'rentalInsurance',
'skiPass', 'transportationOutbound', 'transportationInbound', 'pickupOutbound', 'pickupInbound', 'parking',
'licensePlate', 'insurance',
'licensePlate', 'bulkInsuranceBooking', 'insurance',
];
foreach ($dynamicFields as $fieldName) {
if ($form->has($fieldName)) {
@@ -248,6 +249,7 @@ class BookingCreateParticipantType extends AbstractType
'pickupInbound' => ChoiceType::class,
'parking' => CheckboxType::class,
'licensePlate' => TextType::class,
'bulkInsuranceBooking' => CheckboxType::class,
'insurance' => ChoiceType::class,
];
+3
View File
@@ -74,6 +74,9 @@ class ParticipantDto
// Selected insurance for this participant (individual insurance selection per participant)
public ?Insurance $insurance = null;
// Bulk insurance booking flag (applicant only: when checked, assigns same insurance type to all participants)
public bool $bulkInsuranceBooking = false;
public static function fromPersonalData(PersonalData $personalData): static
{
$instance = new static();
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that evaluates whether bulk insurance booking is active for dependent participants.
*
* This condition determines if the applicant (first participant) has enabled bulk insurance
* booking for all participants. When active, dependent participants (index > 0) will have
* their insurance field replaced with a "wie Anmelder" (same as applicant) message, and the
* applicant's insurance selection (or lack thereof) will be automatically applied to all
* participants based on their individual travel prices and age constraints.
*
* The condition is satisfied when:
* 1. Evaluating a dependent participant (not the applicant)
* 2. The applicant has bulkInsuranceBooking flag set to true
*/
class BulkInsuranceBookingCondition implements FieldConditionInterface
{
/**
* Evaluates if bulk insurance booking is active for the given participant.
*
* For the applicant (index 0), always returns false since they control the bulk booking.
* For dependent participants, returns true if the applicant has bulk insurance booking enabled,
* regardless of whether an insurance is selected (applies to "no insurance" as well).
*
* @param BookingDtoInterface $bookingDto The current booking data
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if bulk insurance booking is active for this dependent participant
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
{
// Applicant is never affected by bulk insurance booking (they control it)
if (0 === $participantIndex) {
return false;
}
// Get applicant data
$applicant = $bookingDto->getParticipant(0);
if (null === $applicant) {
return false;
}
// Check if applicant has bulk insurance booking enabled
// No need to check for insurance selection - bulk applies even when no insurance is selected
return $this->getBulkInsuranceBookingValue($formData, $applicant);
}
/**
* Returns field names that this condition depends on.
*
* This condition depends on the applicant's bulkInsuranceBooking flag.
* Any changes to this field should trigger re-evaluation of dependent participant field states.
*
* @return string[] Array containing field names this condition depends on
*/
public function getDependentFields(): array
{
return ['bulkInsuranceBooking'];
}
/**
* Returns a human-readable description of this condition.
*
* @return string Description of the bulk insurance booking condition
*/
public function getDescription(): string
{
return 'Applicant has bulk insurance booking enabled';
}
/**
* Gets the bulk insurance booking flag value from form data or participant DTO.
*/
private function getBulkInsuranceBookingValue(array $formData, object $applicant): bool
{
// First check form data (for fresh submissions)
if (isset($formData['participants'][0]['bulkInsuranceBooking'])) {
return (bool) $formData['participants'][0]['bulkInsuranceBooking'];
}
// Fall back to participant DTO
if (property_exists($applicant, 'bulkInsuranceBooking')) {
return (bool) $applicant->bulkInsuranceBooking;
}
return false;
}
}
+32 -2
View File
@@ -6,6 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Service\Abstract\AbstractFieldStateProvider;
use App\Form\Service\Condition\BulkInsuranceBookingCondition;
use App\Form\Service\Condition\CompositeCondition;
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
use App\Form\Service\Condition\FieldValueCondition;
@@ -87,9 +88,38 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
];
// Hide insurance field until date of birth is provided
// Bulk insurance booking conditions
$bulkInsuranceBookingCondition = new BulkInsuranceBookingCondition();
// Show bulk insurance booking checkbox ONLY for applicant (index 0) and when insurance field is visible
$this->fieldStateConditions['bulkInsuranceBooking'] = [
'hidden' => CompositeCondition::or(
CompositeCondition::not($dateOfBirthProvidedCondition), // Hide until date of birth provided
new class() implements \App\Form\Service\Contract\FieldConditionInterface {
public function evaluate(\App\Form\Model\BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
{
return $participantIndex > 0; // Hide for all participants except applicant
}
public function getDependentFields(): array
{
return [];
}
public function getDescription(): string
{
return 'Participant is not the applicant';
}
}
),
];
// Hide insurance field until date of birth is provided OR when bulk insurance booking is active for dependent participants
$this->fieldStateConditions['insurance'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
'hidden' => CompositeCondition::or(
CompositeCondition::not($dateOfBirthProvidedCondition),
$bulkInsuranceBookingCondition
),
];
// Room-specific field conditions
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\InsuranceMatchingService;
/**
* Handles bulk insurance booking for the applicant (first participant).
*
* When the applicant enables bulk insurance booking, their selected insurance type
* (subType + familyInsurance) is automatically assigned to all participants with
* automatic price tier adjustment based on each participant's individual travel price.
*
* This handler processes the bulkInsuranceBooking checkbox state and triggers
* insurance assignment to dependent participants when enabled.
*
* Dependencies: insurance (applicant must have insurance selected before enabling bulk)
*/
class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly InsuranceMatchingService $insuranceMatchingService,
) {
}
public function getFieldName(): string
{
return 'bulkInsuranceBooking';
}
public function getDependencies(): array
{
// Depends on insurance field to ensure insurance is selected before bulk assignment
return ['insurance'];
}
/**
* Determines if this handler should process the field.
*
* Only process for the applicant (index 0). Dependent participants don't have
* the bulkInsuranceBooking checkbox - their insurance is controlled by the handler.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param int $participantIndex The index of the participant being processed
*
* @return bool True if this is the applicant, false otherwise
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
{
return 0 === $participantIndex; // Only process for applicant
}
/**
* Processes the bulk insurance booking checkbox for the applicant.
*
* When bulk insurance is enabled and applicant has insurance selected,
* assigns the same insurance type to all participants based on their
* individual pricing and eligibility criteria.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $bookingDto The booking DTO to update
* @param int $participantIndex The index of the participant (must be 0)
*/
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant || !$bookingDto instanceof BookingCreateDto) {
return;
}
// Get checkbox value from submitted data
$bulkInsuranceBooking = $this->getFieldValue($submittedData, $this->getFieldName());
$isBulkEnabled = (bool) $bulkInsuranceBooking;
// Store checkbox state
$participant->bulkInsuranceBooking = $isBulkEnabled;
// If bulk insurance is enabled and applicant has insurance, assign to all participants
if (true === $isBulkEnabled && null !== $participant->insurance) {
$this->applyBulkInsuranceToAllParticipants($bookingDto, $participant->insurance);
}
// If bulk insurance is disabled, clear dependent participants' insurances
if (false === $isBulkEnabled) {
$this->clearDependentParticipantsInsurance($bookingDto);
}
}
/**
* Applies the applicant's insurance type to all participants with automatic price tier adjustment.
*
* Uses InsuranceMatchingService to find the appropriate price tier for each participant
* based on their individual travel price and eligibility criteria.
*
* @param BookingCreateDto $bookingDto The booking DTO with all participants
* @param object $applicantInsurance The insurance selected by the applicant
*/
private function applyBulkInsuranceToAllParticipants(BookingCreateDto $bookingDto, object $applicantInsurance): void
{
$availableInsurances = $bookingDto->travel->insurances ?? [];
// Get insurance assignments for all participants
$assignments = $this->insuranceMatchingService->batchAssignInsuranceToParticipants(
$availableInsurances,
$applicantInsurance,
$bookingDto
);
// Apply assignments to participants (skip applicant index 0, they keep their selection)
foreach ($assignments as $index => $insurance) {
if (0 === $index) {
continue; // Skip applicant
}
$participant = $bookingDto->getParticipant($index);
if (null !== $participant) {
$participant->insurance = $insurance;
}
}
}
/**
* Clears insurance selections for dependent participants when bulk booking is disabled.
*
* @param BookingCreateDto $bookingDto The booking DTO with all participants
*/
private function clearDependentParticipantsInsurance(BookingCreateDto $bookingDto): void
{
foreach ($bookingDto->getParticipants() as $index => $participant) {
if (0 === $index) {
continue; // Skip applicant
}
$participant->insurance = null;
}
}
}
@@ -405,6 +405,17 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'required' => false,
];
// Bulk insurance booking checkbox (applicant only - controls insurance assignment for all participants)
$this->fieldOptionProviders['bulkInsuranceBooking'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Für alle Teilnehmer buchen',
'required' => false,
'attr' => [
'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
'hx-swap' => 'none',
'hx-trigger' => 'change',
],
];
// Insurance field provider - provides age and eligibility filtered insurances for participants
$this->fieldOptionProviders['insurance'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Reiseversicherung',
@@ -82,13 +82,22 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
* where the selection is cleared (field not present in data). This ensures
* the participant DTO is updated with null when no insurance is selected.
*
* However, when bulk insurance booking is active for a dependent participant,
* we skip processing to prevent overwriting the insurance assigned by the
* bulk insurance handler.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for insurance selection fields
* @return bool True if processing should occur, false if bulk insurance handles it
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
{
// Skip processing for dependent participants when bulk insurance booking is active
if ($participantIndex > 0 && $this->isBulkInsuranceBookingActive($submittedData)) {
return false;
}
return true; // Always process to handle deselection cases
}
@@ -258,4 +267,35 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
{
return (string) $currentInsurance->id === (string) $selectedInsuranceId;
}
/**
* Checks if bulk insurance booking is active for dependent participants.
*
* Bulk insurance is active when the applicant has enabled the bulkInsuranceBooking
* flag and has selected an insurance.
*
* @param array<string, mixed> $submittedData The submitted form data
*
* @return bool True if bulk insurance booking is active
*/
private function isBulkInsuranceBookingActive(array $submittedData): bool
{
// Check if applicant data exists
if (!isset($submittedData['participants'][0])) {
return false;
}
$applicantData = $submittedData['participants'][0];
// Check if bulk insurance booking is enabled
$bulkInsuranceBooking = $applicantData['bulkInsuranceBooking'] ?? false;
if (false === (bool) $bulkInsuranceBooking) {
return false;
}
// Check if applicant has selected an insurance
$applicantInsurance = $applicantData['insurance'] ?? null;
return null !== $applicantInsurance;
}
}
+28 -11
View File
@@ -63,8 +63,8 @@ class InsuranceMatchingService
*/
public function reassignInsuranceForPriceChange(array $availableInsurances, Insurance $currentInsurance, ParticipantDto $participant, BookingCreateDto $booking): ?Insurance
{
// Group insurances of the same type (subType + familyInsurance)
$sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $currentInsurance->subType, $currentInsurance->familyInsurance);
// Group insurances of the same type
$sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $currentInsurance);
// Get eligible insurances for this participant
$eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking);
@@ -96,8 +96,8 @@ class InsuranceMatchingService
{
$assignments = [];
// Group insurances of the same type (subType + familyInsurance)
$sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $selectedInsurance->subType, $selectedInsurance->familyInsurance);
// Group insurances of the same type
$sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $selectedInsurance);
// Assign appropriate insurance to each participant
foreach ($booking->getParticipants() as $index => $participant) {
@@ -329,22 +329,39 @@ class InsuranceMatchingService
}
/**
* Filters insurances by type (subType and familyInsurance combination).
* Filters insurances by type based on label (for packages) or subType (for individual insurances).
*
* This method groups insurances of the same type together for reassignment or batch assignment.
* Insurance type is defined as the combination of subType (RRV, PAK, OHN) and familyInsurance flag.
* Insurance type matching strategy:
* - **Packages**: Match by label + familyInsurance (packages with same label are different price tiers)
* - **Individual insurances**: Match by subType + familyInsurance
*
* @param array<Insurance> $insurances All available insurances to filter
* @param string|null $subType The insurance subType to match (e.g., 'RRV', 'PAK')
* @param bool $familyInsurance Whether to match family or individual insurances
* Example: "Reise-Rücktritt + Selbstbehaltübernahme" at €10, €14, €26 are the same type,
* but different from "Reiseschutz Platin Auto/Bahn/Bus (Europa) + Selbstbehaltübernahme".
*
* @param array<Insurance> $insurances All available insurances to filter
* @param Insurance $referenceInsurance The insurance to match against
*
* @return array<Insurance> Filtered insurances of the same type
*/
private function filterInsurancesByType(array $insurances, ?string $subType, bool $familyInsurance): array
private function filterInsurancesByType(array $insurances, Insurance $referenceInsurance): array
{
// For packages, match by label (packages with same label are different price tiers of same type)
if (true === $referenceInsurance->package) {
return array_filter(
$insurances,
fn (Insurance $insurance) => true === $insurance->package
&& $insurance->label === $referenceInsurance->label
&& $insurance->familyInsurance === $referenceInsurance->familyInsurance
);
}
// For individual insurances, match by subType
return array_filter(
$insurances,
fn (Insurance $insurance) => $insurance->subType === $subType && $insurance->familyInsurance === $familyInsurance
fn (Insurance $insurance) => false === $insurance->package
&& $insurance->subType === $referenceInsurance->subType
&& $insurance->familyInsurance === $referenceInsurance->familyInsurance
);
}
}
+42 -7
View File
@@ -184,13 +184,48 @@
}
}) }}
{{ _self.service_field(participant, 'insurance', 'Reiseversicherung', {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_refresh'),
'hx-swap': 'none'
}
}) }}
{# Insurance field OR assigned insurance display for dependent participants #}
{% set participantData = participant.vars.data %}
{% set showBulkInsurance = loop.index > 1 and form.vars.data.participants[0].bulkInsuranceBooking %}
{% if showBulkInsurance %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">Reiseversicherung</legend>
<div class="text-sm text-gray-600">
{% if participantData.insurance %}
{{ participantData.insurance.label }}
{% if participantData.insurance.price and participantData.insurance.price > 0 %}
<span class="text-gray-500">(€{{ participantData.insurance.price|number_format(2, ',', '.') }})</span>
{% endif %}
<span class="italic text-gray-500 ml-2"> wie Anmelder</span>
{% else %}
<span class="italic">wie Anmelder</span>
{% endif %}
</div>
</fieldset>
{% else %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">Reiseversicherung</legend>
{# Bulk insurance booking checkbox (applicant only) #}
{% if participant.bulkInsuranceBooking is defined %}
{{ form_row(participant.bulkInsuranceBooking) }}
{% endif %}
{% if participant.insurance is defined %}
{{ form_row(participant.insurance, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_refresh'),
'hx-swap': 'none'
},
'label': false
}) }}
{% else %}
<div class="text-sm text-gray-500">Nicht wählbar</div>
{% endif %}
</fieldset>
{% endif %}
</div>
{# Transportation Services Section #}