From 4ba81b8f03831a352433ce86408c591275cae70c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 29 Sep 2025 18:40:52 +0200 Subject: [PATCH] wip: insurance booking --- config/services.yaml | 10 +- docs/FAMILY_BOOKING_DETECTION_ISSUE.md | 172 +++++++++++++++ src/BusProNet/Constants.php | 6 + src/BusProNet/Model/Insurance.php | 19 ++ src/BusProNet/XmlLoader/InsuranceLoader.php | 4 +- src/BusProNet/XmlParser/InsuranceParser.php | 63 ++++-- .../Booking/CreateStep2Controller.php | 6 - src/Form/BookingCreateParticipantType.php | 8 +- src/Form/Model/BookingCreateDto.php | 20 +- src/Form/Service/CreateFieldStateProvider.php | 9 +- .../ParticipantFieldOptionsProvider.php | 100 ++++++++- .../ParticipantInsuranceFieldHandler.php | 124 ++++++++--- src/Model/InsuranceEligibilityCriteria.php | 28 +++ src/Service/BookingPriceCalculatorService.php | 95 +++++++- src/Service/InsuranceMatchingService.php | 203 ++++++++++++++---- src/Service/TravelDataService.php | 47 ++++ templates/booking/create_step_2.html.twig | 11 + .../XmlParser/InsuranceParserTest.php | 4 +- .../Service/InsuranceMatchingServiceTest.php | 6 +- 19 files changed, 807 insertions(+), 128 deletions(-) create mode 100644 docs/FAMILY_BOOKING_DETECTION_ISSUE.md create mode 100644 src/Model/InsuranceEligibilityCriteria.php diff --git a/config/services.yaml b/config/services.yaml index de5534d..dc7e80e 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -67,11 +67,14 @@ services: arguments: $choiceListFactory: '@form.choice_list_factory.default' - # Participant Field Handler Registry - all handlers now instantiate dependencies directly + # Insurance Field Handler with dependencies + App\Form\Service\ParticipantInsuranceFieldHandler: ~ + + # Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services App\Form\Service\ParticipantFieldHandlerRegistry: arguments: $handlers: - # All handlers now have no external dependencies and can be instantiated directly + # Simple handlers with no external dependencies - instantiated directly - 'App\Form\Service\ParticipantDateOfBirthFieldHandler' - 'App\Form\Service\ParticipantAssignedRoomFieldHandler' - 'App\Form\Service\ParticipantRemarksRoomFieldHandler' @@ -87,4 +90,5 @@ services: - 'App\Form\Service\ParticipantParkingFieldHandler' - 'App\Form\Service\ParticipantRentalInsuranceFieldHandler' - 'App\Form\Service\ParticipantLicensePlateFieldHandler' - - 'App\Form\Service\ParticipantInsuranceFieldHandler' + # Complex handlers with dependencies - use service references + - '@App\Form\Service\ParticipantInsuranceFieldHandler' diff --git a/docs/FAMILY_BOOKING_DETECTION_ISSUE.md b/docs/FAMILY_BOOKING_DETECTION_ISSUE.md new file mode 100644 index 0000000..e7823e0 --- /dev/null +++ b/docs/FAMILY_BOOKING_DETECTION_ISSUE.md @@ -0,0 +1,172 @@ +# Family Booking Detection Issue + +## Problem Description + +The current family booking detection logic in `BookingCreateDto::isFamilyBooking()` has a critical flaw that causes incorrect classification of booking types, leading to wrong insurance options being displayed. + +### Current Logic (FLAWED) + +```php +// Current implementation in BookingCreateDto::isFamilyBooking() +$adults = 0; // Count of participants >= 18 years +$youngPeople = 0; // Count of participants <= 20 years + +foreach ($this->participants as $participant) { + $age = $participant->getAge($travelStartDate); + + if ($age >= 18) { + ++$adults; + } + + if ($age <= 20) { + ++$youngPeople; + } +} + +$isFamily = ($adults >= 1 && $adults <= 2) && ($youngPeople >= 1); +``` + +### The Problem + +**Overlapping Age Ranges**: The current logic creates overlapping age categories: +- **Adults**: ≥18 years +- **Young people**: ≤20 years + +This means participants aged 18-20 are counted as **BOTH** adults AND young people, causing incorrect family booking detection. + +### Example Scenario + +**Booking with 2 participants:** +- **Participant 1**: Born 1980 (age 44 at travel time) +- **Participant 2**: Born 2000 (age 24 at travel time) + +**Current logic result:** +- `adults = 2` (both participants ≥18) +- `youngPeople = 1` (the 24-year-old ≤20) +- `isFamily = (2 >= 1 && 2 <= 2) && (1 >= 1) = true` ❌ + +**Expected result:** This should be classified as an **individual/couple booking**, not a family booking. + +## Impact + +1. **Wrong insurance options**: Family insurances are shown for individual bookings +2. **User confusion**: Customers see inappropriate insurance options +3. **Business logic errors**: Pricing and eligibility calculations are incorrect + +## Suggested Solutions + +### Option 1: Non-Overlapping Age Ranges (Recommended) + +```php +// Suggested implementation +$adults = 0; // Count of participants >= 18 years +$children = 0; // Count of participants < 18 years + +foreach ($this->participants as $participant) { + $age = $participant->getAge($travelStartDate); + + if ($age >= 18) { + ++$adults; + } else { + ++$children; + } +} + +$isFamily = ($adults >= 1) && ($children >= 1); +``` + +**Benefits:** +- No overlapping age ranges +- Clear distinction between adults and children +- Matches insurance industry standards + +### Option 2: Insurance-Specific Age Ranges + +```php +// Alternative implementation based on insurance requirements +$adults = 0; // Count of participants >= 18 years +$minors = 0; // Count of participants < 18 years + +foreach ($this->participants as $participant) { + $age = $participant->getAge($travelStartDate); + + if ($age >= 18) { + ++$adults; + } elseif ($age < 18) { + ++$minors; + } + // Note: 18+ year olds are not counted as minors +} + +$isFamily = ($adults >= 1) && ($minors >= 1); +``` + +### Option 3: Configurable Age Thresholds + +```php +// More flexible approach with configurable thresholds +private const ADULT_AGE_THRESHOLD = 18; +private const CHILD_AGE_THRESHOLD = 18; // Same as adult threshold for non-overlap + +$adults = 0; +$children = 0; + +foreach ($this->participants as $participant) { + $age = $participant->getAge($travelStartDate); + + if ($age >= self::ADULT_AGE_THRESHOLD) { + ++$adults; + } elseif ($age < self::CHILD_AGE_THRESHOLD) { + ++$children; + } +} + +$isFamily = ($adults >= 1) && ($children >= 1); +``` + +## Business Rules to Clarify + +Before implementing a solution, the following business rules need to be clarified: + +1. **What defines a "family booking"?** + - Must have at least 1 adult (≥18) and at least 1 child (<18)? + - Or can it be 2 adults with children? + - Or any booking with children regardless of adult count? + +2. **Age thresholds:** + - Should 18-year-olds be considered adults or children? + - Are there different rules for different types of services? + +3. **Edge cases:** + - What about bookings with only adults (couples)? + - What about bookings with only children (group bookings)? + +## Implementation Notes + +- The fix should be implemented in `src/Form/Model/BookingCreateDto.php` +- Update the `isFamilyBooking()` method +- Add comprehensive unit tests for edge cases +- Consider adding configuration options for age thresholds +- Update documentation to reflect the new business rules + +## Testing Scenarios + +After implementation, test these scenarios: + +1. **Single adult** (should be individual booking) +2. **Two adults** (should be couple booking, not family) +3. **One adult + one child** (should be family booking) +4. **Two adults + one child** (should be family booking) +5. **Only children** (edge case - clarify business rule) +6. **18-year-old participant** (edge case - clarify classification) + +## Related Files + +- `src/Form/Model/BookingCreateDto.php` - Main implementation +- `src/Service/InsuranceMatchingService.php` - Uses family booking detection +- `tests/Form/Model/BookingCreateDtoTest.php` - Unit tests (to be updated) + + + + + diff --git a/src/BusProNet/Constants.php b/src/BusProNet/Constants.php index 3678ef4..25b365a 100644 --- a/src/BusProNet/Constants.php +++ b/src/BusProNet/Constants.php @@ -16,8 +16,14 @@ final class Constants public const TOKEN_BOARD = 'VPF'; public const TOKEN_RENTALS = ['VER', 'VE2', 'VE3', 'VE4', 'VE5', 'VE6', 'VE7', 'VE8']; public const TOKEN_RENTAL_INSURANCE = 'LVS'; + public const TOKEN_INSURANCES = ['RRV', 'PAK', 'OHN', 'PKG']; public const TOKEN_PARKING = 'PAR'; + // Normalized service group keys for pricing display + public const GROUP_TRANSPORTATION = 'transportation'; + public const GROUP_RENTALS = 'rentals'; + public const GROUP_INSURANCE = 'insurance'; + public const STATUS_AVAILABLE = 'Frei'; public const STATUS_BLOCKED = 'Buchungsstop'; public const STATUS_ON_REQUEST = 'Anfrage'; diff --git a/src/BusProNet/Model/Insurance.php b/src/BusProNet/Model/Insurance.php index 21f6ef5..fdc7be5 100644 --- a/src/BusProNet/Model/Insurance.php +++ b/src/BusProNet/Model/Insurance.php @@ -91,4 +91,23 @@ class Insurance */ #[Groups(['api:single', 'api:list'])] public array $containedInsuranceIds = []; + + public function __toString(): string + { + return (string) $this->id; + } + + /** + * Returns the subType, with virtual 'PKG' subType for insurance packages. + * + * @return string|null The subType (RRV, PAK, OHN, PKG) or null + */ + public function getSubType(): ?string + { + if (true === $this->package) { + return 'PKG'; // Virtual subType for all insurance packages + } + + return $this->subType; + } } \ No newline at end of file diff --git a/src/BusProNet/XmlLoader/InsuranceLoader.php b/src/BusProNet/XmlLoader/InsuranceLoader.php index 1ba18a6..7f4c394 100644 --- a/src/BusProNet/XmlLoader/InsuranceLoader.php +++ b/src/BusProNet/XmlLoader/InsuranceLoader.php @@ -15,8 +15,8 @@ use Symfony\Contracts\Cache\ItemInterface; class InsuranceLoader extends AbstractLoader { public function __construct( - protected readonly CacheInterface $cache, - protected readonly FilesystemOperator $xmlExport, + CacheInterface $cache, + FilesystemOperator $xmlExport, private readonly InsuranceParser $insuranceParser, ) { parent::__construct($cache, $xmlExport); diff --git a/src/BusProNet/XmlParser/InsuranceParser.php b/src/BusProNet/XmlParser/InsuranceParser.php index c3a071e..b479d01 100644 --- a/src/BusProNet/XmlParser/InsuranceParser.php +++ b/src/BusProNet/XmlParser/InsuranceParser.php @@ -19,6 +19,7 @@ class InsuranceParser extends AbstractParser * Parses insurance XML nodes into Insurance objects. * * @param Crawler $xmlContent The XML crawler containing insurance data + * * @return array Array of Insurance objects indexed by id */ public function parse(Crawler $xmlContent): array @@ -28,25 +29,29 @@ class InsuranceParser extends AbstractParser // First pass: collect referenced insurance IDs from packages $referencedIds = $this->collectReferencedInsuranceIds($xmlContent); - // Parse individual insurances with conditional filtering + // Second pass: Parse individual insurances with conditional filtering + $individualInsurances = []; $xmlContent->filterXPath('//versicherungen/versicherung') - ->each(function (Crawler $node) use (&$insurances, $referencedIds) { + ->each(function (Crawler $node) use (&$individualInsurances, &$insurances, $referencedIds) { $id = (int) $node->attr('idbuspro'); // Individual insurances have int IDs - $isZusatz = $this->getBoolAttributeValue($node->attr('zusatzversicherung')); + $isAdditional = $this->getBoolAttributeValue($node->attr('zusatzversicherung')); - // Include if: not zusatzversicherung OR referenced by package - if (!$isZusatz || in_array($id, $referencedIds, true)) { - $insurance = $this->parseInsuranceNode($node, false); - if (null !== $insurance && null !== $insurance->id) { + // Parse all individual insurances for reference lookup + $insurance = $this->parseInsuranceNode($node, false); + if (null !== $insurance && null !== $insurance->id) { + $individualInsurances[$insurance->id] = $insurance; + + // Include if: not additional insurance OR referenced by package + if (!$isAdditional || in_array($id, $referencedIds, true)) { $insurances[$insurance->id] = $insurance; } } }); - // Parse packages (no filtering needed) + // Third pass: Parse packages with family detection based on contained insurances $xmlContent->filterXPath('//versicherungspakete/versicherungspaket') - ->each(function (Crawler $node) use (&$insurances) { - $insurance = $this->parseInsuranceNode($node, true); + ->each(function (Crawler $node) use (&$insurances, $individualInsurances) { + $insurance = $this->parseInsuranceNode($node, true, $individualInsurances); if (null !== $insurance && null !== $insurance->id) { // Parse contained insurance IDs $insurance->containedInsuranceIds = $this->parseContainedInsuranceIds($node); @@ -57,14 +62,37 @@ class InsuranceParser extends AbstractParser return $insurances; } + /** + * Determines if a package contains any family insurances. + * + * @param Crawler $packageNode The package XML node + * @param array $individualInsurances Parsed individual insurances for reference + * + * @return bool True if any contained insurance is a family insurance + */ + private function isPackageFamilyInsurance(Crawler $packageNode, array $individualInsurances): bool + { + // Check if any contained insurance is a family insurance + $containedInsuranceIds = $this->parseContainedInsuranceIds($packageNode); + foreach ($containedInsuranceIds as $containedId) { + if (isset($individualInsurances[$containedId]) && true === $individualInsurances[$containedId]->familyInsurance) { + return true; + } + } + + return false; + } + /** * Parses a single insurance or package node. * - * @param Crawler $node The XML node to parse - * @param bool $isPackage Whether this is a package node + * @param Crawler $node The XML node to parse + * @param bool $isPackage Whether this is a package node + * @param array $individualInsurances Individual insurances for package family detection + * * @return Insurance|null The parsed insurance object */ - private function parseInsuranceNode(Crawler $node, bool $isPackage): ?Insurance + private function parseInsuranceNode(Crawler $node, bool $isPackage, array $individualInsurances = []): ?Insurance { $insurance = new Insurance(); $insurance->package = $isPackage; @@ -79,6 +107,11 @@ class InsuranceParser extends AbstractParser $this->stringToFloat($node->attr('preis')) : null; $insurance->familyInsurance = $this->stringToBool($node->attr('familienversicherung')); + // For packages, check if any contained insurances are family insurances + if ($isPackage && !$insurance->familyInsurance && !empty($individualInsurances)) { + $insurance->familyInsurance = $this->isPackageFamilyInsurance($node, $individualInsurances); + } + // Date constraints $insurance->travelDateFrom = $node->attr('reisedatumvon') ? $this->stringToDate($node->attr('reisedatumvon')) : null; @@ -115,6 +148,7 @@ class InsuranceParser extends AbstractParser * Collects all insurance IDs referenced by packages. * * @param Crawler $xmlContent The XML content to scan + * * @return array Array of referenced insurance IDs */ private function collectReferencedInsuranceIds(Crawler $xmlContent): array @@ -135,6 +169,7 @@ class InsuranceParser extends AbstractParser * Parses contained insurance IDs from a package node. * * @param Crawler $node The package XML node + * * @return array Array of contained insurance IDs (always int, as they reference individual insurances) */ private function parseContainedInsuranceIds(Crawler $node): array @@ -152,4 +187,4 @@ class InsuranceParser extends AbstractParser { return !empty($value) && $this->stringToBool($value); } -} \ No newline at end of file +} diff --git a/src/Controller/Booking/CreateStep2Controller.php b/src/Controller/Booking/CreateStep2Controller.php index eb69e6c..7d1ef6e 100644 --- a/src/Controller/Booking/CreateStep2Controller.php +++ b/src/Controller/Booking/CreateStep2Controller.php @@ -124,12 +124,6 @@ class CreateStep2Controller extends AbstractController // Pre-select mandatory services after form processing but before pricing calculation $this->bookingService->preselectMandatoryServices($bookingCreateDto); - // Recreate the form with the updated DTO that includes mandatory services - $form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [ - 'attr' => ['novalidate' => 'novalidate'], - 'validation_groups' => false, - ]); - $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); diff --git a/src/Form/BookingCreateParticipantType.php b/src/Form/BookingCreateParticipantType.php index 4acbc46..807e889 100644 --- a/src/Form/BookingCreateParticipantType.php +++ b/src/Form/BookingCreateParticipantType.php @@ -181,6 +181,7 @@ class BookingCreateParticipantType extends AbstractType 'pickupInbound', 'parking', 'licensePlate', + 'insurance', ]; foreach ($dynamicFields as $fieldName) { @@ -212,7 +213,11 @@ class BookingCreateParticipantType extends AbstractType $this->addBaseFields($form, $bookingDto, $participantIndex); // Rebuild dynamic fields - $dynamicFields = ['assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'rentalInsurance', 'skiPass', 'transportationOutbound', 'transportationInbound', 'pickupOutbound', 'pickupInbound', 'parking', 'licensePlate']; + $dynamicFields = [ + 'assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'rentalInsurance', + 'skiPass', 'transportationOutbound', 'transportationInbound', 'pickupOutbound', 'pickupInbound', 'parking', + 'licensePlate', 'insurance', + ]; foreach ($dynamicFields as $fieldName) { if ($form->has($fieldName)) { $form->remove($fieldName); @@ -243,6 +248,7 @@ class BookingCreateParticipantType extends AbstractType 'pickupInbound' => ChoiceType::class, 'parking' => CheckboxType::class, 'licensePlate' => TextType::class, + 'insurance' => ChoiceType::class, ]; foreach ($dynamicFields as $fieldName => $fieldType) { diff --git a/src/Form/Model/BookingCreateDto.php b/src/Form/Model/BookingCreateDto.php index 771dfd1..3194500 100644 --- a/src/Form/Model/BookingCreateDto.php +++ b/src/Form/Model/BookingCreateDto.php @@ -55,18 +55,21 @@ class BookingCreateDto implements BookingDtoInterface * Determines if this is a family booking based on participant age distribution. * * A family booking is defined as: - * - 1-2 participants aged 18 or older (adults) - * - At least 1 participant aged 20 or younger (young people/children) + * - 1 or 2 participants aged 18 or older (adults) + * - At least 1 participant younger than 18 (children) * * @return bool True if this qualifies as a family booking */ public function isFamilyBooking(): bool { $adults = 0; // Count of participants >= 18 years - $youngPeople = 0; // Count of participants <= 20 years + $children = 0; // Count of participants < 18 years + + // Use travel start date for age calculation + $travelStartDate = $this->travel->dateFrom; foreach ($this->participants as $participant) { - $age = $participant->getAge(); + $age = $participant->getAge($travelStartDate); if (null === $age) { continue; // Skip participants without birth date @@ -74,15 +77,12 @@ class BookingCreateDto implements BookingDtoInterface if ($age >= 18) { ++$adults; - } - - if ($age <= 20) { - ++$youngPeople; + } else { + ++$children; } } - // Check family booking criteria - return ($adults >= 1 && $adults <= 2) && ($youngPeople >= 1); + return ($adults >= 1 && $adults <= 2) && ($children >= 1); } #[Assert\Callback(callback: 'validateRoomSelection', groups: ['booking_create_step_1'])] diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index a1f82e4..cf1878b 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -87,12 +87,17 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider 'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition), ]; + // Hide insurance field until date of birth is provided + $this->fieldStateConditions['insurance'] = [ + 'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition), + ]; + // Room-specific field conditions - $mbzRoomCondition = new RoomSelectionCondition(['mbz']); + $sharedRoomCondition = new RoomSelectionCondition(['mbz']); // Show remarks room field only when room with code 'mbz' is selected $this->fieldStateConditions['remarksRoom'] = [ - 'hidden' => CompositeCondition::not($mbzRoomCondition), + 'hidden' => CompositeCondition::not($sharedRoomCondition), ]; // Transportation-related field conditions diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index ceacbd7..4bfbfa3 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Form\Service; use App\BusProNet\Constants; +use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Service; use App\BusProNet\Utility\DirectionMapper; @@ -12,7 +13,10 @@ use App\Form\Model\BookingCreateDto; use App\Form\Model\BookingDtoInterface; use App\Form\Service\Abstract\AbstractFieldOptionsProvider; use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory; +use App\Form\Service\ServiceAgeEvaluator; +use App\Service\InsuranceMatchingService; use App\Service\ServiceAvailabilityCalculator; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** * Provides dynamic field options for participant form fields. @@ -43,10 +47,14 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider * * @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders * @param ServiceAvailabilityCalculator $serviceAvailabilityCalculator Service for calculating dynamic availability + * @param InsuranceMatchingService $insuranceMatchingService Service for matching insurances to participants + * @param UrlGeneratorInterface $urlGenerator URL generator for HTMX endpoints */ public function __construct( private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory, private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator, + private readonly InsuranceMatchingService $insuranceMatchingService, + private readonly UrlGeneratorInterface $urlGenerator, ) { parent::__construct(); } @@ -329,8 +337,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider return $attributes; }, 'attr' => [ - 'hx-post' => '#', // Will be configured when HTMX integration is implemented - 'hx-target' => '#booking-summary', + 'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'), + 'hx-swap' => 'none', 'hx-trigger' => 'change', ], ]; @@ -360,8 +368,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider return $attributes; }, 'attr' => [ - 'hx-post' => '#', // Will be configured when HTMX integration is implemented - 'hx-target' => '#booking-summary', + 'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'), + 'hx-swap' => 'none', 'hx-trigger' => 'change', ], ]; @@ -397,6 +405,26 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider 'required' => false, ]; + // Insurance field provider - provides age and eligibility filtered insurances for participants + $this->fieldOptionProviders['insurance'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + 'label' => 'Reiseversicherung', + 'multiple' => false, + 'expanded' => true, + 'required' => false, + 'choices' => array_merge( + [0 => null], // "no insurance" option + $this->getEligibleInsurances($bookingDto, $participantIndex) + ), + 'choice_label' => fn (?Insurance $insurance) => $this->formatInsuranceLabel($insurance), + 'choice_value' => 'id', + 'help' => 'Wählen Sie eine passende Reiseversicherung für diese Person aus.', + 'attr' => [ + 'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'), + 'hx-swap' => 'none', + 'hx-trigger' => 'change', + ], + ]; + // Future field providers would be added here, for example: // // $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [ @@ -672,4 +700,68 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider return $rentalInsuranceService->description; } + + /** + * Gets eligible insurances for a participant based on eligibility criteria. + * + * @param BookingDtoInterface $bookingDto The booking DTO containing travel and participant data + * @param int $participantIndex The index of the participant to get eligible insurances for + * + * @return array Array of eligible insurance objects filtered by age, family status, and other constraints + */ + private function getEligibleInsurances(BookingDtoInterface $bookingDto, int $participantIndex): array + { + $participant = $bookingDto->getParticipant($participantIndex); + if (null === $participant) { + return []; + } + + $availableInsurances = $bookingDto->travel->insurances ?? []; + + // Only apply insurance filtering for BookingCreateDto (creation workflow) + if (!$bookingDto instanceof BookingCreateDto) { + return $availableInsurances; + } + + // Use insurance matching service to filter based on eligibility criteria + return $this->insuranceMatchingService->getEligibleInsurances( + $availableInsurances, + $participant, + $bookingDto + ); + } + + /** + * Formats insurance label with pricing and type information. + * + * @param Insurance|null $insurance The insurance to format, or null for "No Insurance" option + * + * @return string The formatted insurance label + */ + private function formatInsuranceLabel(?Insurance $insurance): string + { + if (null === $insurance) { + return 'Keine Versicherung'; + } + + $label = $insurance->label; + + // Add pricing information (consistent with other services) + if (null !== $insurance->price && $insurance->price > 0) { + $label .= sprintf(' (€%s)', number_format($insurance->price, 2, ',', '.')); + } + + // Add type information if available + if (null !== $insurance->subType) { + $typeLabel = match ($insurance->subType) { + 'RRV' => 'Reiserücktrittsversicherung', + 'PAK' => 'Reiseschutz', + 'OHN' => 'Selbstbehalt', + default => $insurance->subType, + }; + $label .= sprintf(' (%s)', $typeLabel); + } + + return $label; + } } diff --git a/src/Form/Service/ParticipantInsuranceFieldHandler.php b/src/Form/Service/ParticipantInsuranceFieldHandler.php index 148b548..99b339d 100644 --- a/src/Form/Service/ParticipantInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantInsuranceFieldHandler.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Form\Service; use App\BusProNet\Model\Insurance; +use App\Form\Model\BookingCreateDto; use App\Form\Model\BookingDtoInterface; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; use App\Service\InsuranceMatchingService; @@ -13,19 +14,28 @@ use App\Service\InsuranceMatchingService; * Handles processing of the insurance field for booking participants. * * This handler manages insurance selections for individual participants in the booking - * creation process. It processes the insurance field from form submissions, - * validates the selection against participant eligibility criteria, and updates - * the participant DTO with the valid insurance selection. + * creation process. It processes insurance field from form submissions, validates + * selections against participant eligibility criteria, and provides automatic + * reassignment when participant pricing changes. * - * The insurance field depends on dateOfBirth for age-based eligibility calculations - * and uses the InsuranceMatchingService to ensure only eligible insurances can be selected. + * Key Features: + * - Validates submitted insurance selections against eligibility criteria + * - Automatically reassigns insurances when travel price changes make current selection invalid + * - Preserves user intent by maintaining same insurance type (subType + familyInsurance) + * - Uses InsuranceMatchingService for eligibility filtering and reassignment logic + * + * Auto-Reassignment Logic: + * When a participant's individual travel price changes (e.g., by adding services), + * their current insurance may no longer be eligible for the new price range. + * Instead of clearing the selection, this handler automatically reassigns + * to the same insurance type with the appropriate price tier. * * Dependencies: dateOfBirth (for age evaluation and insurance eligibility) */ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler { public function __construct( - private readonly InsuranceMatchingService $insuranceMatchingService, + private readonly InsuranceMatchingService $insuranceMatchingService ) { } @@ -71,10 +81,9 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler /** * Processes the insurance field for a specific participant. * - * This method extracts the insurance selection from submitted form data, - * validates the selection against the participant's eligibility criteria, - * and updates the participant DTO with the valid selection. If the insurance - * is no longer appropriate for the participant, it is automatically cleared. + * This method handles both explicit insurance selection from form data and automatic + * reassignment when the participant's travel price changes. It maintains the same + * insurance type but adjusts to the appropriate price tier when needed. * * @param array $submittedData The submitted participant form data * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) @@ -83,38 +92,67 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void { $participant = $bookingDto->getParticipant($participantIndex); - if (null === $participant) { + if (null === $participant || !$bookingDto instanceof BookingCreateDto) { return; } - $insuranceId = $submittedData['insurance'] ?? null; - - // Clear insurance if no selection - if (null === $insuranceId || '' === $insuranceId) { - $participant->insurance = null; - - return; - } - - // Find the selected insurance from available travel insurances + $selectedInsuranceId = $this->getFieldValue($submittedData, $this->getFieldName()); + $currentInsurance = $participant->insurance; $availableInsurances = $bookingDto->travel->insurances ?? []; - $selectedInsurance = $this->findInsuranceById($availableInsurances, $insuranceId); - if (null === $selectedInsurance) { - $participant->insurance = null; + // Handle explicit insurance selection from form + if (null !== $selectedInsuranceId) { + $selectedInsurance = $this->findInsuranceById($availableInsurances, $selectedInsuranceId); - return; + // Check if the selected insurance is still eligible for this participant + if (null !== $selectedInsurance) { + $eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances($availableInsurances, $participant, $bookingDto); + $isSelectedInsuranceEligible = $this->isInsuranceInList($selectedInsurance, $eligibleInsurances); + + if ($isSelectedInsuranceEligible) { + // Insurance is still eligible - use it directly + $participant->insurance = $selectedInsurance; + return; + } else { + // Insurance is no longer eligible - try to reassign to same type with new price tier + $reassignedInsurance = $this->insuranceMatchingService->reassignInsuranceForPriceChange( + $availableInsurances, + $selectedInsurance, + $participant, + $bookingDto + ); + + $participant->insurance = $reassignedInsurance; + return; + } + } else { + // Insurance not found - clear selection + $participant->insurance = null; + return; + } } - // Validate insurance eligibility for this participant - $eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances( - [$selectedInsurance], - $participant, - $bookingDto - ); + // Handle automatic reassignment if participant had an insurance but it's no longer eligible + if (null !== $currentInsurance) { + // Check if current insurance is still eligible + $eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances($availableInsurances, $participant, $bookingDto); + $isCurrentInsuranceStillEligible = $this->isInsuranceInList($currentInsurance, $eligibleInsurances); - // Set insurance only if it's eligible for this participant - $participant->insurance = !empty($eligibleInsurances) ? $selectedInsurance : null; + if (!$isCurrentInsuranceStillEligible) { + // Try to reassign to same insurance type with appropriate price tier + $reassignedInsurance = $this->insuranceMatchingService->reassignInsuranceForPriceChange( + $availableInsurances, + $currentInsurance, + $participant, + $bookingDto + ); + + $participant->insurance = $reassignedInsurance; // null if no suitable match found + return; + } + } + + // No insurance selected or reassignment needed - keep current state (may be null) } /** @@ -163,4 +201,24 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler return null; } + + /** + * Checks if a specific insurance exists in a list of insurances. + * + * @param Insurance $targetInsurance The insurance to find + * @param array $insuranceList The list to search in + * + * @return bool True if the insurance is found in the list + */ + private function isInsuranceInList(Insurance $targetInsurance, array $insuranceList): bool + { + foreach ($insuranceList as $insurance) { + if ($insurance->id === $targetInsurance->id || (string) $insurance->id === (string) $targetInsurance->id) { + return true; + } + } + + return false; + } + } diff --git a/src/Model/InsuranceEligibilityCriteria.php b/src/Model/InsuranceEligibilityCriteria.php new file mode 100644 index 0000000..4f336c9 --- /dev/null +++ b/src/Model/InsuranceEligibilityCriteria.php @@ -0,0 +1,28 @@ +getParticipant($participantIndex); + if (null === $participant) { + return 0.0; + } + + $totalPrice = 0.0; + + // Add room price if participant is assigned to a room + if (null !== $participant->assignedRoomId) { + $room = $this->getRoomById($bookingDto, $participant->assignedRoomId); + if (null !== $room && null !== $room->price) { + // Each participant pays the full room price + $totalPrice += $room->price; + } + } + + // Add service prices for this participant (excluding insurance) + $totalPrice += $this->calculateParticipantServiceTotal($participant, false); + + return $totalPrice; + } + /** * Aggregates all transportation-related services and pricing into separate line items. * @@ -311,7 +351,7 @@ class BookingPriceCalculatorService 'unitPrice' => null, 'participantCount' => $pickupParticipants, 'totalPrice' => $pickupTotal, - 'subType' => 'transportation', + 'subType' => Constants::GROUP_TRANSPORTATION, ]; } @@ -323,7 +363,7 @@ class BookingPriceCalculatorService 'unitPrice' => null, 'participantCount' => $parkingParticipants, 'totalPrice' => $parkingTotal, - 'subType' => 'transportation', + 'subType' => Constants::GROUP_TRANSPORTATION, ]; } @@ -356,7 +396,12 @@ class BookingPriceCalculatorService // Normalize rental subtypes to avoid duplicate sections if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) { - $subType = 'rentals'; // Normalize all rental subtypes to a single key + $subType = Constants::GROUP_RENTALS; // Normalize all rental subtypes to a single key + } + + // Normalize insurance subtypes to avoid duplicate sections + if (true === in_array($subType, Constants::TOKEN_INSURANCES, true)) { + $subType = Constants::GROUP_INSURANCE; // Normalize all insurance subtypes to a single key } $isDiscount = $serviceData['totalPrice'] < 0; @@ -405,8 +450,9 @@ class BookingPriceCalculatorService Constants::TOKEN_ADDITIONAL => 'Zusatzleistungen', Constants::TOKEN_BOARD => 'Verpflegung', Constants::TOKEN_RENTAL_INSURANCE => 'Leihmaterial-Versicherung', - 'transportation' => 'Beförderung', - 'rentals' => 'Leihmaterial', // Normalized rental subtype + Constants::GROUP_TRANSPORTATION => 'Beförderung', + Constants::GROUP_RENTALS => 'Leihmaterial', // Normalized rental subtype + Constants::GROUP_INSURANCE => 'Reiseversicherungen', // Normalized insurance subtype ]; // Handle rentals array (keep for backward compatibility with non-normalized subtypes) @@ -431,6 +477,10 @@ class BookingPriceCalculatorService $this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance, 1); } + if (null !== $participant->insurance && null !== $participant->insurance->price) { + $this->addInsuranceToServiceAggregation($serviceAggregation, $participant->insurance, 1); + } + // Handle multiple service selections $multipleServiceArrays = [ 'courses' => $participant->courses, @@ -475,11 +525,12 @@ class BookingPriceCalculatorService /** * 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) * * @return float The total service cost for this participant */ - private function calculateParticipantServiceTotal(ParticipantDto $participant): float + private function calculateParticipantServiceTotal(ParticipantDto $participant, bool $includeInsurance = true): float { $serviceTotal = 0.0; @@ -492,6 +543,10 @@ class BookingPriceCalculatorService $serviceTotal += $participant->rentalInsurance->price; } + if ($includeInsurance && null !== $participant->insurance && null !== $participant->insurance->price) { + $serviceTotal += $participant->insurance->price; + } + // Transportation services if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) { $serviceTotal += $participant->transportationOutbound->price; @@ -551,4 +606,30 @@ class BookingPriceCalculatorService return null; } + + /** + * Adds an insurance to the service aggregation array. + * + * @param array $serviceAggregation The service aggregation array to update + * @param Insurance $insurance The insurance to add + * @param int $quantity The quantity of the insurance + */ + private function addInsuranceToServiceAggregation(array &$serviceAggregation, Insurance $insurance, int $quantity): void + { + $serviceKey = $insurance->id.'_'.$insurance->label; + + if (false === isset($serviceAggregation[$serviceKey])) { + $serviceAggregation[$serviceKey] = [ + 'serviceId' => $insurance->id, + 'label' => $insurance->label, + 'unitPrice' => $insurance->price, + 'participantCount' => 0, + 'totalPrice' => 0.0, + 'subType' => $insurance->getSubType(), + ]; + } + + $serviceAggregation[$serviceKey]['participantCount'] += $quantity; + $serviceAggregation[$serviceKey]['totalPrice'] += $insurance->price * $quantity; + } } diff --git a/src/Service/InsuranceMatchingService.php b/src/Service/InsuranceMatchingService.php index cbbd0f2..19d8b12 100644 --- a/src/Service/InsuranceMatchingService.php +++ b/src/Service/InsuranceMatchingService.php @@ -7,6 +7,9 @@ namespace App\Service; use App\BusProNet\Model\Insurance; use App\Form\Model\BookingCreateDto; use App\Form\Model\ParticipantDto; +use App\Model\InsuranceEligibilityCriteria; +use App\Service\BookingPriceCalculatorService; +use App\Service\InsuranceTypeResolver; use Carbon\Carbon; /** @@ -18,6 +21,11 @@ use Carbon\Carbon; */ class InsuranceMatchingService { + public function __construct( + private readonly BookingPriceCalculatorService $priceCalculatorService, + private readonly InsuranceTypeResolver $insuranceTypeResolver + ) { + } /** * Filters insurances based on participant and booking criteria. * @@ -29,67 +37,151 @@ class InsuranceMatchingService */ public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingCreateDto $booking): array { - $travelStartDate = $booking->travel->dateFrom; - $travelEndDate = $booking->travel->dateTo; + $criteria = $this->createEligibilityCriteria($participant, $booking); - // Cannot match insurances without travel dates - if (null === $travelStartDate || null === $travelEndDate) { - return []; + if (null === $criteria) { + return []; // Cannot match insurances without travel dates } - $bookingDate = Carbon::now()->toDateTimeImmutable(); - $travelPrice = $this->calculateTravelPrice($booking); - $travelDurationDays = $this->calculateTravelDurationDays($travelStartDate, $travelEndDate); - - return array_filter($insurances, function (Insurance $insurance) use ($participant, $travelStartDate, $travelEndDate, $bookingDate, $travelPrice, $travelDurationDays) { - return $this->isInsuranceEligible($insurance, $participant, $travelStartDate, $travelEndDate, $bookingDate, $travelPrice, $travelDurationDays); - }); + return array_filter( + $insurances, + fn (Insurance $insurance) => $this->isInsuranceEligible($insurance, $criteria) + ); } /** - * Checks if a specific insurance is eligible for a participant. + * Auto-reassigns an insurance to the same type with appropriate price tier. * - * @param Insurance $insurance The insurance to check - * @param ParticipantDto $participant The participant - * @param \DateTimeImmutable $travelStartDate Travel start date - * @param \DateTimeImmutable $travelEndDate Travel end date - * @param \DateTimeImmutable $bookingDate Booking date - * @param float $travelPrice Total travel price - * @param int $travelDurationDays Travel duration in days + * This method is used when a participant's individual price changes and their + * current insurance is no longer eligible. It finds the same insurance type + * (subType + familyInsurance) with the correct price tier. * - * @return bool True if insurance is eligible + * @param array $availableInsurances All available insurances + * @param Insurance $currentInsurance The currently selected insurance + * @param ParticipantDto $participant The participant to reassign for + * @param BookingCreateDto $booking The booking context + * + * @return Insurance|null The reassigned insurance or null if no suitable match found */ - private function isInsuranceEligible( - Insurance $insurance, - ParticipantDto $participant, - \DateTimeImmutable $travelStartDate, - \DateTimeImmutable $travelEndDate, - \DateTimeImmutable $bookingDate, - float $travelPrice, - int $travelDurationDays, - ): bool { + 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); + + // Get eligible insurances for this participant + $eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking); + + // Return the first eligible insurance (they should all be equivalent for the same type) + return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null; + } + + /** + * Batch-assigns insurances of the same type to all participants based on individual pricing. + * + * This method takes the applicant's insurance selection and assigns the same insurance type + * (subType + familyInsurance) to all participants, but selects the appropriate price tier + * based on each participant's individual travel price. + * + * @param array $availableInsurances All available insurances + * @param Insurance $selectedInsurance The insurance selected by the applicant + * @param BookingCreateDto $booking The booking with all participants + * + * @return array Array indexed by participant index with assigned insurances + */ + public function batchAssignInsuranceToParticipants(array $availableInsurances, Insurance $selectedInsurance, BookingCreateDto $booking): array + { + $assignments = []; + + // Group insurances of the same type (subType + familyInsurance) + $sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $selectedInsurance->subType, $selectedInsurance->familyInsurance); + + // Assign appropriate insurance to each participant + foreach ($booking->getParticipants() as $index => $participant) { + $eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking); + $assignments[$index] = !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null; + } + + return $assignments; + } + + /** + * Creates eligibility criteria from participant and booking data. + */ + private function createEligibilityCriteria(ParticipantDto $participant, BookingCreateDto $booking): ?InsuranceEligibilityCriteria + { + $travelStartDate = $booking->travel->dateFrom; + $travelEndDate = $booking->travel->dateTo; + + if (null === $travelStartDate || null === $travelEndDate) { + return null; // Cannot create criteria without travel dates + } + + return new InsuranceEligibilityCriteria( + participant: $participant, + travelStartDate: $travelStartDate, + travelEndDate: $travelEndDate, + bookingDate: Carbon::now()->toDateTimeImmutable(), + travelPrice: $this->calculateTravelPrice($booking, $participant->index), + travelDurationDays: $this->calculateTravelDurationDays($travelStartDate, $travelEndDate), + booking: $booking, + ); + } + + /** + * Checks if a specific insurance is eligible for given criteria. + */ + private function isInsuranceEligible(Insurance $insurance, InsuranceEligibilityCriteria $criteria): bool + { + // Family insurance constraints + if (false === $this->checkFamilyInsuranceConstraints($insurance, $criteria->booking)) { + return false; + } + // Age constraints - if (!$this->checkAgeConstraints($insurance, $participant, $travelStartDate)) { + if (false === $this->checkAgeConstraints($insurance, $criteria->participant, $criteria->travelStartDate)) { return false; } // Travel date constraints - if (!$this->checkTravelDateConstraints($insurance, $travelStartDate, $travelEndDate)) { + if (false === $this->checkTravelDateConstraints($insurance, $criteria->travelStartDate, $criteria->travelEndDate)) { return false; } // Booking date constraints - if (!$this->checkBookingDateConstraints($insurance, $bookingDate)) { + if (false === $this->checkBookingDateConstraints($insurance, $criteria->bookingDate)) { return false; } // Travel price constraints - if (!$this->checkTravelPriceConstraints($insurance, $travelPrice)) { + if (false === $this->checkTravelPriceConstraints($insurance, $criteria->travelPrice)) { return false; } // Travel duration constraints - if (!$this->checkTravelDurationConstraints($insurance, $travelDurationDays)) { + if (false === $this->checkTravelDurationConstraints($insurance, $criteria->travelDurationDays)) { + return false; + } + + return true; + } + + /** + * Checks if family insurance constraints are met. + * + * Family insurances should only be available for family bookings, + * and individual insurances should only be available for non-family bookings. + */ + private function checkFamilyInsuranceConstraints(Insurance $insurance, BookingCreateDto $booking): bool + { + $isFamilyBooking = $booking->isFamilyBooking(); + + // If it's a family insurance, it should only be available for family bookings + if (true === $insurance->familyInsurance && false === $isFamilyBooking) { + return false; + } + + // If it's not a family insurance, it should only be available for non-family bookings + if (false === $insurance->familyInsurance && true === $isFamilyBooking) { return false; } @@ -103,8 +195,9 @@ class InsuranceMatchingService { $participantAge = $participant->getAge($travelStartDate); + // If no birth date is provided, skip age constraints (field will be hidden via field state conditions) if (null === $participantAge) { - return false; // Cannot determine age eligibility without birth date + return true; } // Check minimum age @@ -197,17 +290,21 @@ class InsuranceMatchingService } /** - * Calculates the total travel price from booking data. + * Calculates the total travel price for a participant, excluding insurance prices. * - * @param BookingCreateDto $booking The booking to calculate price for + * This method calculates the travel price used for insurance eligibility filtering. + * It excludes insurance prices to prevent circular dependency where insurance selection + * affects travel price which then affects insurance eligibility. * - * @return float The total travel price + * @param BookingCreateDto $booking The booking to calculate price for + * @param int $participantIndex The participant index to calculate for + * + * @return float The total travel price for the participant excluding insurance */ - private function calculateTravelPrice(BookingCreateDto $booking): float + private function calculateTravelPrice(BookingCreateDto $booking, int $participantIndex): float { - // For now, return 0.0 as placeholder - this will be enhanced - // when we integrate with the existing pricing calculation system - return 0.0; + // Use the price calculator to get the participant's individual price excluding insurance + return $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($booking, $participantIndex); } /** @@ -222,4 +319,24 @@ class InsuranceMatchingService { return $startDate->diff($endDate)->days; } + + /** + * Filters insurances by type (subType and familyInsurance combination). + * + * 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. + * + * @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 + * + * @return array Filtered insurances of the same type + */ + private function filterInsurancesByType(array $insurances, ?string $subType, bool $familyInsurance): array + { + return array_filter( + $insurances, + fn (Insurance $insurance) => $insurance->subType === $subType && $insurance->familyInsurance === $familyInsurance + ); + } } diff --git a/src/Service/TravelDataService.php b/src/Service/TravelDataService.php index ede35c6..fb04a14 100644 --- a/src/Service/TravelDataService.php +++ b/src/Service/TravelDataService.php @@ -10,6 +10,7 @@ use App\BusProNet\Model\BaseData; use App\BusProNet\Model\Notification; use App\BusProNet\Model\Travel; use App\BusProNet\XmlLoader\HotelLoader; +use App\BusProNet\XmlLoader\InsuranceLoader; use App\BusProNet\XmlLoader\PickupLoader; use App\BusProNet\XmlLoader\TravelLoader; use App\Exception\HotelNotFoundException; @@ -36,6 +37,7 @@ class TravelDataService private readonly TravelLoader $travelLoader, private readonly HotelLoader $hotelLoader, private readonly PickupLoader $pickupLoader, + private readonly InsuranceLoader $insuranceLoader, private readonly ApiClient $apiClient, private readonly CacheInterface $cache, private readonly LoggerInterface $logger, @@ -102,6 +104,11 @@ class TravelDataService public function getTravelDataFromXml(int $dateId, ?int $hotelId = null): ?Travel { try { + $this->logger->debug('Loading travel data from XML', [ + 'dateId' => $dateId, + 'hotelId' => $hotelId, + ]); + $travel = $this->travelLoader->loadById($dateId, $hotelId); $this->enrichTravelData($travel); @@ -151,6 +158,11 @@ class TravelDataService public function getTravelDataFromApi(int $dateId, ?int $hotelId = null): ?Travel { try { + $this->logger->debug('Loading travel data from API', [ + 'dateId' => $dateId, + 'hotelId' => $hotelId, + ]); + // Map dateId to productId for API call $productId = $this->mapDateIdToProductId($dateId); if (null === $productId) { @@ -581,8 +593,13 @@ class TravelDataService private function enrichTravelData(Travel $travel): void { try { + $this->logger->debug('Enriching travel data', [ + 'travelId' => $travel->id, + ]); + $this->pickupLoader->patchPickupsDetails($travel); $this->hotelLoader->patchHotelDetails($travel); + $this->patchInsuranceData($travel); } catch (\Exception $e) { $this->logger->warning('Failed to enrich travel data', [ 'travelId' => $travel->id, @@ -590,4 +607,34 @@ class TravelDataService ]); } } + + /** + * Adds insurance data to travel object. + * + * Loads all available insurances and adds them to the travel object. + * This ensures insurance options are available for booking. + * + * @param Travel $travel The travel object to enrich with insurance data + */ + private function patchInsuranceData(Travel $travel): void + { + try { + $this->logger->debug('Loading insurance data', [ + 'travelId' => $travel->id, + ]); + + $insurances = $this->insuranceLoader->loadAll(); + $travel->insurances = array_values($insurances); + + $this->logger->debug('Insurance data added to travel', [ + 'travelId' => $travel->id, + 'insuranceCount' => count($insurances), + ]); + } catch (\Exception $e) { + $this->logger->warning('Failed to load insurance data', [ + 'travelId' => $travel->id, + 'error' => $e->getMessage(), + ]); + } + } } diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index 6e0965c..0d97b9d 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -94,6 +94,8 @@ 'hx-swap': 'none' } }) }} + {% else %} +
{# Empty div to maintain grid layout when skipass not available #} {% endif %} {% if participant.courses is defined %} {{ form_row(participant.courses, { @@ -140,6 +142,15 @@ } }) }} {% 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' + } + }) }} + {% endif %} {# Transportation Services Section #} diff --git a/tests/BusProNet/XmlParser/InsuranceParserTest.php b/tests/BusProNet/XmlParser/InsuranceParserTest.php index b81e098..0b4d725 100644 --- a/tests/BusProNet/XmlParser/InsuranceParserTest.php +++ b/tests/BusProNet/XmlParser/InsuranceParserTest.php @@ -72,13 +72,13 @@ class InsuranceParserTest extends TestCase $this->assertFalse($deductibleInsurance->familyInsurance); $this->assertFalse($deductibleInsurance->package); - // Test package + // Test package - should be family insurance because it contains a family insurance (177236) $package = $insurances['P1000320']; $this->assertEquals('P1000320', $package->id); $this->assertEquals('Reiseschutz Platin Auto/Bahn/Bus (Europa) + Selbstbehaltübernahme', $package->label); $this->assertNull($package->subType); // Packages don't have subtypes $this->assertEquals(14.0, $package->price); - $this->assertFalse($package->familyInsurance); + $this->assertTrue($package->familyInsurance); // Should be true because it contains family insurance 177236 $this->assertTrue($package->package); $this->assertEquals([177236, 177270], $package->containedInsuranceIds); } diff --git a/tests/Service/InsuranceMatchingServiceTest.php b/tests/Service/InsuranceMatchingServiceTest.php index 4a132fd..54cb1ee 100644 --- a/tests/Service/InsuranceMatchingServiceTest.php +++ b/tests/Service/InsuranceMatchingServiceTest.php @@ -171,7 +171,11 @@ class InsuranceMatchingServiceTest extends TestCase $result = $this->service->getEligibleInsurances([$insurance], $participant, $booking); - $this->assertEmpty($result); + // Participants without birth date should now have insurances available + // (field visibility is controlled by field state conditions, not service logic) + $this->assertNotEmpty($result); + $this->assertCount(1, $result); + $this->assertSame($insurance, $result[0]); } public function testGetEligibleInsurancesWithNullConstraints(): void