From d7929dd8556650c6d94dab6364d37700ddd4b02e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 2 Oct 2025 12:11:23 +0200 Subject: [PATCH] wip: improved insurance selection --- src/BusProNet/Model/Travel.php | 10 ++-- src/BusProNet/Traits/SortByPriceTrait.php | 46 +++++++++++++++++++ src/BusProNet/XmlParser/InsuranceParser.php | 39 ++++++++++++++-- .../ParticipantFieldOptionsProvider.php | 5 -- src/Service/InsuranceMatchingService.php | 6 ++- src/Service/ParticipantEligibilityService.php | 2 +- 6 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 src/BusProNet/Traits/SortByPriceTrait.php diff --git a/src/BusProNet/Model/Travel.php b/src/BusProNet/Model/Travel.php index 1463a30..92d7c67 100644 --- a/src/BusProNet/Model/Travel.php +++ b/src/BusProNet/Model/Travel.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\BusProNet\Model; use App\BusProNet\Constants; +use App\BusProNet\Traits\SortByPriceTrait; use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; @@ -18,6 +19,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; */ class Travel { + use SortByPriceTrait; #[Groups(['api:list', 'api:single'])] public ?int $id = null; @@ -98,7 +100,7 @@ class Travel * Filters additional services based on the provided subtype(s), availability, * and optionally whether their date range overlaps with the travel dates. * Services with null dates are considered always available when date filtering is enabled. - * Services are sorted alphabetically by label. + * Services are sorted by price in ascending order. * * @param mixed $subTypes The service subtype(s) to filter by * @param bool $filterAvailable Whether to include only available services @@ -135,11 +137,7 @@ class Travel return true; }); - usort($services, function (Service $a, Service $b) { - return $a->label <=> $b->label; - }); - - return $services; + return $this->sortByPrice($services); } /** diff --git a/src/BusProNet/Traits/SortByPriceTrait.php b/src/BusProNet/Traits/SortByPriceTrait.php new file mode 100644 index 0000000..1e691e4 --- /dev/null +++ b/src/BusProNet/Traits/SortByPriceTrait.php @@ -0,0 +1,46 @@ +price ?? 0; + $priceB = $b->price ?? 0; + + return $priceA <=> $priceB; + }); + + return $items; + } +} diff --git a/src/BusProNet/XmlParser/InsuranceParser.php b/src/BusProNet/XmlParser/InsuranceParser.php index b479d01..7ae5051 100644 --- a/src/BusProNet/XmlParser/InsuranceParser.php +++ b/src/BusProNet/XmlParser/InsuranceParser.php @@ -34,15 +34,16 @@ class InsuranceParser extends AbstractParser $xmlContent->filterXPath('//versicherungen/versicherung') ->each(function (Crawler $node) use (&$individualInsurances, &$insurances, $referencedIds) { $id = (int) $node->attr('idbuspro'); // Individual insurances have int IDs - $isAdditional = $this->getBoolAttributeValue($node->attr('zusatzversicherung')); + $isComplementary = $this->getBoolAttributeValue($node->attr('zusatzversicherung')); // 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)) { + // 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; } } @@ -101,7 +102,7 @@ class InsuranceParser extends AbstractParser $idValue = $node->attr('idbuspro'); $insurance->id = $isPackage ? $idValue : (int) $idValue; $insurance->code = $node->attr('code'); - $insurance->label = $node->attr('bezeichnung'); + $insurance->label = $this->normalizeInsuranceLabel($node->attr('bezeichnung')); $insurance->subType = $node->attr('unterart'); $insurance->price = $node->attr('preis') ? $this->stringToFloat($node->attr('preis')) : null; @@ -183,6 +184,36 @@ class InsuranceParser extends AbstractParser return $ids; } + /** + * Normalizes insurance labels by removing redundant phrases and replacing abbreviations. + * + * Transformations: + * - Removes "Auto/Bahn/Bus (Europa)" - redundant travel mode specification + * - Replaces "FAM" with "Familie" - clearer German terminology + * + * @param string|null $label The original insurance label from XML + * + * @return string|null The normalized label, or null if input was null + */ + private function normalizeInsuranceLabel(?string $label): ?string + { + if (null === $label) { + return null; + } + + // Remove redundant travel mode specification + $label = str_replace('Auto/Bahn/Bus (Europa)', '', $label); + + // Replace abbreviation with full German word + $label = str_replace('FAM', 'Familie', $label); + + // Clean up extra spaces that may result from removals + $label = preg_replace('/\s+/', ' ', $label); + $label = trim($label); + + return $label; + } + private function getBoolAttributeValue(?string $value): bool { return !empty($value) && $this->stringToBool($value); diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 273ec4f..39939a2 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -762,11 +762,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $label .= sprintf(' (€%s)', number_format($insurance->price, 2, ',', '.')); } - // Add type information if available using centralized mapping - if (null !== $insurance->subType && isset(Constants::INSURANCE_LABELS[$insurance->subType])) { - $label .= sprintf(' (%s)', Constants::INSURANCE_LABELS[$insurance->subType]); - } - return $label; } } diff --git a/src/Service/InsuranceMatchingService.php b/src/Service/InsuranceMatchingService.php index 6bce729..0f39068 100644 --- a/src/Service/InsuranceMatchingService.php +++ b/src/Service/InsuranceMatchingService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Service; use App\BusProNet\Model\Insurance; +use App\BusProNet\Traits\SortByPriceTrait; use App\Form\Model\BookingCreateDto; use App\Form\Model\ParticipantDto; use App\Model\InsuranceEligibilityCriteria; @@ -19,6 +20,7 @@ use Carbon\Carbon; */ class InsuranceMatchingService { + use SortByPriceTrait; public function __construct( private readonly BookingPriceCalculatorService $priceCalculatorService, ) { @@ -41,10 +43,12 @@ class InsuranceMatchingService return []; // Cannot match insurances without travel dates } - return array_filter( + $eligibleInsurances = array_filter( $insurances, fn (Insurance $insurance) => $this->isInsuranceEligible($insurance, $criteria) ); + + return $this->sortByPrice($eligibleInsurances); } /** diff --git a/src/Service/ParticipantEligibilityService.php b/src/Service/ParticipantEligibilityService.php index e334dac..0e8fa08 100644 --- a/src/Service/ParticipantEligibilityService.php +++ b/src/Service/ParticipantEligibilityService.php @@ -81,7 +81,7 @@ class ParticipantEligibilityService $travelStartDate = CarbonImmutable::instance($bookingDto->travel->dateFrom); $birthDate = CarbonImmutable::instance($participant->dateOfBirth); - $ageAtTravelStart = $travelStartDate->diffInYears($birthDate); + $ageAtTravelStart = $birthDate->diffInYears($travelStartDate); $birthYear = (int) $birthDate->format('Y'); $constraintType = $service->ageConstraintType ?? 'absolute_age';