diff --git a/config/services.yaml b/config/services.yaml index dc7e80e..b04fcf1 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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' diff --git a/src/Form/BookingCreateParticipantType.php b/src/Form/BookingCreateParticipantType.php index 807e889..b0a213c 100644 --- a/src/Form/BookingCreateParticipantType.php +++ b/src/Form/BookingCreateParticipantType.php @@ -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, ]; diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index ddade64..0ab6a5f 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -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(); diff --git a/src/Form/Service/Condition/BulkInsuranceBookingCondition.php b/src/Form/Service/Condition/BulkInsuranceBookingCondition.php new file mode 100644 index 0000000..5affa22 --- /dev/null +++ b/src/Form/Service/Condition/BulkInsuranceBookingCondition.php @@ -0,0 +1,96 @@ + 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 $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; + } +} \ No newline at end of file diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index cf1878b..7e10056 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -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 diff --git a/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php b/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php new file mode 100644 index 0000000..762a881 --- /dev/null +++ b/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php @@ -0,0 +1,142 @@ + $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 $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; + } + } +} \ No newline at end of file diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 8add401..19a1155 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -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', diff --git a/src/Form/Service/ParticipantInsuranceFieldHandler.php b/src/Form/Service/ParticipantInsuranceFieldHandler.php index b615c6d..c664450 100644 --- a/src/Form/Service/ParticipantInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantInsuranceFieldHandler.php @@ -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 $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 $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; + } } diff --git a/src/Service/InsuranceMatchingService.php b/src/Service/InsuranceMatchingService.php index b3a283c..6bce729 100644 --- a/src/Service/InsuranceMatchingService.php +++ b/src/Service/InsuranceMatchingService.php @@ -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 $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 $insurances All available insurances to filter + * @param Insurance $referenceInsurance The insurance to match against * * @return array 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 ); } } diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index f18e88f..0dc36e5 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -183,14 +183,49 @@ 'hx-swap': 'none' } }) }} - - {{ _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 %} +
+ Reiseversicherung +
+ {% if participantData.insurance %} + {{ participantData.insurance.label }} + {% if participantData.insurance.price and participantData.insurance.price > 0 %} + (€{{ participantData.insurance.price|number_format(2, ',', '.') }}) + {% endif %} + – wie Anmelder + {% else %} + wie Anmelder + {% endif %} +
+
+ {% else %} +
+ Reiseversicherung + + {# 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 %} +
Nicht wählbar
+ {% endif %} +
+ {% endif %} {# Transportation Services Section #}