feat: extended insurance parsing, pass info urls to view

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 201b223813
commit e3081e56f8
8 changed files with 277 additions and 23 deletions
+65
View File
@@ -37,6 +37,9 @@ class Insurance
#[Groups(['api:single', 'api:list'])] #[Groups(['api:single', 'api:list'])]
public bool $package = false; public bool $package = false;
#[Groups(['api:single', 'api:list'])]
public bool $complementary = false;
#[Groups(['api:single', 'api:list'])] #[Groups(['api:single', 'api:list'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
public ?\DateTimeImmutable $travelDateFrom = null; public ?\DateTimeImmutable $travelDateFrom = null;
@@ -110,4 +113,66 @@ class Insurance
return $this->subType; return $this->subType;
} }
/**
* Returns all info URLs from this insurance or its contained insurances (for packages).
*
* @return array<string> 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<string> 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<string> 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<string> 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));
}
} }
+15 -5
View File
@@ -39,13 +39,14 @@ class InsuranceParser extends AbstractParser
// Parse all individual insurances for reference lookup // Parse all individual insurances for reference lookup
$insurance = $this->parseInsuranceNode($node, false); $insurance = $this->parseInsuranceNode($node, false);
if (null !== $insurance && null !== $insurance->id) { if (null !== $insurance && null !== $insurance->id) {
// Set complementary flag from XML attribute
$insurance->complementary = $isComplementary;
$individualInsurances[$insurance->id] = $insurance; $individualInsurances[$insurance->id] = $insurance;
// Include only if it's NOT a complementary insurance (zusatzversicherung) // Include ALL insurances in the result array
// Complementary insurances are only available as part of packages, never standalone // Complementary insurances will be filtered out at the form field level
if (!$isComplementary) { $insurances[$insurance->id] = $insurance;
$insurances[$insurance->id] = $insurance;
}
} }
}); });
@@ -56,6 +57,15 @@ class InsuranceParser extends AbstractParser
if (null !== $insurance && null !== $insurance->id) { if (null !== $insurance && null !== $insurance->id) {
// Parse contained insurance IDs // Parse contained insurance IDs
$insurance->containedInsuranceIds = $this->parseContainedInsuranceIds($node); $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; $insurances[$insurance->id] = $insurance;
} }
}); });
+1 -1
View File
@@ -250,7 +250,7 @@ class BookingCreateParticipantType extends AbstractType
'parking' => CheckboxType::class, 'parking' => CheckboxType::class,
'licensePlate' => TextType::class, 'licensePlate' => TextType::class,
'bulkInsuranceBooking' => CheckboxType::class, 'bulkInsuranceBooking' => CheckboxType::class,
'insurance' => ChoiceType::class, 'insurance' => InsuranceChoiceType::class,
]; ];
foreach ($dynamicFields as $fieldName => $fieldType) { foreach ($dynamicFields as $fieldName => $fieldType) {
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\BusProNet\Model\Insurance;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form type for insurance selection that provides full Insurance objects to templates.
*
* This type extends ChoiceType to pass complete Insurance model data to the template layer,
* enabling flexible rendering of insurance details including URLs, pricing, and coverage information.
*/
class InsuranceChoiceType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->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';
}
}
@@ -422,12 +422,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'multiple' => false, 'multiple' => false,
'expanded' => true, 'expanded' => true,
'required' => false, 'required' => false,
'choices' => array_merge( 'insurances' => $this->getEligibleInsurances($bookingDto, $participantIndex),
[0 => null], // "no insurance" option
$this->getEligibleInsurances($bookingDto, $participantIndex)
),
'choice_label' => fn (?Insurance $insurance) => $this->formatInsuranceLabel($insurance),
'choice_value' => 'id',
'attr' => [ 'attr' => [
'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'), 'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
'hx-swap' => 'none', 'hx-swap' => 'none',
@@ -729,6 +724,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$availableInsurances = $bookingDto->travel->insurances ?? []; $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) // Only apply insurance filtering for BookingCreateDto (creation workflow)
if (!$bookingDto instanceof BookingCreateDto) { if (!$bookingDto instanceof BookingCreateDto) {
return $availableInsurances; return $availableInsurances;
+36
View File
@@ -607,6 +607,10 @@ class TravelDataService
try { try {
$insurances = $this->insuranceLoader->loadAll(); $insurances = $this->insuranceLoader->loadAll();
$travel->insurances = array_values($insurances); $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) { } catch (\Exception $e) {
$this->logger->warning('Failed to load insurance data', [ $this->logger->warning('Failed to load insurance data', [
'travelId' => $travel->id, '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<Insurance> $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];
}
}
}
}
} }
+41
View File
@@ -193,3 +193,44 @@
</div> </div>
</div> </div>
{%- endblock multiselect_widget -%} {%- 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 %}
@@ -22,9 +22,9 @@ class InsuranceParserTest extends TestCase
$xmlContent = '<?xml version="1.0" encoding="utf-8"?> $xmlContent = '<?xml version="1.0" encoding="utf-8"?>
<versicherungsstammdaten> <versicherungsstammdaten>
<versicherungen> <versicherungen>
<versicherung id="1" idbuspro="178917" code="903100" bezeichnung="Reise-Rücktritt" preis="5,00" preisartprozent="False" unterart="RRV" familienversicherung="False" paarversicherung="False" zusatzversicherung="False" reisedatumvon="01.09.2024" reisedatumbis="31.12.2030" buchungdatumvon="01.03.2025" buchungdatumbis="31.12.2030" altervon="0" alterbis="64" reisepreisvon="0,00" reisepreisbis="100,00" reisedauervon="0" reisedauerbis="255" /> <versicherung id="1" idbuspro="178917" code="903100" bezeichnung="Reise-Rücktritt" preis="5,00" preisartprozent="False" unterart="RRV" familienversicherung="False" paarversicherung="False" zusatzversicherung="False" reisedatumvon="01.09.2024" reisedatumbis="31.12.2030" buchungdatumvon="01.03.2025" buchungdatumbis="31.12.2030" altervon="0" alterbis="64" reisepreisvon="0,00" reisepreisbis="100,00" reisedauervon="0" reisedauerbis="255" urlinfo="https://example.com/info1" urlproduktinfo="https://example.com/product1" urlagb="https://example.com/terms1" />
<versicherung id="2" idbuspro="177236" code="913320" bezeichnung="Reiseschutz Platin Auto/Bahn/Bus (Europa)" preis="9,00" preisartprozent="False" unterart="PAK" familienversicherung="True" paarversicherung="False" zusatzversicherung="False" reisedatumvon="01.09.2024" reisedatumbis="31.12.2030" buchungdatumvon="01.03.2025" buchungdatumbis="31.12.2030" altervon="0" alterbis="64" reisepreisvon="0,00" reisepreisbis="100,00" reisedauervon="1" reisedauerbis="45" /> <versicherung id="2" idbuspro="177236" code="913320" bezeichnung="Reiseschutz Platin Auto/Bahn/Bus (Europa)" preis="9,00" preisartprozent="False" unterart="PAK" familienversicherung="True" paarversicherung="False" zusatzversicherung="False" reisedatumvon="01.09.2024" reisedatumbis="31.12.2030" buchungdatumvon="01.03.2025" buchungdatumbis="31.12.2030" altervon="0" alterbis="64" reisepreisvon="0,00" reisepreisbis="100,00" reisedauervon="1" reisedauerbis="45" urlinfo="https://example.com/info2" urlproduktinfo="https://example.com/product2" urlagb="https://example.com/terms2" />
<versicherung id="3" idbuspro="177270" code="903551" bezeichnung="Selbstbehaltübernahme" preis="5,00" preisartprozent="False" unterart="OHN" familienversicherung="False" paarversicherung="False" zusatzversicherung="True" reisedatumvon="01.09.2024" reisedatumbis="31.12.2030" buchungdatumvon="01.03.2025" buchungdatumbis="31.12.2030" altervon="0" alterbis="64" reisepreisvon="0,00" reisepreisbis="600,00" reisedauervon="0" reisedauerbis="255" /> <versicherung id="3" idbuspro="177270" code="903551" bezeichnung="Selbstbehaltübernahme" preis="5,00" preisartprozent="False" unterart="OHN" familienversicherung="False" paarversicherung="False" zusatzversicherung="True" reisedatumvon="01.09.2024" reisedatumbis="31.12.2030" buchungdatumvon="01.03.2025" buchungdatumbis="31.12.2030" altervon="0" alterbis="64" reisepreisvon="0,00" reisepreisbis="600,00" reisedauervon="0" reisedauerbis="255" urlinfo="https://example.com/info3" urlproduktinfo="https://example.com/product3" urlagb="https://example.com/terms3" />
</versicherungen> </versicherungen>
<versicherungspakete> <versicherungspakete>
<versicherungspaket id="1" idbuspro="P1000320" bezeichnung="Reiseschutz Platin Auto/Bahn/Bus (Europa) + Selbstbehaltübernahme" preis="14,00" preisartprozent="False" reisedatumvon="01.09.2024" reisedatumbis="31.12.2030" altervon="0" alterbis="64" reisepreisvon="0" reisepreisbis="100,00" familienversicherung="False"> <versicherungspaket id="1" idbuspro="P1000320" bezeichnung="Reiseschutz Platin Auto/Bahn/Bus (Europa) + Selbstbehaltübernahme" preis="14,00" preisartprozent="False" reisedatumvon="01.09.2024" reisedatumbis="31.12.2030" altervon="0" alterbis="64" reisepreisvon="0" reisepreisbis="100,00" familienversicherung="False">
@@ -39,7 +39,7 @@ class InsuranceParserTest extends TestCase
$crawler = new Crawler($xmlContent); $crawler = new Crawler($xmlContent);
$insurances = $this->parser->parse($crawler); $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); $this->assertCount(4, $insurances);
// Test travel cancellation insurance (RRV) // Test travel cancellation insurance (RRV)
@@ -51,18 +51,20 @@ class InsuranceParserTest extends TestCase
$this->assertEquals(5.0, $cancellationInsurance->price); $this->assertEquals(5.0, $cancellationInsurance->price);
$this->assertFalse($cancellationInsurance->familyInsurance); $this->assertFalse($cancellationInsurance->familyInsurance);
$this->assertFalse($cancellationInsurance->package); $this->assertFalse($cancellationInsurance->package);
$this->assertFalse($cancellationInsurance->complementary);
// Test travel protection insurance (PAK) with family flag // Test travel protection insurance (PAK) with family flag
$protectionInsurance = $insurances[177236]; $protectionInsurance = $insurances[177236];
$this->assertEquals(177236, $protectionInsurance->id); $this->assertEquals(177236, $protectionInsurance->id);
$this->assertEquals('913320', $protectionInsurance->code); $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('PAK', $protectionInsurance->subType);
$this->assertEquals(9.0, $protectionInsurance->price); $this->assertEquals(9.0, $protectionInsurance->price);
$this->assertTrue($protectionInsurance->familyInsurance); $this->assertTrue($protectionInsurance->familyInsurance);
$this->assertFalse($protectionInsurance->package); $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]; $deductibleInsurance = $insurances[177270];
$this->assertEquals(177270, $deductibleInsurance->id); $this->assertEquals(177270, $deductibleInsurance->id);
$this->assertEquals('903551', $deductibleInsurance->code); $this->assertEquals('903551', $deductibleInsurance->code);
@@ -71,16 +73,27 @@ class InsuranceParserTest extends TestCase
$this->assertEquals(5.0, $deductibleInsurance->price); $this->assertEquals(5.0, $deductibleInsurance->price);
$this->assertFalse($deductibleInsurance->familyInsurance); $this->assertFalse($deductibleInsurance->familyInsurance);
$this->assertFalse($deductibleInsurance->package); $this->assertFalse($deductibleInsurance->package);
$this->assertTrue($deductibleInsurance->complementary); // Marked as complementary
// Test package - should be family insurance because it contains a family insurance (177236) // Test package - should be family insurance because it contains a family insurance (177236)
$package = $insurances['P1000320']; $package = $insurances['P1000320'];
$this->assertEquals('P1000320', $package->id); $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->assertNull($package->subType); // Packages don't have subtypes
$this->assertEquals(14.0, $package->price); $this->assertEquals(14.0, $package->price);
$this->assertTrue($package->familyInsurance); // Should be true because it contains family insurance 177236 $this->assertTrue($package->familyInsurance); // Should be true because it contains family insurance 177236
$this->assertTrue($package->package); $this->assertTrue($package->package);
$this->assertEquals([177236, 177270], $package->containedInsuranceIds); $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 public function testParsePackageWithoutSubtype(): void
@@ -169,12 +182,16 @@ class InsuranceParserTest extends TestCase
$crawler = new Crawler($xmlContent); $crawler = new Crawler($xmlContent);
$insurances = $this->parser->parse($crawler); $insurances = $this->parser->parse($crawler);
// Should include: normal insurance (178917), referenced zusatz (177270), and package (P1000320) // Should include: normal insurance (178917), both complementary insurances (177270, 999999), and package (P1000320)
// Should exclude: unreferenced zusatz (999999) $this->assertCount(4, $insurances);
$this->assertCount(3, $insurances);
$this->assertArrayHasKey(178917, $insurances); $this->assertArrayHasKey(178917, $insurances);
$this->assertArrayHasKey(177270, $insurances); $this->assertArrayHasKey(177270, $insurances);
$this->assertArrayHasKey(999999, $insurances);
$this->assertArrayHasKey('P1000320', $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
} }
} }