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;
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);
}
/**
+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')
->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);
@@ -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;
}
}
+5 -1
View File
@@ -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);
}
/**
@@ -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';