wip: insurance booking

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 8af7f0e797
commit 4ba81b8f03
19 changed files with 807 additions and 128 deletions
+7 -3
View File
@@ -67,11 +67,14 @@ services:
arguments:
$choiceListFactory: '@form.choice_list_factory.default'
# Participant Field Handler Registry - all handlers now instantiate dependencies directly
# Insurance Field Handler with dependencies
App\Form\Service\ParticipantInsuranceFieldHandler: ~
# Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services
App\Form\Service\ParticipantFieldHandlerRegistry:
arguments:
$handlers:
# All handlers now have no external dependencies and can be instantiated directly
# Simple handlers with no external dependencies - instantiated directly
- 'App\Form\Service\ParticipantDateOfBirthFieldHandler'
- 'App\Form\Service\ParticipantAssignedRoomFieldHandler'
- 'App\Form\Service\ParticipantRemarksRoomFieldHandler'
@@ -87,4 +90,5 @@ services:
- 'App\Form\Service\ParticipantParkingFieldHandler'
- 'App\Form\Service\ParticipantRentalInsuranceFieldHandler'
- 'App\Form\Service\ParticipantLicensePlateFieldHandler'
- 'App\Form\Service\ParticipantInsuranceFieldHandler'
# Complex handlers with dependencies - use service references
- '@App\Form\Service\ParticipantInsuranceFieldHandler'
+172
View File
@@ -0,0 +1,172 @@
# Family Booking Detection Issue
## Problem Description
The current family booking detection logic in `BookingCreateDto::isFamilyBooking()` has a critical flaw that causes incorrect classification of booking types, leading to wrong insurance options being displayed.
### Current Logic (FLAWED)
```php
// Current implementation in BookingCreateDto::isFamilyBooking()
$adults = 0; // Count of participants >= 18 years
$youngPeople = 0; // Count of participants <= 20 years
foreach ($this->participants as $participant) {
$age = $participant->getAge($travelStartDate);
if ($age >= 18) {
++$adults;
}
if ($age <= 20) {
++$youngPeople;
}
}
$isFamily = ($adults >= 1 && $adults <= 2) && ($youngPeople >= 1);
```
### The Problem
**Overlapping Age Ranges**: The current logic creates overlapping age categories:
- **Adults**: ≥18 years
- **Young people**: ≤20 years
This means participants aged 18-20 are counted as **BOTH** adults AND young people, causing incorrect family booking detection.
### Example Scenario
**Booking with 2 participants:**
- **Participant 1**: Born 1980 (age 44 at travel time)
- **Participant 2**: Born 2000 (age 24 at travel time)
**Current logic result:**
- `adults = 2` (both participants ≥18)
- `youngPeople = 1` (the 24-year-old ≤20)
- `isFamily = (2 >= 1 && 2 <= 2) && (1 >= 1) = true`
**Expected result:** This should be classified as an **individual/couple booking**, not a family booking.
## Impact
1. **Wrong insurance options**: Family insurances are shown for individual bookings
2. **User confusion**: Customers see inappropriate insurance options
3. **Business logic errors**: Pricing and eligibility calculations are incorrect
## Suggested Solutions
### Option 1: Non-Overlapping Age Ranges (Recommended)
```php
// Suggested implementation
$adults = 0; // Count of participants >= 18 years
$children = 0; // Count of participants < 18 years
foreach ($this->participants as $participant) {
$age = $participant->getAge($travelStartDate);
if ($age >= 18) {
++$adults;
} else {
++$children;
}
}
$isFamily = ($adults >= 1) && ($children >= 1);
```
**Benefits:**
- No overlapping age ranges
- Clear distinction between adults and children
- Matches insurance industry standards
### Option 2: Insurance-Specific Age Ranges
```php
// Alternative implementation based on insurance requirements
$adults = 0; // Count of participants >= 18 years
$minors = 0; // Count of participants < 18 years
foreach ($this->participants as $participant) {
$age = $participant->getAge($travelStartDate);
if ($age >= 18) {
++$adults;
} elseif ($age < 18) {
++$minors;
}
// Note: 18+ year olds are not counted as minors
}
$isFamily = ($adults >= 1) && ($minors >= 1);
```
### Option 3: Configurable Age Thresholds
```php
// More flexible approach with configurable thresholds
private const ADULT_AGE_THRESHOLD = 18;
private const CHILD_AGE_THRESHOLD = 18; // Same as adult threshold for non-overlap
$adults = 0;
$children = 0;
foreach ($this->participants as $participant) {
$age = $participant->getAge($travelStartDate);
if ($age >= self::ADULT_AGE_THRESHOLD) {
++$adults;
} elseif ($age < self::CHILD_AGE_THRESHOLD) {
++$children;
}
}
$isFamily = ($adults >= 1) && ($children >= 1);
```
## Business Rules to Clarify
Before implementing a solution, the following business rules need to be clarified:
1. **What defines a "family booking"?**
- Must have at least 1 adult (≥18) and at least 1 child (<18)?
- Or can it be 2 adults with children?
- Or any booking with children regardless of adult count?
2. **Age thresholds:**
- Should 18-year-olds be considered adults or children?
- Are there different rules for different types of services?
3. **Edge cases:**
- What about bookings with only adults (couples)?
- What about bookings with only children (group bookings)?
## Implementation Notes
- The fix should be implemented in `src/Form/Model/BookingCreateDto.php`
- Update the `isFamilyBooking()` method
- Add comprehensive unit tests for edge cases
- Consider adding configuration options for age thresholds
- Update documentation to reflect the new business rules
## Testing Scenarios
After implementation, test these scenarios:
1. **Single adult** (should be individual booking)
2. **Two adults** (should be couple booking, not family)
3. **One adult + one child** (should be family booking)
4. **Two adults + one child** (should be family booking)
5. **Only children** (edge case - clarify business rule)
6. **18-year-old participant** (edge case - clarify classification)
## Related Files
- `src/Form/Model/BookingCreateDto.php` - Main implementation
- `src/Service/InsuranceMatchingService.php` - Uses family booking detection
- `tests/Form/Model/BookingCreateDtoTest.php` - Unit tests (to be updated)
+6
View File
@@ -16,8 +16,14 @@ final class Constants
public const TOKEN_BOARD = 'VPF';
public const TOKEN_RENTALS = ['VER', 'VE2', 'VE3', 'VE4', 'VE5', 'VE6', 'VE7', 'VE8'];
public const TOKEN_RENTAL_INSURANCE = 'LVS';
public const TOKEN_INSURANCES = ['RRV', 'PAK', 'OHN', 'PKG'];
public const TOKEN_PARKING = 'PAR';
// Normalized service group keys for pricing display
public const GROUP_TRANSPORTATION = 'transportation';
public const GROUP_RENTALS = 'rentals';
public const GROUP_INSURANCE = 'insurance';
public const STATUS_AVAILABLE = 'Frei';
public const STATUS_BLOCKED = 'Buchungsstop';
public const STATUS_ON_REQUEST = 'Anfrage';
+19
View File
@@ -91,4 +91,23 @@ class Insurance
*/
#[Groups(['api:single', 'api:list'])]
public array $containedInsuranceIds = [];
public function __toString(): string
{
return (string) $this->id;
}
/**
* Returns the subType, with virtual 'PKG' subType for insurance packages.
*
* @return string|null The subType (RRV, PAK, OHN, PKG) or null
*/
public function getSubType(): ?string
{
if (true === $this->package) {
return 'PKG'; // Virtual subType for all insurance packages
}
return $this->subType;
}
}
+2 -2
View File
@@ -15,8 +15,8 @@ use Symfony\Contracts\Cache\ItemInterface;
class InsuranceLoader extends AbstractLoader
{
public function __construct(
protected readonly CacheInterface $cache,
protected readonly FilesystemOperator $xmlExport,
CacheInterface $cache,
FilesystemOperator $xmlExport,
private readonly InsuranceParser $insuranceParser,
) {
parent::__construct($cache, $xmlExport);
+49 -14
View File
@@ -19,6 +19,7 @@ class InsuranceParser extends AbstractParser
* Parses insurance XML nodes into Insurance objects.
*
* @param Crawler $xmlContent The XML crawler containing insurance data
*
* @return array<string|int, Insurance> Array of Insurance objects indexed by id
*/
public function parse(Crawler $xmlContent): array
@@ -28,25 +29,29 @@ class InsuranceParser extends AbstractParser
// First pass: collect referenced insurance IDs from packages
$referencedIds = $this->collectReferencedInsuranceIds($xmlContent);
// Parse individual insurances with conditional filtering
// Second pass: Parse individual insurances with conditional filtering
$individualInsurances = [];
$xmlContent->filterXPath('//versicherungen/versicherung')
->each(function (Crawler $node) use (&$insurances, $referencedIds) {
->each(function (Crawler $node) use (&$individualInsurances, &$insurances, $referencedIds) {
$id = (int) $node->attr('idbuspro'); // Individual insurances have int IDs
$isZusatz = $this->getBoolAttributeValue($node->attr('zusatzversicherung'));
$isAdditional = $this->getBoolAttributeValue($node->attr('zusatzversicherung'));
// Include if: not zusatzversicherung OR referenced by package
if (!$isZusatz || in_array($id, $referencedIds, true)) {
$insurance = $this->parseInsuranceNode($node, false);
if (null !== $insurance && null !== $insurance->id) {
// 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)) {
$insurances[$insurance->id] = $insurance;
}
}
});
// Parse packages (no filtering needed)
// Third pass: Parse packages with family detection based on contained insurances
$xmlContent->filterXPath('//versicherungspakete/versicherungspaket')
->each(function (Crawler $node) use (&$insurances) {
$insurance = $this->parseInsuranceNode($node, true);
->each(function (Crawler $node) use (&$insurances, $individualInsurances) {
$insurance = $this->parseInsuranceNode($node, true, $individualInsurances);
if (null !== $insurance && null !== $insurance->id) {
// Parse contained insurance IDs
$insurance->containedInsuranceIds = $this->parseContainedInsuranceIds($node);
@@ -57,14 +62,37 @@ class InsuranceParser extends AbstractParser
return $insurances;
}
/**
* Determines if a package contains any family insurances.
*
* @param Crawler $packageNode The package XML node
* @param array<int, Insurance> $individualInsurances Parsed individual insurances for reference
*
* @return bool True if any contained insurance is a family insurance
*/
private function isPackageFamilyInsurance(Crawler $packageNode, array $individualInsurances): bool
{
// Check if any contained insurance is a family insurance
$containedInsuranceIds = $this->parseContainedInsuranceIds($packageNode);
foreach ($containedInsuranceIds as $containedId) {
if (isset($individualInsurances[$containedId]) && true === $individualInsurances[$containedId]->familyInsurance) {
return true;
}
}
return false;
}
/**
* Parses a single insurance or package node.
*
* @param Crawler $node The XML node to parse
* @param bool $isPackage Whether this is a package node
* @param Crawler $node The XML node to parse
* @param bool $isPackage Whether this is a package node
* @param array<int, Insurance> $individualInsurances Individual insurances for package family detection
*
* @return Insurance|null The parsed insurance object
*/
private function parseInsuranceNode(Crawler $node, bool $isPackage): ?Insurance
private function parseInsuranceNode(Crawler $node, bool $isPackage, array $individualInsurances = []): ?Insurance
{
$insurance = new Insurance();
$insurance->package = $isPackage;
@@ -79,6 +107,11 @@ class InsuranceParser extends AbstractParser
$this->stringToFloat($node->attr('preis')) : null;
$insurance->familyInsurance = $this->stringToBool($node->attr('familienversicherung'));
// For packages, check if any contained insurances are family insurances
if ($isPackage && !$insurance->familyInsurance && !empty($individualInsurances)) {
$insurance->familyInsurance = $this->isPackageFamilyInsurance($node, $individualInsurances);
}
// Date constraints
$insurance->travelDateFrom = $node->attr('reisedatumvon') ?
$this->stringToDate($node->attr('reisedatumvon')) : null;
@@ -115,6 +148,7 @@ class InsuranceParser extends AbstractParser
* Collects all insurance IDs referenced by packages.
*
* @param Crawler $xmlContent The XML content to scan
*
* @return array<int> Array of referenced insurance IDs
*/
private function collectReferencedInsuranceIds(Crawler $xmlContent): array
@@ -135,6 +169,7 @@ class InsuranceParser extends AbstractParser
* Parses contained insurance IDs from a package node.
*
* @param Crawler $node The package XML node
*
* @return array<int> Array of contained insurance IDs (always int, as they reference individual insurances)
*/
private function parseContainedInsuranceIds(Crawler $node): array
@@ -152,4 +187,4 @@ class InsuranceParser extends AbstractParser
{
return !empty($value) && $this->stringToBool($value);
}
}
}
@@ -124,12 +124,6 @@ class CreateStep2Controller extends AbstractController
// Pre-select mandatory services after form processing but before pricing calculation
$this->bookingService->preselectMandatoryServices($bookingCreateDto);
// Recreate the form with the updated DTO that includes mandatory services
$form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [
'attr' => ['novalidate' => 'novalidate'],
'validation_groups' => false,
]);
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
+7 -1
View File
@@ -181,6 +181,7 @@ class BookingCreateParticipantType extends AbstractType
'pickupInbound',
'parking',
'licensePlate',
'insurance',
];
foreach ($dynamicFields as $fieldName) {
@@ -212,7 +213,11 @@ class BookingCreateParticipantType extends AbstractType
$this->addBaseFields($form, $bookingDto, $participantIndex);
// Rebuild dynamic fields
$dynamicFields = ['assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'rentalInsurance', 'skiPass', 'transportationOutbound', 'transportationInbound', 'pickupOutbound', 'pickupInbound', 'parking', 'licensePlate'];
$dynamicFields = [
'assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'rentalInsurance',
'skiPass', 'transportationOutbound', 'transportationInbound', 'pickupOutbound', 'pickupInbound', 'parking',
'licensePlate', 'insurance',
];
foreach ($dynamicFields as $fieldName) {
if ($form->has($fieldName)) {
$form->remove($fieldName);
@@ -243,6 +248,7 @@ class BookingCreateParticipantType extends AbstractType
'pickupInbound' => ChoiceType::class,
'parking' => CheckboxType::class,
'licensePlate' => TextType::class,
'insurance' => ChoiceType::class,
];
foreach ($dynamicFields as $fieldName => $fieldType) {
+10 -10
View File
@@ -55,18 +55,21 @@ class BookingCreateDto implements BookingDtoInterface
* Determines if this is a family booking based on participant age distribution.
*
* A family booking is defined as:
* - 1-2 participants aged 18 or older (adults)
* - At least 1 participant aged 20 or younger (young people/children)
* - 1 or 2 participants aged 18 or older (adults)
* - At least 1 participant younger than 18 (children)
*
* @return bool True if this qualifies as a family booking
*/
public function isFamilyBooking(): bool
{
$adults = 0; // Count of participants >= 18 years
$youngPeople = 0; // Count of participants <= 20 years
$children = 0; // Count of participants < 18 years
// Use travel start date for age calculation
$travelStartDate = $this->travel->dateFrom;
foreach ($this->participants as $participant) {
$age = $participant->getAge();
$age = $participant->getAge($travelStartDate);
if (null === $age) {
continue; // Skip participants without birth date
@@ -74,15 +77,12 @@ class BookingCreateDto implements BookingDtoInterface
if ($age >= 18) {
++$adults;
}
if ($age <= 20) {
++$youngPeople;
} else {
++$children;
}
}
// Check family booking criteria
return ($adults >= 1 && $adults <= 2) && ($youngPeople >= 1);
return ($adults >= 1 && $adults <= 2) && ($children >= 1);
}
#[Assert\Callback(callback: 'validateRoomSelection', groups: ['booking_create_step_1'])]
@@ -87,12 +87,17 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
];
// Hide insurance field until date of birth is provided
$this->fieldStateConditions['insurance'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
];
// Room-specific field conditions
$mbzRoomCondition = new RoomSelectionCondition(['mbz']);
$sharedRoomCondition = new RoomSelectionCondition(['mbz']);
// Show remarks room field only when room with code 'mbz' is selected
$this->fieldStateConditions['remarksRoom'] = [
'hidden' => CompositeCondition::not($mbzRoomCondition),
'hidden' => CompositeCondition::not($sharedRoomCondition),
];
// Transportation-related field conditions
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
@@ -12,7 +13,10 @@ use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractFieldOptionsProvider;
use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory;
use App\Form\Service\ServiceAgeEvaluator;
use App\Service\InsuranceMatchingService;
use App\Service\ServiceAvailabilityCalculator;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Provides dynamic field options for participant form fields.
@@ -43,10 +47,14 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
*
* @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders
* @param ServiceAvailabilityCalculator $serviceAvailabilityCalculator Service for calculating dynamic availability
* @param InsuranceMatchingService $insuranceMatchingService Service for matching insurances to participants
* @param UrlGeneratorInterface $urlGenerator URL generator for HTMX endpoints
*/
public function __construct(
private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory,
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
private readonly InsuranceMatchingService $insuranceMatchingService,
private readonly UrlGeneratorInterface $urlGenerator,
) {
parent::__construct();
}
@@ -329,8 +337,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $attributes;
},
'attr' => [
'hx-post' => '#', // Will be configured when HTMX integration is implemented
'hx-target' => '#booking-summary',
'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
'hx-swap' => 'none',
'hx-trigger' => 'change',
],
];
@@ -360,8 +368,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $attributes;
},
'attr' => [
'hx-post' => '#', // Will be configured when HTMX integration is implemented
'hx-target' => '#booking-summary',
'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
'hx-swap' => 'none',
'hx-trigger' => 'change',
],
];
@@ -397,6 +405,26 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'required' => false,
];
// Insurance field provider - provides age and eligibility filtered insurances for participants
$this->fieldOptionProviders['insurance'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Reiseversicherung',
'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',
'help' => 'Wählen Sie eine passende Reiseversicherung für diese Person aus.',
'attr' => [
'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
'hx-swap' => 'none',
'hx-trigger' => 'change',
],
];
// Future field providers would be added here, for example:
//
// $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [
@@ -672,4 +700,68 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $rentalInsuranceService->description;
}
/**
* Gets eligible insurances for a participant based on eligibility criteria.
*
* @param BookingDtoInterface $bookingDto The booking DTO containing travel and participant data
* @param int $participantIndex The index of the participant to get eligible insurances for
*
* @return array Array of eligible insurance objects filtered by age, family status, and other constraints
*/
private function getEligibleInsurances(BookingDtoInterface $bookingDto, int $participantIndex): array
{
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
return [];
}
$availableInsurances = $bookingDto->travel->insurances ?? [];
// Only apply insurance filtering for BookingCreateDto (creation workflow)
if (!$bookingDto instanceof BookingCreateDto) {
return $availableInsurances;
}
// Use insurance matching service to filter based on eligibility criteria
return $this->insuranceMatchingService->getEligibleInsurances(
$availableInsurances,
$participant,
$bookingDto
);
}
/**
* Formats insurance label with pricing and type information.
*
* @param Insurance|null $insurance The insurance to format, or null for "No Insurance" option
*
* @return string The formatted insurance label
*/
private function formatInsuranceLabel(?Insurance $insurance): string
{
if (null === $insurance) {
return 'Keine Versicherung';
}
$label = $insurance->label;
// Add pricing information (consistent with other services)
if (null !== $insurance->price && $insurance->price > 0) {
$label .= sprintf(' (€%s)', number_format($insurance->price, 2, ',', '.'));
}
// Add type information if available
if (null !== $insurance->subType) {
$typeLabel = match ($insurance->subType) {
'RRV' => 'Reiserücktrittsversicherung',
'PAK' => 'Reiseschutz',
'OHN' => 'Selbstbehalt',
default => $insurance->subType,
};
$label .= sprintf(' (%s)', $typeLabel);
}
return $label;
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Insurance;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\InsuranceMatchingService;
@@ -13,19 +14,28 @@ use App\Service\InsuranceMatchingService;
* Handles processing of the insurance field for booking participants.
*
* This handler manages insurance selections for individual participants in the booking
* creation process. It processes the insurance field from form submissions,
* validates the selection against participant eligibility criteria, and updates
* the participant DTO with the valid insurance selection.
* creation process. It processes insurance field from form submissions, validates
* selections against participant eligibility criteria, and provides automatic
* reassignment when participant pricing changes.
*
* The insurance field depends on dateOfBirth for age-based eligibility calculations
* and uses the InsuranceMatchingService to ensure only eligible insurances can be selected.
* Key Features:
* - Validates submitted insurance selections against eligibility criteria
* - Automatically reassigns insurances when travel price changes make current selection invalid
* - Preserves user intent by maintaining same insurance type (subType + familyInsurance)
* - Uses InsuranceMatchingService for eligibility filtering and reassignment logic
*
* Auto-Reassignment Logic:
* When a participant's individual travel price changes (e.g., by adding services),
* their current insurance may no longer be eligible for the new price range.
* Instead of clearing the selection, this handler automatically reassigns
* to the same insurance type with the appropriate price tier.
*
* Dependencies: dateOfBirth (for age evaluation and insurance eligibility)
*/
class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly InsuranceMatchingService $insuranceMatchingService,
private readonly InsuranceMatchingService $insuranceMatchingService
) {
}
@@ -71,10 +81,9 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
/**
* Processes the insurance field for a specific participant.
*
* This method extracts the insurance selection from submitted form data,
* validates the selection against the participant's eligibility criteria,
* and updates the participant DTO with the valid selection. If the insurance
* is no longer appropriate for the participant, it is automatically cleared.
* This method handles both explicit insurance selection from form data and automatic
* reassignment when the participant's travel price changes. It maintains the same
* insurance type but adjusts to the appropriate price tier when needed.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit)
@@ -83,38 +92,67 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
if (null === $participant || !$bookingDto instanceof BookingCreateDto) {
return;
}
$insuranceId = $submittedData['insurance'] ?? null;
// Clear insurance if no selection
if (null === $insuranceId || '' === $insuranceId) {
$participant->insurance = null;
return;
}
// Find the selected insurance from available travel insurances
$selectedInsuranceId = $this->getFieldValue($submittedData, $this->getFieldName());
$currentInsurance = $participant->insurance;
$availableInsurances = $bookingDto->travel->insurances ?? [];
$selectedInsurance = $this->findInsuranceById($availableInsurances, $insuranceId);
if (null === $selectedInsurance) {
$participant->insurance = null;
// Handle explicit insurance selection from form
if (null !== $selectedInsuranceId) {
$selectedInsurance = $this->findInsuranceById($availableInsurances, $selectedInsuranceId);
return;
// Check if the selected insurance is still eligible for this participant
if (null !== $selectedInsurance) {
$eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances($availableInsurances, $participant, $bookingDto);
$isSelectedInsuranceEligible = $this->isInsuranceInList($selectedInsurance, $eligibleInsurances);
if ($isSelectedInsuranceEligible) {
// Insurance is still eligible - use it directly
$participant->insurance = $selectedInsurance;
return;
} else {
// Insurance is no longer eligible - try to reassign to same type with new price tier
$reassignedInsurance = $this->insuranceMatchingService->reassignInsuranceForPriceChange(
$availableInsurances,
$selectedInsurance,
$participant,
$bookingDto
);
$participant->insurance = $reassignedInsurance;
return;
}
} else {
// Insurance not found - clear selection
$participant->insurance = null;
return;
}
}
// Validate insurance eligibility for this participant
$eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances(
[$selectedInsurance],
$participant,
$bookingDto
);
// Handle automatic reassignment if participant had an insurance but it's no longer eligible
if (null !== $currentInsurance) {
// Check if current insurance is still eligible
$eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances($availableInsurances, $participant, $bookingDto);
$isCurrentInsuranceStillEligible = $this->isInsuranceInList($currentInsurance, $eligibleInsurances);
// Set insurance only if it's eligible for this participant
$participant->insurance = !empty($eligibleInsurances) ? $selectedInsurance : null;
if (!$isCurrentInsuranceStillEligible) {
// Try to reassign to same insurance type with appropriate price tier
$reassignedInsurance = $this->insuranceMatchingService->reassignInsuranceForPriceChange(
$availableInsurances,
$currentInsurance,
$participant,
$bookingDto
);
$participant->insurance = $reassignedInsurance; // null if no suitable match found
return;
}
}
// No insurance selected or reassignment needed - keep current state (may be null)
}
/**
@@ -163,4 +201,24 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
return null;
}
/**
* Checks if a specific insurance exists in a list of insurances.
*
* @param Insurance $targetInsurance The insurance to find
* @param array<Insurance> $insuranceList The list to search in
*
* @return bool True if the insurance is found in the list
*/
private function isInsuranceInList(Insurance $targetInsurance, array $insuranceList): bool
{
foreach ($insuranceList as $insurance) {
if ($insurance->id === $targetInsurance->id || (string) $insurance->id === (string) $targetInsurance->id) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Model;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\ParticipantDto;
/**
* Value object containing criteria for insurance eligibility evaluation.
*
* Groups all the parameters needed to determine if an insurance product
* is eligible for a specific participant and booking scenario.
*/
readonly class InsuranceEligibilityCriteria
{
public function __construct(
public ParticipantDto $participant,
public \DateTimeImmutable $travelStartDate,
public \DateTimeImmutable $travelEndDate,
public \DateTimeImmutable $bookingDate,
public float $travelPrice,
public int $travelDurationDays,
public BookingCreateDto $booking,
) {
}
}
+88 -7
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingCreateDto;
@@ -238,6 +239,45 @@ class BookingPriceCalculatorService
return $participantPrices;
}
/**
* Calculates the total price for an individual participant excluding insurance.
*
* This method is used for insurance eligibility filtering to avoid circular dependency
* where insurance selection affects travel price which affects insurance eligibility.
*
* @param BookingDtoInterface $bookingDto The booking data containing all participants
* @param int $participantIndex The index of the participant to calculate for
*
* @return float The total price for the specified participant excluding insurance
*/
public function calculateIndividualParticipantPriceExcludingInsurance(BookingDtoInterface $bookingDto, int $participantIndex): float
{
if (false === $bookingDto instanceof BookingCreateDto) {
return 0.0;
}
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
return 0.0;
}
$totalPrice = 0.0;
// Add room price if participant is assigned to a room
if (null !== $participant->assignedRoomId) {
$room = $this->getRoomById($bookingDto, $participant->assignedRoomId);
if (null !== $room && null !== $room->price) {
// Each participant pays the full room price
$totalPrice += $room->price;
}
}
// Add service prices for this participant (excluding insurance)
$totalPrice += $this->calculateParticipantServiceTotal($participant, false);
return $totalPrice;
}
/**
* Aggregates all transportation-related services and pricing into separate line items.
*
@@ -311,7 +351,7 @@ class BookingPriceCalculatorService
'unitPrice' => null,
'participantCount' => $pickupParticipants,
'totalPrice' => $pickupTotal,
'subType' => 'transportation',
'subType' => Constants::GROUP_TRANSPORTATION,
];
}
@@ -323,7 +363,7 @@ class BookingPriceCalculatorService
'unitPrice' => null,
'participantCount' => $parkingParticipants,
'totalPrice' => $parkingTotal,
'subType' => 'transportation',
'subType' => Constants::GROUP_TRANSPORTATION,
];
}
@@ -356,7 +396,12 @@ class BookingPriceCalculatorService
// Normalize rental subtypes to avoid duplicate sections
if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) {
$subType = 'rentals'; // Normalize all rental subtypes to a single key
$subType = Constants::GROUP_RENTALS; // Normalize all rental subtypes to a single key
}
// Normalize insurance subtypes to avoid duplicate sections
if (true === in_array($subType, Constants::TOKEN_INSURANCES, true)) {
$subType = Constants::GROUP_INSURANCE; // Normalize all insurance subtypes to a single key
}
$isDiscount = $serviceData['totalPrice'] < 0;
@@ -405,8 +450,9 @@ class BookingPriceCalculatorService
Constants::TOKEN_ADDITIONAL => 'Zusatzleistungen',
Constants::TOKEN_BOARD => 'Verpflegung',
Constants::TOKEN_RENTAL_INSURANCE => 'Leihmaterial-Versicherung',
'transportation' => 'Beförderung',
'rentals' => 'Leihmaterial', // Normalized rental subtype
Constants::GROUP_TRANSPORTATION => 'Beförderung',
Constants::GROUP_RENTALS => 'Leihmaterial', // Normalized rental subtype
Constants::GROUP_INSURANCE => 'Reiseversicherungen', // Normalized insurance subtype
];
// Handle rentals array (keep for backward compatibility with non-normalized subtypes)
@@ -431,6 +477,10 @@ class BookingPriceCalculatorService
$this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance, 1);
}
if (null !== $participant->insurance && null !== $participant->insurance->price) {
$this->addInsuranceToServiceAggregation($serviceAggregation, $participant->insurance, 1);
}
// Handle multiple service selections
$multipleServiceArrays = [
'courses' => $participant->courses,
@@ -475,11 +525,12 @@ class BookingPriceCalculatorService
/**
* Calculates the total service cost for a single participant.
*
* @param ParticipantDto $participant The participant to calculate services for
* @param ParticipantDto $participant The participant to calculate services for
* @param bool $includeInsurance Whether to include insurance pricing (default: true)
*
* @return float The total service cost for this participant
*/
private function calculateParticipantServiceTotal(ParticipantDto $participant): float
private function calculateParticipantServiceTotal(ParticipantDto $participant, bool $includeInsurance = true): float
{
$serviceTotal = 0.0;
@@ -492,6 +543,10 @@ class BookingPriceCalculatorService
$serviceTotal += $participant->rentalInsurance->price;
}
if ($includeInsurance && null !== $participant->insurance && null !== $participant->insurance->price) {
$serviceTotal += $participant->insurance->price;
}
// Transportation services
if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) {
$serviceTotal += $participant->transportationOutbound->price;
@@ -551,4 +606,30 @@ class BookingPriceCalculatorService
return null;
}
/**
* Adds an insurance to the service aggregation array.
*
* @param array $serviceAggregation The service aggregation array to update
* @param Insurance $insurance The insurance to add
* @param int $quantity The quantity of the insurance
*/
private function addInsuranceToServiceAggregation(array &$serviceAggregation, Insurance $insurance, int $quantity): void
{
$serviceKey = $insurance->id.'_'.$insurance->label;
if (false === isset($serviceAggregation[$serviceKey])) {
$serviceAggregation[$serviceKey] = [
'serviceId' => $insurance->id,
'label' => $insurance->label,
'unitPrice' => $insurance->price,
'participantCount' => 0,
'totalPrice' => 0.0,
'subType' => $insurance->getSubType(),
];
}
$serviceAggregation[$serviceKey]['participantCount'] += $quantity;
$serviceAggregation[$serviceKey]['totalPrice'] += $insurance->price * $quantity;
}
}
+160 -43
View File
@@ -7,6 +7,9 @@ namespace App\Service;
use App\BusProNet\Model\Insurance;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\ParticipantDto;
use App\Model\InsuranceEligibilityCriteria;
use App\Service\BookingPriceCalculatorService;
use App\Service\InsuranceTypeResolver;
use Carbon\Carbon;
/**
@@ -18,6 +21,11 @@ use Carbon\Carbon;
*/
class InsuranceMatchingService
{
public function __construct(
private readonly BookingPriceCalculatorService $priceCalculatorService,
private readonly InsuranceTypeResolver $insuranceTypeResolver
) {
}
/**
* Filters insurances based on participant and booking criteria.
*
@@ -29,67 +37,151 @@ class InsuranceMatchingService
*/
public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingCreateDto $booking): array
{
$travelStartDate = $booking->travel->dateFrom;
$travelEndDate = $booking->travel->dateTo;
$criteria = $this->createEligibilityCriteria($participant, $booking);
// Cannot match insurances without travel dates
if (null === $travelStartDate || null === $travelEndDate) {
return [];
if (null === $criteria) {
return []; // Cannot match insurances without travel dates
}
$bookingDate = Carbon::now()->toDateTimeImmutable();
$travelPrice = $this->calculateTravelPrice($booking);
$travelDurationDays = $this->calculateTravelDurationDays($travelStartDate, $travelEndDate);
return array_filter($insurances, function (Insurance $insurance) use ($participant, $travelStartDate, $travelEndDate, $bookingDate, $travelPrice, $travelDurationDays) {
return $this->isInsuranceEligible($insurance, $participant, $travelStartDate, $travelEndDate, $bookingDate, $travelPrice, $travelDurationDays);
});
return array_filter(
$insurances,
fn (Insurance $insurance) => $this->isInsuranceEligible($insurance, $criteria)
);
}
/**
* Checks if a specific insurance is eligible for a participant.
* Auto-reassigns an insurance to the same type with appropriate price tier.
*
* @param Insurance $insurance The insurance to check
* @param ParticipantDto $participant The participant
* @param \DateTimeImmutable $travelStartDate Travel start date
* @param \DateTimeImmutable $travelEndDate Travel end date
* @param \DateTimeImmutable $bookingDate Booking date
* @param float $travelPrice Total travel price
* @param int $travelDurationDays Travel duration in days
* This method is used when a participant's individual price changes and their
* current insurance is no longer eligible. It finds the same insurance type
* (subType + familyInsurance) with the correct price tier.
*
* @return bool True if insurance is eligible
* @param array<Insurance> $availableInsurances All available insurances
* @param Insurance $currentInsurance The currently selected insurance
* @param ParticipantDto $participant The participant to reassign for
* @param BookingCreateDto $booking The booking context
*
* @return Insurance|null The reassigned insurance or null if no suitable match found
*/
private function isInsuranceEligible(
Insurance $insurance,
ParticipantDto $participant,
\DateTimeImmutable $travelStartDate,
\DateTimeImmutable $travelEndDate,
\DateTimeImmutable $bookingDate,
float $travelPrice,
int $travelDurationDays,
): bool {
public function reassignInsuranceForPriceChange(array $availableInsurances, Insurance $currentInsurance, ParticipantDto $participant, BookingCreateDto $booking): ?Insurance
{
// Group insurances of the same type (subType + familyInsurance)
$sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $currentInsurance->subType, $currentInsurance->familyInsurance);
// Get eligible insurances for this participant
$eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking);
// Return the first eligible insurance (they should all be equivalent for the same type)
return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
}
/**
* Batch-assigns insurances of the same type to all participants based on individual pricing.
*
* This method takes the applicant's insurance selection and assigns the same insurance type
* (subType + familyInsurance) to all participants, but selects the appropriate price tier
* based on each participant's individual travel price.
*
* @param array<Insurance> $availableInsurances All available insurances
* @param Insurance $selectedInsurance The insurance selected by the applicant
* @param BookingCreateDto $booking The booking with all participants
*
* @return array<int, Insurance|null> Array indexed by participant index with assigned insurances
*/
public function batchAssignInsuranceToParticipants(array $availableInsurances, Insurance $selectedInsurance, BookingCreateDto $booking): array
{
$assignments = [];
// Group insurances of the same type (subType + familyInsurance)
$sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $selectedInsurance->subType, $selectedInsurance->familyInsurance);
// Assign appropriate insurance to each participant
foreach ($booking->getParticipants() as $index => $participant) {
$eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking);
$assignments[$index] = !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
}
return $assignments;
}
/**
* Creates eligibility criteria from participant and booking data.
*/
private function createEligibilityCriteria(ParticipantDto $participant, BookingCreateDto $booking): ?InsuranceEligibilityCriteria
{
$travelStartDate = $booking->travel->dateFrom;
$travelEndDate = $booking->travel->dateTo;
if (null === $travelStartDate || null === $travelEndDate) {
return null; // Cannot create criteria without travel dates
}
return new InsuranceEligibilityCriteria(
participant: $participant,
travelStartDate: $travelStartDate,
travelEndDate: $travelEndDate,
bookingDate: Carbon::now()->toDateTimeImmutable(),
travelPrice: $this->calculateTravelPrice($booking, $participant->index),
travelDurationDays: $this->calculateTravelDurationDays($travelStartDate, $travelEndDate),
booking: $booking,
);
}
/**
* Checks if a specific insurance is eligible for given criteria.
*/
private function isInsuranceEligible(Insurance $insurance, InsuranceEligibilityCriteria $criteria): bool
{
// Family insurance constraints
if (false === $this->checkFamilyInsuranceConstraints($insurance, $criteria->booking)) {
return false;
}
// Age constraints
if (!$this->checkAgeConstraints($insurance, $participant, $travelStartDate)) {
if (false === $this->checkAgeConstraints($insurance, $criteria->participant, $criteria->travelStartDate)) {
return false;
}
// Travel date constraints
if (!$this->checkTravelDateConstraints($insurance, $travelStartDate, $travelEndDate)) {
if (false === $this->checkTravelDateConstraints($insurance, $criteria->travelStartDate, $criteria->travelEndDate)) {
return false;
}
// Booking date constraints
if (!$this->checkBookingDateConstraints($insurance, $bookingDate)) {
if (false === $this->checkBookingDateConstraints($insurance, $criteria->bookingDate)) {
return false;
}
// Travel price constraints
if (!$this->checkTravelPriceConstraints($insurance, $travelPrice)) {
if (false === $this->checkTravelPriceConstraints($insurance, $criteria->travelPrice)) {
return false;
}
// Travel duration constraints
if (!$this->checkTravelDurationConstraints($insurance, $travelDurationDays)) {
if (false === $this->checkTravelDurationConstraints($insurance, $criteria->travelDurationDays)) {
return false;
}
return true;
}
/**
* Checks if family insurance constraints are met.
*
* Family insurances should only be available for family bookings,
* and individual insurances should only be available for non-family bookings.
*/
private function checkFamilyInsuranceConstraints(Insurance $insurance, BookingCreateDto $booking): bool
{
$isFamilyBooking = $booking->isFamilyBooking();
// If it's a family insurance, it should only be available for family bookings
if (true === $insurance->familyInsurance && false === $isFamilyBooking) {
return false;
}
// If it's not a family insurance, it should only be available for non-family bookings
if (false === $insurance->familyInsurance && true === $isFamilyBooking) {
return false;
}
@@ -103,8 +195,9 @@ class InsuranceMatchingService
{
$participantAge = $participant->getAge($travelStartDate);
// If no birth date is provided, skip age constraints (field will be hidden via field state conditions)
if (null === $participantAge) {
return false; // Cannot determine age eligibility without birth date
return true;
}
// Check minimum age
@@ -197,17 +290,21 @@ class InsuranceMatchingService
}
/**
* Calculates the total travel price from booking data.
* Calculates the total travel price for a participant, excluding insurance prices.
*
* @param BookingCreateDto $booking The booking to calculate price for
* This method calculates the travel price used for insurance eligibility filtering.
* It excludes insurance prices to prevent circular dependency where insurance selection
* affects travel price which then affects insurance eligibility.
*
* @return float The total travel price
* @param BookingCreateDto $booking The booking to calculate price for
* @param int $participantIndex The participant index to calculate for
*
* @return float The total travel price for the participant excluding insurance
*/
private function calculateTravelPrice(BookingCreateDto $booking): float
private function calculateTravelPrice(BookingCreateDto $booking, int $participantIndex): float
{
// For now, return 0.0 as placeholder - this will be enhanced
// when we integrate with the existing pricing calculation system
return 0.0;
// Use the price calculator to get the participant's individual price excluding insurance
return $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($booking, $participantIndex);
}
/**
@@ -222,4 +319,24 @@ class InsuranceMatchingService
{
return $startDate->diff($endDate)->days;
}
/**
* Filters insurances by type (subType and familyInsurance combination).
*
* This method groups insurances of the same type together for reassignment or batch assignment.
* Insurance type is defined as the combination of subType (RRV, PAK, OHN) and familyInsurance flag.
*
* @param array<Insurance> $insurances All available insurances to filter
* @param string|null $subType The insurance subType to match (e.g., 'RRV', 'PAK')
* @param bool $familyInsurance Whether to match family or individual insurances
*
* @return array<Insurance> Filtered insurances of the same type
*/
private function filterInsurancesByType(array $insurances, ?string $subType, bool $familyInsurance): array
{
return array_filter(
$insurances,
fn (Insurance $insurance) => $insurance->subType === $subType && $insurance->familyInsurance === $familyInsurance
);
}
}
+47
View File
@@ -10,6 +10,7 @@ use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\InsuranceLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Exception\HotelNotFoundException;
@@ -36,6 +37,7 @@ class TravelDataService
private readonly TravelLoader $travelLoader,
private readonly HotelLoader $hotelLoader,
private readonly PickupLoader $pickupLoader,
private readonly InsuranceLoader $insuranceLoader,
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
@@ -102,6 +104,11 @@ class TravelDataService
public function getTravelDataFromXml(int $dateId, ?int $hotelId = null): ?Travel
{
try {
$this->logger->debug('Loading travel data from XML', [
'dateId' => $dateId,
'hotelId' => $hotelId,
]);
$travel = $this->travelLoader->loadById($dateId, $hotelId);
$this->enrichTravelData($travel);
@@ -151,6 +158,11 @@ class TravelDataService
public function getTravelDataFromApi(int $dateId, ?int $hotelId = null): ?Travel
{
try {
$this->logger->debug('Loading travel data from API', [
'dateId' => $dateId,
'hotelId' => $hotelId,
]);
// Map dateId to productId for API call
$productId = $this->mapDateIdToProductId($dateId);
if (null === $productId) {
@@ -581,8 +593,13 @@ class TravelDataService
private function enrichTravelData(Travel $travel): void
{
try {
$this->logger->debug('Enriching travel data', [
'travelId' => $travel->id,
]);
$this->pickupLoader->patchPickupsDetails($travel);
$this->hotelLoader->patchHotelDetails($travel);
$this->patchInsuranceData($travel);
} catch (\Exception $e) {
$this->logger->warning('Failed to enrich travel data', [
'travelId' => $travel->id,
@@ -590,4 +607,34 @@ class TravelDataService
]);
}
}
/**
* Adds insurance data to travel object.
*
* Loads all available insurances and adds them to the travel object.
* This ensures insurance options are available for booking.
*
* @param Travel $travel The travel object to enrich with insurance data
*/
private function patchInsuranceData(Travel $travel): void
{
try {
$this->logger->debug('Loading insurance data', [
'travelId' => $travel->id,
]);
$insurances = $this->insuranceLoader->loadAll();
$travel->insurances = array_values($insurances);
$this->logger->debug('Insurance data added to travel', [
'travelId' => $travel->id,
'insuranceCount' => count($insurances),
]);
} catch (\Exception $e) {
$this->logger->warning('Failed to load insurance data', [
'travelId' => $travel->id,
'error' => $e->getMessage(),
]);
}
}
}
+11
View File
@@ -94,6 +94,8 @@
'hx-swap': 'none'
}
}) }}
{% else %}
<div></div>{# Empty div to maintain grid layout when skipass not available #}
{% endif %}
{% if participant.courses is defined %}
{{ form_row(participant.courses, {
@@ -140,6 +142,15 @@
}
}) }}
{% endif %}
{% if participant.insurance is defined %}
{{ form_row(participant.insurance, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_refresh'),
'hx-swap': 'none'
}
}) }}
{% endif %}
</div>
{# Transportation Services Section #}
@@ -72,13 +72,13 @@ class InsuranceParserTest extends TestCase
$this->assertFalse($deductibleInsurance->familyInsurance);
$this->assertFalse($deductibleInsurance->package);
// Test package
// 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->assertNull($package->subType); // Packages don't have subtypes
$this->assertEquals(14.0, $package->price);
$this->assertFalse($package->familyInsurance);
$this->assertTrue($package->familyInsurance); // Should be true because it contains family insurance 177236
$this->assertTrue($package->package);
$this->assertEquals([177236, 177270], $package->containedInsuranceIds);
}
@@ -171,7 +171,11 @@ class InsuranceMatchingServiceTest extends TestCase
$result = $this->service->getEligibleInsurances([$insurance], $participant, $booking);
$this->assertEmpty($result);
// Participants without birth date should now have insurances available
// (field visibility is controlled by field state conditions, not service logic)
$this->assertNotEmpty($result);
$this->assertCount(1, $result);
$this->assertSame($insurance, $result[0]);
}
public function testGetEligibleInsurancesWithNullConstraints(): void