wip: improved insurance selection

This commit is contained in:
Björn Fromme
2025-10-02 12:11:23 +02:00
parent 8a495346d6
commit d7929dd855
6 changed files with 91 additions and 17 deletions
+4 -6
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\BusProNet\Model; namespace App\BusProNet\Model;
use App\BusProNet\Constants; use App\BusProNet\Constants;
use App\BusProNet\Traits\SortByPriceTrait;
use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
@@ -18,6 +19,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
*/ */
class Travel class Travel
{ {
use SortByPriceTrait;
#[Groups(['api:list', 'api:single'])] #[Groups(['api:list', 'api:single'])]
public ?int $id = null; public ?int $id = null;
@@ -98,7 +100,7 @@ class Travel
* Filters additional services based on the provided subtype(s), availability, * Filters additional services based on the provided subtype(s), availability,
* and optionally whether their date range overlaps with the travel dates. * 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 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 mixed $subTypes The service subtype(s) to filter by
* @param bool $filterAvailable Whether to include only available services * @param bool $filterAvailable Whether to include only available services
@@ -135,11 +137,7 @@ class Travel
return true; return true;
}); });
usort($services, function (Service $a, Service $b) { return $this->sortByPrice($services);
return $a->label <=> $b->label;
});
return $services;
} }
/** /**
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Traits;
/**
* Provides generic price-based sorting functionality for services and insurances.
*
* This trait implements consistent ascending price sorting across the application
* for any objects that have a price property. Null prices are treated as zero,
* ensuring items without pricing information appear first in sorted results.
*
* Usage:
* - Services (skipasses, courses, rentals, board options, etc.)
* - Insurances (individual and package insurances)
*
* Excluded from price sorting:
* - Transportation services (sorted by subType)
* - Pickup locations (no specific sorting)
*/
trait SortByPriceTrait
{
/**
* Sorts an array of objects by their price property in ascending order.
*
* This method provides a consistent sorting strategy across all price-based
* collections in the application. Items are sorted from lowest to highest price,
* with null prices being treated as zero (appearing first).
*
* @param array $items Array of objects with a price property to sort
*
* @return array The sorted array of objects (ascending by price)
*/
protected function sortByPrice(array $items): array
{
usort($items, function (object $a, object $b) {
$priceA = $a->price ?? 0;
$priceB = $b->price ?? 0;
return $priceA <=> $priceB;
});
return $items;
}
}
+35 -4
View File
@@ -34,15 +34,16 @@ class InsuranceParser extends AbstractParser
$xmlContent->filterXPath('//versicherungen/versicherung') $xmlContent->filterXPath('//versicherungen/versicherung')
->each(function (Crawler $node) use (&$individualInsurances, &$insurances, $referencedIds) { ->each(function (Crawler $node) use (&$individualInsurances, &$insurances, $referencedIds) {
$id = (int) $node->attr('idbuspro'); // Individual insurances have int IDs $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 // 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) {
$individualInsurances[$insurance->id] = $insurance; $individualInsurances[$insurance->id] = $insurance;
// Include if: not additional insurance OR referenced by package // Include only if it's NOT a complementary insurance (zusatzversicherung)
if (!$isAdditional || in_array($id, $referencedIds, true)) { // Complementary insurances are only available as part of packages, never standalone
if (!$isComplementary) {
$insurances[$insurance->id] = $insurance; $insurances[$insurance->id] = $insurance;
} }
} }
@@ -101,7 +102,7 @@ class InsuranceParser extends AbstractParser
$idValue = $node->attr('idbuspro'); $idValue = $node->attr('idbuspro');
$insurance->id = $isPackage ? $idValue : (int) $idValue; $insurance->id = $isPackage ? $idValue : (int) $idValue;
$insurance->code = $node->attr('code'); $insurance->code = $node->attr('code');
$insurance->label = $node->attr('bezeichnung'); $insurance->label = $this->normalizeInsuranceLabel($node->attr('bezeichnung'));
$insurance->subType = $node->attr('unterart'); $insurance->subType = $node->attr('unterart');
$insurance->price = $node->attr('preis') ? $insurance->price = $node->attr('preis') ?
$this->stringToFloat($node->attr('preis')) : null; $this->stringToFloat($node->attr('preis')) : null;
@@ -183,6 +184,36 @@ class InsuranceParser extends AbstractParser
return $ids; 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 private function getBoolAttributeValue(?string $value): bool
{ {
return !empty($value) && $this->stringToBool($value); return !empty($value) && $this->stringToBool($value);
@@ -762,11 +762,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$label .= sprintf(' (€%s)', number_format($insurance->price, 2, ',', '.')); $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; return $label;
} }
} }
+5 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Insurance;
use App\BusProNet\Traits\SortByPriceTrait;
use App\Form\Model\BookingCreateDto; use App\Form\Model\BookingCreateDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use App\Model\InsuranceEligibilityCriteria; use App\Model\InsuranceEligibilityCriteria;
@@ -19,6 +20,7 @@ use Carbon\Carbon;
*/ */
class InsuranceMatchingService class InsuranceMatchingService
{ {
use SortByPriceTrait;
public function __construct( public function __construct(
private readonly BookingPriceCalculatorService $priceCalculatorService, private readonly BookingPriceCalculatorService $priceCalculatorService,
) { ) {
@@ -41,10 +43,12 @@ class InsuranceMatchingService
return []; // Cannot match insurances without travel dates return []; // Cannot match insurances without travel dates
} }
return array_filter( $eligibleInsurances = array_filter(
$insurances, $insurances,
fn (Insurance $insurance) => $this->isInsuranceEligible($insurance, $criteria) fn (Insurance $insurance) => $this->isInsuranceEligible($insurance, $criteria)
); );
return $this->sortByPrice($eligibleInsurances);
} }
/** /**
@@ -81,7 +81,7 @@ class ParticipantEligibilityService
$travelStartDate = CarbonImmutable::instance($bookingDto->travel->dateFrom); $travelStartDate = CarbonImmutable::instance($bookingDto->travel->dateFrom);
$birthDate = CarbonImmutable::instance($participant->dateOfBirth); $birthDate = CarbonImmutable::instance($participant->dateOfBirth);
$ageAtTravelStart = $travelStartDate->diffInYears($birthDate); $ageAtTravelStart = $birthDate->diffInYears($travelStartDate);
$birthYear = (int) $birthDate->format('Y'); $birthYear = (int) $birthDate->format('Y');
$constraintType = $service->ageConstraintType ?? 'absolute_age'; $constraintType = $service->ageConstraintType ?? 'absolute_age';