wip: insurance booking
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
// 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 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Validate insurance eligibility for this participant
|
||||
$eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances(
|
||||
[$selectedInsurance],
|
||||
} else {
|
||||
// Insurance is no longer eligible - try to reassign to same type with new price tier
|
||||
$reassignedInsurance = $this->insuranceMatchingService->reassignInsuranceForPriceChange(
|
||||
$availableInsurances,
|
||||
$selectedInsurance,
|
||||
$participant,
|
||||
$bookingDto
|
||||
);
|
||||
|
||||
// Set insurance only if it's eligible for this participant
|
||||
$participant->insurance = !empty($eligibleInsurances) ? $selectedInsurance : null;
|
||||
$participant->insurance = $reassignedInsurance;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Insurance not found - clear selection
|
||||
$participant->insurance = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -476,10 +526,11 @@ class BookingPriceCalculatorService
|
||||
* Calculates the total service cost for a single participant.
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @param BookingCreateDto $booking The booking to calculate price for
|
||||
* @param int $participantIndex The participant index to calculate for
|
||||
*
|
||||
* @return float The total travel price
|
||||
* @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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user