From e3081e56f83eb70a159a6e86537e637e530b1aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 2 Oct 2025 16:32:50 +0200 Subject: [PATCH] feat: extended insurance parsing, pass info urls to view --- src/BusProNet/Model/Insurance.php | 65 ++++++++++++++ src/BusProNet/XmlParser/InsuranceParser.php | 20 +++-- src/Form/BookingCreateParticipantType.php | 2 +- src/Form/InsuranceChoiceType.php | 86 +++++++++++++++++++ .../ParticipantFieldOptionsProvider.php | 11 ++- src/Service/TravelDataService.php | 36 ++++++++ templates/forms.html.twig | 41 +++++++++ .../XmlParser/InsuranceParserTest.php | 39 ++++++--- 8 files changed, 277 insertions(+), 23 deletions(-) create mode 100644 src/Form/InsuranceChoiceType.php diff --git a/src/BusProNet/Model/Insurance.php b/src/BusProNet/Model/Insurance.php index fdc7be5..cde1164 100644 --- a/src/BusProNet/Model/Insurance.php +++ b/src/BusProNet/Model/Insurance.php @@ -37,6 +37,9 @@ class Insurance #[Groups(['api:single', 'api:list'])] public bool $package = false; + #[Groups(['api:single', 'api:list'])] + public bool $complementary = false; + #[Groups(['api:single', 'api:list'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] public ?\DateTimeImmutable $travelDateFrom = null; @@ -110,4 +113,66 @@ class Insurance return $this->subType; } + + /** + * Returns all info URLs from this insurance or its contained insurances (for packages). + * + * @return array Array of info URLs + */ + public function getAllUrlsInfo(): array + { + if (true === $this->package) { + return $this->collectUrlsFromContainedInsurances('urlInfo'); + } + + return null !== $this->urlInfo ? [$this->urlInfo] : []; + } + + /** + * Returns all product info URLs from this insurance or its contained insurances (for packages). + * + * @return array Array of product info URLs + */ + public function getAllUrlsProductInfo(): array + { + if (true === $this->package) { + return $this->collectUrlsFromContainedInsurances('urlProductInfo'); + } + + return null !== $this->urlProductInfo ? [$this->urlProductInfo] : []; + } + + /** + * Returns all terms URLs from this insurance or its contained insurances (for packages). + * + * @return array Array of terms URLs + */ + public function getAllUrlsTerms(): array + { + if (true === $this->package) { + return $this->collectUrlsFromContainedInsurances('urlTerms'); + } + + return null !== $this->urlTerms ? [$this->urlTerms] : []; + } + + /** + * Collects URLs from contained insurances for a specific URL property. + * + * @param string $urlProperty The URL property name to collect (urlInfo, urlProductInfo, urlTerms) + * + * @return array Array of non-null URLs from contained insurances + */ + private function collectUrlsFromContainedInsurances(string $urlProperty): array + { + $urls = []; + + foreach ($this->containedInsurances as $containedInsurance) { + if (null !== $containedInsurance->{$urlProperty}) { + $urls[] = $containedInsurance->{$urlProperty}; + } + } + + return array_values(array_unique($urls)); + } } \ No newline at end of file diff --git a/src/BusProNet/XmlParser/InsuranceParser.php b/src/BusProNet/XmlParser/InsuranceParser.php index 7ae5051..86d09ef 100644 --- a/src/BusProNet/XmlParser/InsuranceParser.php +++ b/src/BusProNet/XmlParser/InsuranceParser.php @@ -39,13 +39,14 @@ class InsuranceParser extends AbstractParser // Parse all individual insurances for reference lookup $insurance = $this->parseInsuranceNode($node, false); if (null !== $insurance && null !== $insurance->id) { + // Set complementary flag from XML attribute + $insurance->complementary = $isComplementary; + $individualInsurances[$insurance->id] = $insurance; - // Include only if it's NOT a complementary insurance (zusatzversicherung) - // Complementary insurances are only available as part of packages, never standalone - if (!$isComplementary) { - $insurances[$insurance->id] = $insurance; - } + // Include ALL insurances in the result array + // Complementary insurances will be filtered out at the form field level + $insurances[$insurance->id] = $insurance; } }); @@ -56,6 +57,15 @@ class InsuranceParser extends AbstractParser if (null !== $insurance && null !== $insurance->id) { // Parse contained insurance IDs $insurance->containedInsuranceIds = $this->parseContainedInsuranceIds($node); + + // Populate containedInsurances with actual Insurance objects + $insurance->containedInsurances = []; + foreach ($insurance->containedInsuranceIds as $containedId) { + if (isset($individualInsurances[$containedId])) { + $insurance->containedInsurances[] = $individualInsurances[$containedId]; + } + } + $insurances[$insurance->id] = $insurance; } }); diff --git a/src/Form/BookingCreateParticipantType.php b/src/Form/BookingCreateParticipantType.php index b0a213c..e975a75 100644 --- a/src/Form/BookingCreateParticipantType.php +++ b/src/Form/BookingCreateParticipantType.php @@ -250,7 +250,7 @@ class BookingCreateParticipantType extends AbstractType 'parking' => CheckboxType::class, 'licensePlate' => TextType::class, 'bulkInsuranceBooking' => CheckboxType::class, - 'insurance' => ChoiceType::class, + 'insurance' => InsuranceChoiceType::class, ]; foreach ($dynamicFields as $fieldName => $fieldType) { diff --git a/src/Form/InsuranceChoiceType.php b/src/Form/InsuranceChoiceType.php new file mode 100644 index 0000000..5b33440 --- /dev/null +++ b/src/Form/InsuranceChoiceType.php @@ -0,0 +1,86 @@ +setRequired('insurances'); + $resolver->setAllowedTypes('insurances', 'array'); + + $resolver->setDefault('choices', function (Options $options) { + // Prepend "no insurance" option to eligible insurances + return array_merge( + [0 => null], + $options['insurances'] + ); + }); + + $resolver->setDefault('choice_value', function ($insurance) { + // Handle "no insurance" option (null value at index 0) + // instanceof check required because closures in configureOptions receive mixed types + if ($insurance instanceof Insurance) { + return (string) $insurance->id; + } + return ''; + }); + + $resolver->setDefault('choice_label', function ($insurance) { + // Handle "no insurance" option (null value at index 0) + // instanceof check required because closures in configureOptions receive mixed types + if (!$insurance instanceof Insurance) { + return 'Keine Versicherung'; + } + + $label = $insurance->label; + + if (null !== $insurance->price && $insurance->price > 0) { + $label .= sprintf(' (€%s)', number_format($insurance->price, 2, ',', '.')); + } + + return $label; + }); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + // Index insurances by ID for template lookup + $insurances = []; + foreach ($options['insurances'] as $insurance) { + if ($insurance instanceof Insurance) { + $insurances[(string) $insurance->id] = $insurance; + } + } + + // Pass Insurance objects to template indexed by choice value + $view->vars['insurances'] = $insurances; + } + + public function getParent(): string + { + return ChoiceType::class; + } + + public function getBlockPrefix(): string + { + return 'insurance_choice'; + } +} diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 39939a2..805a8ee 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -422,12 +422,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider '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', + 'insurances' => $this->getEligibleInsurances($bookingDto, $participantIndex), 'attr' => [ 'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'), 'hx-swap' => 'none', @@ -729,6 +724,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $availableInsurances = $bookingDto->travel->insurances ?? []; + // Exclude complementary insurances from standalone selection + // They are only available as part of packages + $availableInsurances = array_filter($availableInsurances, fn ($insurance) => !$insurance->complementary); + // Only apply insurance filtering for BookingCreateDto (creation workflow) if (!$bookingDto instanceof BookingCreateDto) { return $availableInsurances; diff --git a/src/Service/TravelDataService.php b/src/Service/TravelDataService.php index 64f4f1e..dc74014 100644 --- a/src/Service/TravelDataService.php +++ b/src/Service/TravelDataService.php @@ -607,6 +607,10 @@ class TravelDataService try { $insurances = $this->insuranceLoader->loadAll(); $travel->insurances = array_values($insurances); + + // Hydrate package relationships after loading + // Packages lose their containedInsurances during serialization, so rebuild them + $this->hydrateInsurancePackageRelationships($travel->insurances); } catch (\Exception $e) { $this->logger->warning('Failed to load insurance data', [ 'travelId' => $travel->id, @@ -614,4 +618,36 @@ class TravelDataService ]); } } + + /** + * Reconstructs containedInsurances arrays for insurance packages. + * + * Insurance packages reference other insurances via containedInsuranceIds. + * After deserialization, the containedInsurances array is empty due to + * circular reference prevention. This method rebuilds those relationships. + * + * @param array $insurances All insurances including packages + */ + private function hydrateInsurancePackageRelationships(array $insurances): void + { + // Build lookup map of all insurances by ID (includes complementary insurances) + $insuranceById = []; + foreach ($insurances as $insurance) { + $insuranceById[$insurance->id] = $insurance; + } + + // Reconstruct containedInsurances for each package + foreach ($insurances as $insurance) { + if (!$insurance->package || empty($insurance->containedInsuranceIds)) { + continue; + } + + $insurance->containedInsurances = []; + foreach ($insurance->containedInsuranceIds as $containedId) { + if (isset($insuranceById[$containedId])) { + $insurance->containedInsurances[] = $insuranceById[$containedId]; + } + } + } + } } diff --git a/templates/forms.html.twig b/templates/forms.html.twig index d7b813e..92f3b4c 100644 --- a/templates/forms.html.twig +++ b/templates/forms.html.twig @@ -193,3 +193,44 @@ {%- endblock multiselect_widget -%} + +{% block insurance_choice_widget %} + {%- if expanded -%} + {%- for child in form %} + {%- set child_attr = {} -%} + + {%- if attr['hx-trigger'] is defined -%} + {%- set child_attr = child_attr|merge({ + 'hx-trigger': attr['hx-trigger'], + 'hx-post': attr['hx-post'], + 'hx-swap': attr['hx-swap'] + }) -%} + {%- endif -%} + + {%- if child.vars.attr is defined -%} + {%- set child_attr = child_attr|merge(child.vars.attr) -%} + {%- endif -%} + + {%- set insurance = insurances[child.vars.value] ?? null -%} + {%- if insurance is not null -%} + {%- set urlsInfo = insurance.getAllUrlsInfo() -%} + {%- set urlsProductInfo = insurance.getAllUrlsProductInfo() -%} + {%- set urlsTerms = insurance.getAllUrlsTerms() -%} + + {%- if urlsInfo is not empty -%} + {%- set child_attr = child_attr|merge({'data-urls-info': urlsInfo|json_encode}) -%} + {%- endif -%} + {%- if urlsProductInfo is not empty -%} + {%- set child_attr = child_attr|merge({'data-urls-product-info': urlsProductInfo|json_encode}) -%} + {%- endif -%} + {%- if urlsTerms is not empty -%} + {%- set child_attr = child_attr|merge({'data-urls-terms': urlsTerms|json_encode}) -%} + {%- endif -%} + {%- endif -%} + + {{- form_row(child, { 'attr': child_attr }) -}} + {% endfor -%} + {%- else -%} + {{- parent() -}} + {%- endif -%} +{% endblock %} diff --git a/tests/BusProNet/XmlParser/InsuranceParserTest.php b/tests/BusProNet/XmlParser/InsuranceParserTest.php index 0b4d725..3f6bc3a 100644 --- a/tests/BusProNet/XmlParser/InsuranceParserTest.php +++ b/tests/BusProNet/XmlParser/InsuranceParserTest.php @@ -22,9 +22,9 @@ class InsuranceParserTest extends TestCase $xmlContent = ' - - - + + + @@ -39,7 +39,7 @@ class InsuranceParserTest extends TestCase $crawler = new Crawler($xmlContent); $insurances = $this->parser->parse($crawler); - // Test individual insurances + package (3 individual + 1 package = 4 total) + // Test all insurances are included (2 regular + 1 complementary + 1 package = 4 total) $this->assertCount(4, $insurances); // Test travel cancellation insurance (RRV) @@ -51,18 +51,20 @@ class InsuranceParserTest extends TestCase $this->assertEquals(5.0, $cancellationInsurance->price); $this->assertFalse($cancellationInsurance->familyInsurance); $this->assertFalse($cancellationInsurance->package); + $this->assertFalse($cancellationInsurance->complementary); // Test travel protection insurance (PAK) with family flag $protectionInsurance = $insurances[177236]; $this->assertEquals(177236, $protectionInsurance->id); $this->assertEquals('913320', $protectionInsurance->code); - $this->assertEquals('Reiseschutz Platin Auto/Bahn/Bus (Europa)', $protectionInsurance->label); + $this->assertEquals('Reiseschutz Platin', $protectionInsurance->label); // "Auto/Bahn/Bus (Europa)" is normalized away $this->assertEquals('PAK', $protectionInsurance->subType); $this->assertEquals(9.0, $protectionInsurance->price); $this->assertTrue($protectionInsurance->familyInsurance); $this->assertFalse($protectionInsurance->package); + $this->assertFalse($protectionInsurance->complementary); - // Test deductible insurance (OHN) - should be included because it's referenced by package + // Test deductible insurance (OHN) - IS included but marked as complementary $deductibleInsurance = $insurances[177270]; $this->assertEquals(177270, $deductibleInsurance->id); $this->assertEquals('903551', $deductibleInsurance->code); @@ -71,16 +73,27 @@ class InsuranceParserTest extends TestCase $this->assertEquals(5.0, $deductibleInsurance->price); $this->assertFalse($deductibleInsurance->familyInsurance); $this->assertFalse($deductibleInsurance->package); + $this->assertTrue($deductibleInsurance->complementary); // Marked as complementary // 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->assertEquals('Reiseschutz Platin + Selbstbehaltübernahme', $package->label); // "Auto/Bahn/Bus (Europa)" is normalized away $this->assertNull($package->subType); // Packages don't have subtypes $this->assertEquals(14.0, $package->price); $this->assertTrue($package->familyInsurance); // Should be true because it contains family insurance 177236 $this->assertTrue($package->package); $this->assertEquals([177236, 177270], $package->containedInsuranceIds); + + // Test containedInsurances array is populated with actual Insurance objects + $this->assertCount(2, $package->containedInsurances); + $this->assertEquals(177236, $package->containedInsurances[0]->id); + $this->assertEquals(177270, $package->containedInsurances[1]->id); + + // Test URL aggregation from contained insurances + $this->assertEquals(['https://example.com/info2', 'https://example.com/info3'], $package->getAllUrlsInfo()); + $this->assertEquals(['https://example.com/product2', 'https://example.com/product3'], $package->getAllUrlsProductInfo()); + $this->assertEquals(['https://example.com/terms2', 'https://example.com/terms3'], $package->getAllUrlsTerms()); } public function testParsePackageWithoutSubtype(): void @@ -169,12 +182,16 @@ class InsuranceParserTest extends TestCase $crawler = new Crawler($xmlContent); $insurances = $this->parser->parse($crawler); - // Should include: normal insurance (178917), referenced zusatz (177270), and package (P1000320) - // Should exclude: unreferenced zusatz (999999) - $this->assertCount(3, $insurances); + // Should include: normal insurance (178917), both complementary insurances (177270, 999999), and package (P1000320) + $this->assertCount(4, $insurances); $this->assertArrayHasKey(178917, $insurances); $this->assertArrayHasKey(177270, $insurances); + $this->assertArrayHasKey(999999, $insurances); $this->assertArrayHasKey('P1000320', $insurances); - $this->assertArrayNotHasKey(999999, $insurances); + + // Verify complementary flags + $this->assertFalse($insurances[178917]->complementary); // Normal insurance + $this->assertTrue($insurances[177270]->complementary); // Complementary insurance + $this->assertTrue($insurances[999999]->complementary); // Complementary insurance } }