wip: insurance booking

This commit is contained in:
Björn Fromme
2025-09-29 19:54:20 +02:00
parent 0809f07d46
commit 90ae78262a
19 changed files with 807 additions and 128 deletions
+7 -3
View File
@@ -67,11 +67,14 @@ services:
arguments: arguments:
$choiceListFactory: '@form.choice_list_factory.default' $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: App\Form\Service\ParticipantFieldHandlerRegistry:
arguments: arguments:
$handlers: $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\ParticipantDateOfBirthFieldHandler'
- 'App\Form\Service\ParticipantAssignedRoomFieldHandler' - 'App\Form\Service\ParticipantAssignedRoomFieldHandler'
- 'App\Form\Service\ParticipantRemarksRoomFieldHandler' - 'App\Form\Service\ParticipantRemarksRoomFieldHandler'
@@ -87,4 +90,5 @@ services:
- 'App\Form\Service\ParticipantParkingFieldHandler' - 'App\Form\Service\ParticipantParkingFieldHandler'
- 'App\Form\Service\ParticipantRentalInsuranceFieldHandler' - 'App\Form\Service\ParticipantRentalInsuranceFieldHandler'
- 'App\Form\Service\ParticipantLicensePlateFieldHandler' - '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_BOARD = 'VPF';
public const TOKEN_RENTALS = ['VER', 'VE2', 'VE3', 'VE4', 'VE5', 'VE6', 'VE7', 'VE8']; public const TOKEN_RENTALS = ['VER', 'VE2', 'VE3', 'VE4', 'VE5', 'VE6', 'VE7', 'VE8'];
public const TOKEN_RENTAL_INSURANCE = 'LVS'; public const TOKEN_RENTAL_INSURANCE = 'LVS';
public const TOKEN_INSURANCES = ['RRV', 'PAK', 'OHN', 'PKG'];
public const TOKEN_PARKING = 'PAR'; 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_AVAILABLE = 'Frei';
public const STATUS_BLOCKED = 'Buchungsstop'; public const STATUS_BLOCKED = 'Buchungsstop';
public const STATUS_ON_REQUEST = 'Anfrage'; public const STATUS_ON_REQUEST = 'Anfrage';
+19
View File
@@ -91,4 +91,23 @@ class Insurance
*/ */
#[Groups(['api:single', 'api:list'])] #[Groups(['api:single', 'api:list'])]
public array $containedInsuranceIds = []; 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 class InsuranceLoader extends AbstractLoader
{ {
public function __construct( public function __construct(
protected readonly CacheInterface $cache, CacheInterface $cache,
protected readonly FilesystemOperator $xmlExport, FilesystemOperator $xmlExport,
private readonly InsuranceParser $insuranceParser, private readonly InsuranceParser $insuranceParser,
) { ) {
parent::__construct($cache, $xmlExport); parent::__construct($cache, $xmlExport);
+49 -14
View File
@@ -19,6 +19,7 @@ class InsuranceParser extends AbstractParser
* Parses insurance XML nodes into Insurance objects. * Parses insurance XML nodes into Insurance objects.
* *
* @param Crawler $xmlContent The XML crawler containing insurance data * @param Crawler $xmlContent The XML crawler containing insurance data
*
* @return array<string|int, Insurance> Array of Insurance objects indexed by id * @return array<string|int, Insurance> Array of Insurance objects indexed by id
*/ */
public function parse(Crawler $xmlContent): array public function parse(Crawler $xmlContent): array
@@ -28,25 +29,29 @@ class InsuranceParser extends AbstractParser
// First pass: collect referenced insurance IDs from packages // First pass: collect referenced insurance IDs from packages
$referencedIds = $this->collectReferencedInsuranceIds($xmlContent); $referencedIds = $this->collectReferencedInsuranceIds($xmlContent);
// Parse individual insurances with conditional filtering // Second pass: Parse individual insurances with conditional filtering
$individualInsurances = [];
$xmlContent->filterXPath('//versicherungen/versicherung') $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 $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 // Parse all individual insurances for reference lookup
if (!$isZusatz || in_array($id, $referencedIds, true)) { $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;
// Include if: not additional insurance OR referenced by package
if (!$isAdditional || in_array($id, $referencedIds, true)) {
$insurances[$insurance->id] = $insurance; $insurances[$insurance->id] = $insurance;
} }
} }
}); });
// Parse packages (no filtering needed) // Third pass: Parse packages with family detection based on contained insurances
$xmlContent->filterXPath('//versicherungspakete/versicherungspaket') $xmlContent->filterXPath('//versicherungspakete/versicherungspaket')
->each(function (Crawler $node) use (&$insurances) { ->each(function (Crawler $node) use (&$insurances, $individualInsurances) {
$insurance = $this->parseInsuranceNode($node, true); $insurance = $this->parseInsuranceNode($node, true, $individualInsurances);
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);
@@ -57,14 +62,37 @@ class InsuranceParser extends AbstractParser
return $insurances; 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. * Parses a single insurance or package node.
* *
* @param Crawler $node The XML node to parse * @param Crawler $node The XML node to parse
* @param bool $isPackage Whether this is a package node * @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 * @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 = new Insurance();
$insurance->package = $isPackage; $insurance->package = $isPackage;
@@ -79,6 +107,11 @@ class InsuranceParser extends AbstractParser
$this->stringToFloat($node->attr('preis')) : null; $this->stringToFloat($node->attr('preis')) : null;
$insurance->familyInsurance = $this->stringToBool($node->attr('familienversicherung')); $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 // Date constraints
$insurance->travelDateFrom = $node->attr('reisedatumvon') ? $insurance->travelDateFrom = $node->attr('reisedatumvon') ?
$this->stringToDate($node->attr('reisedatumvon')) : null; $this->stringToDate($node->attr('reisedatumvon')) : null;
@@ -115,6 +148,7 @@ class InsuranceParser extends AbstractParser
* Collects all insurance IDs referenced by packages. * Collects all insurance IDs referenced by packages.
* *
* @param Crawler $xmlContent The XML content to scan * @param Crawler $xmlContent The XML content to scan
*
* @return array<int> Array of referenced insurance IDs * @return array<int> Array of referenced insurance IDs
*/ */
private function collectReferencedInsuranceIds(Crawler $xmlContent): array private function collectReferencedInsuranceIds(Crawler $xmlContent): array
@@ -135,6 +169,7 @@ class InsuranceParser extends AbstractParser
* Parses contained insurance IDs from a package node. * Parses contained insurance IDs from a package node.
* *
* @param Crawler $node The package XML node * @param Crawler $node The package XML node
*
* @return array<int> Array of contained insurance IDs (always int, as they reference individual insurances) * @return array<int> Array of contained insurance IDs (always int, as they reference individual insurances)
*/ */
private function parseContainedInsuranceIds(Crawler $node): array private function parseContainedInsuranceIds(Crawler $node): array
@@ -152,4 +187,4 @@ class InsuranceParser extends AbstractParser
{ {
return !empty($value) && $this->stringToBool($value); 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 // Pre-select mandatory services after form processing but before pricing calculation
$this->bookingService->preselectMandatoryServices($bookingCreateDto); $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); $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
+7 -1
View File
@@ -181,6 +181,7 @@ class BookingCreateParticipantType extends AbstractType
'pickupInbound', 'pickupInbound',
'parking', 'parking',
'licensePlate', 'licensePlate',
'insurance',
]; ];
foreach ($dynamicFields as $fieldName) { foreach ($dynamicFields as $fieldName) {
@@ -212,7 +213,11 @@ class BookingCreateParticipantType extends AbstractType
$this->addBaseFields($form, $bookingDto, $participantIndex); $this->addBaseFields($form, $bookingDto, $participantIndex);
// Rebuild dynamic fields // 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) { foreach ($dynamicFields as $fieldName) {
if ($form->has($fieldName)) { if ($form->has($fieldName)) {
$form->remove($fieldName); $form->remove($fieldName);
@@ -243,6 +248,7 @@ class BookingCreateParticipantType extends AbstractType
'pickupInbound' => ChoiceType::class, 'pickupInbound' => ChoiceType::class,
'parking' => CheckboxType::class, 'parking' => CheckboxType::class,
'licensePlate' => TextType::class, 'licensePlate' => TextType::class,
'insurance' => ChoiceType::class,
]; ];
foreach ($dynamicFields as $fieldName => $fieldType) { 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. * Determines if this is a family booking based on participant age distribution.
* *
* A family booking is defined as: * A family booking is defined as:
* - 1-2 participants aged 18 or older (adults) * - 1 or 2 participants aged 18 or older (adults)
* - At least 1 participant aged 20 or younger (young people/children) * - At least 1 participant younger than 18 (children)
* *
* @return bool True if this qualifies as a family booking * @return bool True if this qualifies as a family booking
*/ */
public function isFamilyBooking(): bool public function isFamilyBooking(): bool
{ {
$adults = 0; // Count of participants >= 18 years $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) { foreach ($this->participants as $participant) {
$age = $participant->getAge(); $age = $participant->getAge($travelStartDate);
if (null === $age) { if (null === $age) {
continue; // Skip participants without birth date continue; // Skip participants without birth date
@@ -74,15 +77,12 @@ class BookingCreateDto implements BookingDtoInterface
if ($age >= 18) { if ($age >= 18) {
++$adults; ++$adults;
} } else {
++$children;
if ($age <= 20) {
++$youngPeople;
} }
} }
// Check family booking criteria return ($adults >= 1 && $adults <= 2) && ($children >= 1);
return ($adults >= 1 && $adults <= 2) && ($youngPeople >= 1);
} }
#[Assert\Callback(callback: 'validateRoomSelection', groups: ['booking_create_step_1'])] #[Assert\Callback(callback: 'validateRoomSelection', groups: ['booking_create_step_1'])]
@@ -87,12 +87,17 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition), 'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
]; ];
// Hide insurance field until date of birth is provided
$this->fieldStateConditions['insurance'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
];
// Room-specific field conditions // Room-specific field conditions
$mbzRoomCondition = new RoomSelectionCondition(['mbz']); $sharedRoomCondition = new RoomSelectionCondition(['mbz']);
// Show remarks room field only when room with code 'mbz' is selected // Show remarks room field only when room with code 'mbz' is selected
$this->fieldStateConditions['remarksRoom'] = [ $this->fieldStateConditions['remarksRoom'] = [
'hidden' => CompositeCondition::not($mbzRoomCondition), 'hidden' => CompositeCondition::not($sharedRoomCondition),
]; ];
// Transportation-related field conditions // Transportation-related field conditions
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Form\Service; namespace App\Form\Service;
use App\BusProNet\Constants; use App\BusProNet\Constants;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service; use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper; use App\BusProNet\Utility\DirectionMapper;
@@ -12,7 +13,10 @@ use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface; use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractFieldOptionsProvider; use App\Form\Service\Abstract\AbstractFieldOptionsProvider;
use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory; use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory;
use App\Form\Service\ServiceAgeEvaluator;
use App\Service\InsuranceMatchingService;
use App\Service\ServiceAvailabilityCalculator; use App\Service\ServiceAvailabilityCalculator;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/** /**
* Provides dynamic field options for participant form fields. * 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 ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders
* @param ServiceAvailabilityCalculator $serviceAvailabilityCalculator Service for calculating dynamic availability * @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( public function __construct(
private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory, private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory,
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator, private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
private readonly InsuranceMatchingService $insuranceMatchingService,
private readonly UrlGeneratorInterface $urlGenerator,
) { ) {
parent::__construct(); parent::__construct();
} }
@@ -329,8 +337,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $attributes; return $attributes;
}, },
'attr' => [ 'attr' => [
'hx-post' => '#', // Will be configured when HTMX integration is implemented 'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
'hx-target' => '#booking-summary', 'hx-swap' => 'none',
'hx-trigger' => 'change', 'hx-trigger' => 'change',
], ],
]; ];
@@ -360,8 +368,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $attributes; return $attributes;
}, },
'attr' => [ 'attr' => [
'hx-post' => '#', // Will be configured when HTMX integration is implemented 'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
'hx-target' => '#booking-summary', 'hx-swap' => 'none',
'hx-trigger' => 'change', 'hx-trigger' => 'change',
], ],
]; ];
@@ -397,6 +405,26 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'required' => false, '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: // Future field providers would be added here, for example:
// //
// $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [ // $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [
@@ -672,4 +700,68 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $rentalInsuranceService->description; 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; namespace App\Form\Service;
use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Insurance;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface; use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler; use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\InsuranceMatchingService; use App\Service\InsuranceMatchingService;
@@ -13,19 +14,28 @@ use App\Service\InsuranceMatchingService;
* Handles processing of the insurance field for booking participants. * Handles processing of the insurance field for booking participants.
* *
* This handler manages insurance selections for individual participants in the booking * This handler manages insurance selections for individual participants in the booking
* creation process. It processes the insurance field from form submissions, * creation process. It processes insurance field from form submissions, validates
* validates the selection against participant eligibility criteria, and updates * selections against participant eligibility criteria, and provides automatic
* the participant DTO with the valid insurance selection. * reassignment when participant pricing changes.
* *
* The insurance field depends on dateOfBirth for age-based eligibility calculations * Key Features:
* and uses the InsuranceMatchingService to ensure only eligible insurances can be selected. * - 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) * Dependencies: dateOfBirth (for age evaluation and insurance eligibility)
*/ */
class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
{ {
public function __construct( 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. * Processes the insurance field for a specific participant.
* *
* This method extracts the insurance selection from submitted form data, * This method handles both explicit insurance selection from form data and automatic
* validates the selection against the participant's eligibility criteria, * reassignment when the participant's travel price changes. It maintains the same
* and updates the participant DTO with the valid selection. If the insurance * insurance type but adjusts to the appropriate price tier when needed.
* is no longer appropriate for the participant, it is automatically cleared.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) * @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 public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{ {
$participant = $bookingDto->getParticipant($participantIndex); $participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) { if (null === $participant || !$bookingDto instanceof BookingCreateDto) {
return; return;
} }
$insuranceId = $submittedData['insurance'] ?? null; $selectedInsuranceId = $this->getFieldValue($submittedData, $this->getFieldName());
$currentInsurance = $participant->insurance;
// Clear insurance if no selection
if (null === $insuranceId || '' === $insuranceId) {
$participant->insurance = null;
return;
}
// Find the selected insurance from available travel insurances
$availableInsurances = $bookingDto->travel->insurances ?? []; $availableInsurances = $bookingDto->travel->insurances ?? [];
$selectedInsurance = $this->findInsuranceById($availableInsurances, $insuranceId);
if (null === $selectedInsurance) { // Handle explicit insurance selection from form
$participant->insurance = null; 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 // Handle automatic reassignment if participant had an insurance but it's no longer eligible
$eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances( if (null !== $currentInsurance) {
[$selectedInsurance], // Check if current insurance is still eligible
$participant, $eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances($availableInsurances, $participant, $bookingDto);
$bookingDto $isCurrentInsuranceStillEligible = $this->isInsuranceInList($currentInsurance, $eligibleInsurances);
);
// Set insurance only if it's eligible for this participant if (!$isCurrentInsuranceStillEligible) {
$participant->insurance = !empty($eligibleInsurances) ? $selectedInsurance : null; // 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; 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; namespace App\Service;
use App\BusProNet\Constants; use App\BusProNet\Constants;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Room; use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service; use App\BusProNet\Model\Service;
use App\Form\Model\BookingCreateDto; use App\Form\Model\BookingCreateDto;
@@ -238,6 +239,45 @@ class BookingPriceCalculatorService
return $participantPrices; 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. * Aggregates all transportation-related services and pricing into separate line items.
* *
@@ -311,7 +351,7 @@ class BookingPriceCalculatorService
'unitPrice' => null, 'unitPrice' => null,
'participantCount' => $pickupParticipants, 'participantCount' => $pickupParticipants,
'totalPrice' => $pickupTotal, 'totalPrice' => $pickupTotal,
'subType' => 'transportation', 'subType' => Constants::GROUP_TRANSPORTATION,
]; ];
} }
@@ -323,7 +363,7 @@ class BookingPriceCalculatorService
'unitPrice' => null, 'unitPrice' => null,
'participantCount' => $parkingParticipants, 'participantCount' => $parkingParticipants,
'totalPrice' => $parkingTotal, 'totalPrice' => $parkingTotal,
'subType' => 'transportation', 'subType' => Constants::GROUP_TRANSPORTATION,
]; ];
} }
@@ -356,7 +396,12 @@ class BookingPriceCalculatorService
// Normalize rental subtypes to avoid duplicate sections // Normalize rental subtypes to avoid duplicate sections
if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) { 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; $isDiscount = $serviceData['totalPrice'] < 0;
@@ -405,8 +450,9 @@ class BookingPriceCalculatorService
Constants::TOKEN_ADDITIONAL => 'Zusatzleistungen', Constants::TOKEN_ADDITIONAL => 'Zusatzleistungen',
Constants::TOKEN_BOARD => 'Verpflegung', Constants::TOKEN_BOARD => 'Verpflegung',
Constants::TOKEN_RENTAL_INSURANCE => 'Leihmaterial-Versicherung', Constants::TOKEN_RENTAL_INSURANCE => 'Leihmaterial-Versicherung',
'transportation' => 'Beförderung', Constants::GROUP_TRANSPORTATION => 'Beförderung',
'rentals' => 'Leihmaterial', // Normalized rental subtype 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) // Handle rentals array (keep for backward compatibility with non-normalized subtypes)
@@ -431,6 +477,10 @@ class BookingPriceCalculatorService
$this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance, 1); $this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance, 1);
} }
if (null !== $participant->insurance && null !== $participant->insurance->price) {
$this->addInsuranceToServiceAggregation($serviceAggregation, $participant->insurance, 1);
}
// Handle multiple service selections // Handle multiple service selections
$multipleServiceArrays = [ $multipleServiceArrays = [
'courses' => $participant->courses, 'courses' => $participant->courses,
@@ -475,11 +525,12 @@ class BookingPriceCalculatorService
/** /**
* Calculates the total service cost for a single participant. * 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 * @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; $serviceTotal = 0.0;
@@ -492,6 +543,10 @@ class BookingPriceCalculatorService
$serviceTotal += $participant->rentalInsurance->price; $serviceTotal += $participant->rentalInsurance->price;
} }
if ($includeInsurance && null !== $participant->insurance && null !== $participant->insurance->price) {
$serviceTotal += $participant->insurance->price;
}
// Transportation services // Transportation services
if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) { if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->price) {
$serviceTotal += $participant->transportationOutbound->price; $serviceTotal += $participant->transportationOutbound->price;
@@ -551,4 +606,30 @@ class BookingPriceCalculatorService
return null; 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\BusProNet\Model\Insurance;
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\Service\BookingPriceCalculatorService;
use App\Service\InsuranceTypeResolver;
use Carbon\Carbon; use Carbon\Carbon;
/** /**
@@ -18,6 +21,11 @@ use Carbon\Carbon;
*/ */
class InsuranceMatchingService class InsuranceMatchingService
{ {
public function __construct(
private readonly BookingPriceCalculatorService $priceCalculatorService,
private readonly InsuranceTypeResolver $insuranceTypeResolver
) {
}
/** /**
* Filters insurances based on participant and booking criteria. * Filters insurances based on participant and booking criteria.
* *
@@ -29,67 +37,151 @@ class InsuranceMatchingService
*/ */
public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingCreateDto $booking): array public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingCreateDto $booking): array
{ {
$travelStartDate = $booking->travel->dateFrom; $criteria = $this->createEligibilityCriteria($participant, $booking);
$travelEndDate = $booking->travel->dateTo;
// Cannot match insurances without travel dates if (null === $criteria) {
if (null === $travelStartDate || null === $travelEndDate) { return []; // Cannot match insurances without travel dates
return [];
} }
$bookingDate = Carbon::now()->toDateTimeImmutable(); return array_filter(
$travelPrice = $this->calculateTravelPrice($booking); $insurances,
$travelDurationDays = $this->calculateTravelDurationDays($travelStartDate, $travelEndDate); fn (Insurance $insurance) => $this->isInsuranceEligible($insurance, $criteria)
);
return array_filter($insurances, function (Insurance $insurance) use ($participant, $travelStartDate, $travelEndDate, $bookingDate, $travelPrice, $travelDurationDays) {
return $this->isInsuranceEligible($insurance, $participant, $travelStartDate, $travelEndDate, $bookingDate, $travelPrice, $travelDurationDays);
});
} }
/** /**
* 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 * This method is used when a participant's individual price changes and their
* @param ParticipantDto $participant The participant * current insurance is no longer eligible. It finds the same insurance type
* @param \DateTimeImmutable $travelStartDate Travel start date * (subType + familyInsurance) with the correct price tier.
* @param \DateTimeImmutable $travelEndDate Travel end date
* @param \DateTimeImmutable $bookingDate Booking date
* @param float $travelPrice Total travel price
* @param int $travelDurationDays Travel duration in days
* *
* @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( public function reassignInsuranceForPriceChange(array $availableInsurances, Insurance $currentInsurance, ParticipantDto $participant, BookingCreateDto $booking): ?Insurance
Insurance $insurance, {
ParticipantDto $participant, // Group insurances of the same type (subType + familyInsurance)
\DateTimeImmutable $travelStartDate, $sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $currentInsurance->subType, $currentInsurance->familyInsurance);
\DateTimeImmutable $travelEndDate,
\DateTimeImmutable $bookingDate, // Get eligible insurances for this participant
float $travelPrice, $eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking);
int $travelDurationDays,
): bool { // 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 // Age constraints
if (!$this->checkAgeConstraints($insurance, $participant, $travelStartDate)) { if (false === $this->checkAgeConstraints($insurance, $criteria->participant, $criteria->travelStartDate)) {
return false; return false;
} }
// Travel date constraints // Travel date constraints
if (!$this->checkTravelDateConstraints($insurance, $travelStartDate, $travelEndDate)) { if (false === $this->checkTravelDateConstraints($insurance, $criteria->travelStartDate, $criteria->travelEndDate)) {
return false; return false;
} }
// Booking date constraints // Booking date constraints
if (!$this->checkBookingDateConstraints($insurance, $bookingDate)) { if (false === $this->checkBookingDateConstraints($insurance, $criteria->bookingDate)) {
return false; return false;
} }
// Travel price constraints // Travel price constraints
if (!$this->checkTravelPriceConstraints($insurance, $travelPrice)) { if (false === $this->checkTravelPriceConstraints($insurance, $criteria->travelPrice)) {
return false; return false;
} }
// Travel duration constraints // 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; return false;
} }
@@ -103,8 +195,9 @@ class InsuranceMatchingService
{ {
$participantAge = $participant->getAge($travelStartDate); $participantAge = $participant->getAge($travelStartDate);
// If no birth date is provided, skip age constraints (field will be hidden via field state conditions)
if (null === $participantAge) { if (null === $participantAge) {
return false; // Cannot determine age eligibility without birth date return true;
} }
// Check minimum age // 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 // Use the price calculator to get the participant's individual price excluding insurance
// when we integrate with the existing pricing calculation system return $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($booking, $participantIndex);
return 0.0;
} }
/** /**
@@ -222,4 +319,24 @@ class InsuranceMatchingService
{ {
return $startDate->diff($endDate)->days; 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\Notification;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\HotelLoader; use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\InsuranceLoader;
use App\BusProNet\XmlLoader\PickupLoader; use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlLoader\TravelLoader; use App\BusProNet\XmlLoader\TravelLoader;
use App\Exception\HotelNotFoundException; use App\Exception\HotelNotFoundException;
@@ -36,6 +37,7 @@ class TravelDataService
private readonly TravelLoader $travelLoader, private readonly TravelLoader $travelLoader,
private readonly HotelLoader $hotelLoader, private readonly HotelLoader $hotelLoader,
private readonly PickupLoader $pickupLoader, private readonly PickupLoader $pickupLoader,
private readonly InsuranceLoader $insuranceLoader,
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly CacheInterface $cache, private readonly CacheInterface $cache,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
@@ -102,6 +104,11 @@ class TravelDataService
public function getTravelDataFromXml(int $dateId, ?int $hotelId = null): ?Travel public function getTravelDataFromXml(int $dateId, ?int $hotelId = null): ?Travel
{ {
try { try {
$this->logger->debug('Loading travel data from XML', [
'dateId' => $dateId,
'hotelId' => $hotelId,
]);
$travel = $this->travelLoader->loadById($dateId, $hotelId); $travel = $this->travelLoader->loadById($dateId, $hotelId);
$this->enrichTravelData($travel); $this->enrichTravelData($travel);
@@ -151,6 +158,11 @@ class TravelDataService
public function getTravelDataFromApi(int $dateId, ?int $hotelId = null): ?Travel public function getTravelDataFromApi(int $dateId, ?int $hotelId = null): ?Travel
{ {
try { try {
$this->logger->debug('Loading travel data from API', [
'dateId' => $dateId,
'hotelId' => $hotelId,
]);
// Map dateId to productId for API call // Map dateId to productId for API call
$productId = $this->mapDateIdToProductId($dateId); $productId = $this->mapDateIdToProductId($dateId);
if (null === $productId) { if (null === $productId) {
@@ -581,8 +593,13 @@ class TravelDataService
private function enrichTravelData(Travel $travel): void private function enrichTravelData(Travel $travel): void
{ {
try { try {
$this->logger->debug('Enriching travel data', [
'travelId' => $travel->id,
]);
$this->pickupLoader->patchPickupsDetails($travel); $this->pickupLoader->patchPickupsDetails($travel);
$this->hotelLoader->patchHotelDetails($travel); $this->hotelLoader->patchHotelDetails($travel);
$this->patchInsuranceData($travel);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->warning('Failed to enrich travel data', [ $this->logger->warning('Failed to enrich travel data', [
'travelId' => $travel->id, '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' 'hx-swap': 'none'
} }
}) }} }) }}
{% else %}
<div></div>{# Empty div to maintain grid layout when skipass not available #}
{% endif %} {% endif %}
{% if participant.courses is defined %} {% if participant.courses is defined %}
{{ form_row(participant.courses, { {{ form_row(participant.courses, {
@@ -140,6 +142,15 @@
} }
}) }} }) }}
{% endif %} {% 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> </div>
{# Transportation Services Section #} {# Transportation Services Section #}
@@ -72,13 +72,13 @@ class InsuranceParserTest extends TestCase
$this->assertFalse($deductibleInsurance->familyInsurance); $this->assertFalse($deductibleInsurance->familyInsurance);
$this->assertFalse($deductibleInsurance->package); $this->assertFalse($deductibleInsurance->package);
// Test package // 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 Auto/Bahn/Bus (Europa) + Selbstbehaltübernahme', $package->label);
$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->assertFalse($package->familyInsurance); $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);
} }
@@ -171,7 +171,11 @@ class InsuranceMatchingServiceTest extends TestCase
$result = $this->service->getEligibleInsurances([$insurance], $participant, $booking); $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 public function testGetEligibleInsurancesWithNullConstraints(): void