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 -1
View File
@@ -181,6 +181,7 @@ class BookingCreateParticipantType extends AbstractType
'pickupInbound',
'parking',
'licensePlate',
'insurance',
];
foreach ($dynamicFields as $fieldName) {
@@ -212,7 +213,11 @@ class BookingCreateParticipantType extends AbstractType
$this->addBaseFields($form, $bookingDto, $participantIndex);
// Rebuild dynamic fields
$dynamicFields = ['assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'rentalInsurance', 'skiPass', 'transportationOutbound', 'transportationInbound', 'pickupOutbound', 'pickupInbound', 'parking', 'licensePlate'];
$dynamicFields = [
'assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'rentalInsurance',
'skiPass', 'transportationOutbound', 'transportationInbound', 'pickupOutbound', 'pickupInbound', 'parking',
'licensePlate', 'insurance',
];
foreach ($dynamicFields as $fieldName) {
if ($form->has($fieldName)) {
$form->remove($fieldName);
@@ -243,6 +248,7 @@ class BookingCreateParticipantType extends AbstractType
'pickupInbound' => ChoiceType::class,
'parking' => CheckboxType::class,
'licensePlate' => TextType::class,
'insurance' => ChoiceType::class,
];
foreach ($dynamicFields as $fieldName => $fieldType) {
+10 -10
View File
@@ -55,18 +55,21 @@ class BookingCreateDto implements BookingDtoInterface
* Determines if this is a family booking based on participant age distribution.
*
* A family booking is defined as:
* - 1-2 participants aged 18 or older (adults)
* - At least 1 participant aged 20 or younger (young people/children)
* - 1 or 2 participants aged 18 or older (adults)
* - At least 1 participant younger than 18 (children)
*
* @return bool True if this qualifies as a family booking
*/
public function isFamilyBooking(): bool
{
$adults = 0; // Count of participants >= 18 years
$youngPeople = 0; // Count of participants <= 20 years
$children = 0; // Count of participants < 18 years
// Use travel start date for age calculation
$travelStartDate = $this->travel->dateFrom;
foreach ($this->participants as $participant) {
$age = $participant->getAge();
$age = $participant->getAge($travelStartDate);
if (null === $age) {
continue; // Skip participants without birth date
@@ -74,15 +77,12 @@ class BookingCreateDto implements BookingDtoInterface
if ($age >= 18) {
++$adults;
}
if ($age <= 20) {
++$youngPeople;
} else {
++$children;
}
}
// Check family booking criteria
return ($adults >= 1 && $adults <= 2) && ($youngPeople >= 1);
return ($adults >= 1 && $adults <= 2) && ($children >= 1);
}
#[Assert\Callback(callback: 'validateRoomSelection', groups: ['booking_create_step_1'])]
@@ -87,12 +87,17 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
];
// Hide insurance field until date of birth is provided
$this->fieldStateConditions['insurance'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
];
// Room-specific field conditions
$mbzRoomCondition = new RoomSelectionCondition(['mbz']);
$sharedRoomCondition = new RoomSelectionCondition(['mbz']);
// Show remarks room field only when room with code 'mbz' is selected
$this->fieldStateConditions['remarksRoom'] = [
'hidden' => CompositeCondition::not($mbzRoomCondition),
'hidden' => CompositeCondition::not($sharedRoomCondition),
];
// Transportation-related field conditions
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
@@ -12,7 +13,10 @@ use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractFieldOptionsProvider;
use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory;
use App\Form\Service\ServiceAgeEvaluator;
use App\Service\InsuranceMatchingService;
use App\Service\ServiceAvailabilityCalculator;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Provides dynamic field options for participant form fields.
@@ -43,10 +47,14 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
*
* @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders
* @param ServiceAvailabilityCalculator $serviceAvailabilityCalculator Service for calculating dynamic availability
* @param InsuranceMatchingService $insuranceMatchingService Service for matching insurances to participants
* @param UrlGeneratorInterface $urlGenerator URL generator for HTMX endpoints
*/
public function __construct(
private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory,
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
private readonly InsuranceMatchingService $insuranceMatchingService,
private readonly UrlGeneratorInterface $urlGenerator,
) {
parent::__construct();
}
@@ -329,8 +337,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $attributes;
},
'attr' => [
'hx-post' => '#', // Will be configured when HTMX integration is implemented
'hx-target' => '#booking-summary',
'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
'hx-swap' => 'none',
'hx-trigger' => 'change',
],
];
@@ -360,8 +368,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $attributes;
},
'attr' => [
'hx-post' => '#', // Will be configured when HTMX integration is implemented
'hx-target' => '#booking-summary',
'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
'hx-swap' => 'none',
'hx-trigger' => 'change',
],
];
@@ -397,6 +405,26 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'required' => false,
];
// Insurance field provider - provides age and eligibility filtered insurances for participants
$this->fieldOptionProviders['insurance'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Reiseversicherung',
'multiple' => false,
'expanded' => true,
'required' => false,
'choices' => array_merge(
[0 => null], // "no insurance" option
$this->getEligibleInsurances($bookingDto, $participantIndex)
),
'choice_label' => fn (?Insurance $insurance) => $this->formatInsuranceLabel($insurance),
'choice_value' => 'id',
'help' => 'Wählen Sie eine passende Reiseversicherung für diese Person aus.',
'attr' => [
'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
'hx-swap' => 'none',
'hx-trigger' => 'change',
],
];
// Future field providers would be added here, for example:
//
// $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [
@@ -672,4 +700,68 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $rentalInsuranceService->description;
}
/**
* Gets eligible insurances for a participant based on eligibility criteria.
*
* @param BookingDtoInterface $bookingDto The booking DTO containing travel and participant data
* @param int $participantIndex The index of the participant to get eligible insurances for
*
* @return array Array of eligible insurance objects filtered by age, family status, and other constraints
*/
private function getEligibleInsurances(BookingDtoInterface $bookingDto, int $participantIndex): array
{
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
return [];
}
$availableInsurances = $bookingDto->travel->insurances ?? [];
// Only apply insurance filtering for BookingCreateDto (creation workflow)
if (!$bookingDto instanceof BookingCreateDto) {
return $availableInsurances;
}
// Use insurance matching service to filter based on eligibility criteria
return $this->insuranceMatchingService->getEligibleInsurances(
$availableInsurances,
$participant,
$bookingDto
);
}
/**
* Formats insurance label with pricing and type information.
*
* @param Insurance|null $insurance The insurance to format, or null for "No Insurance" option
*
* @return string The formatted insurance label
*/
private function formatInsuranceLabel(?Insurance $insurance): string
{
if (null === $insurance) {
return 'Keine Versicherung';
}
$label = $insurance->label;
// Add pricing information (consistent with other services)
if (null !== $insurance->price && $insurance->price > 0) {
$label .= sprintf(' (€%s)', number_format($insurance->price, 2, ',', '.'));
}
// Add type information if available
if (null !== $insurance->subType) {
$typeLabel = match ($insurance->subType) {
'RRV' => 'Reiserücktrittsversicherung',
'PAK' => 'Reiseschutz',
'OHN' => 'Selbstbehalt',
default => $insurance->subType,
};
$label .= sprintf(' (%s)', $typeLabel);
}
return $label;
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Insurance;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\InsuranceMatchingService;
@@ -13,19 +14,28 @@ use App\Service\InsuranceMatchingService;
* Handles processing of the insurance field for booking participants.
*
* This handler manages insurance selections for individual participants in the booking
* creation process. It processes the insurance field from form submissions,
* validates the selection against participant eligibility criteria, and updates
* the participant DTO with the valid insurance selection.
* creation process. It processes insurance field from form submissions, validates
* selections against participant eligibility criteria, and provides automatic
* reassignment when participant pricing changes.
*
* The insurance field depends on dateOfBirth for age-based eligibility calculations
* and uses the InsuranceMatchingService to ensure only eligible insurances can be selected.
* Key Features:
* - Validates submitted insurance selections against eligibility criteria
* - Automatically reassigns insurances when travel price changes make current selection invalid
* - Preserves user intent by maintaining same insurance type (subType + familyInsurance)
* - Uses InsuranceMatchingService for eligibility filtering and reassignment logic
*
* Auto-Reassignment Logic:
* When a participant's individual travel price changes (e.g., by adding services),
* their current insurance may no longer be eligible for the new price range.
* Instead of clearing the selection, this handler automatically reassigns
* to the same insurance type with the appropriate price tier.
*
* Dependencies: dateOfBirth (for age evaluation and insurance eligibility)
*/
class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly InsuranceMatchingService $insuranceMatchingService,
private readonly InsuranceMatchingService $insuranceMatchingService
) {
}
@@ -71,10 +81,9 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
/**
* Processes the insurance field for a specific participant.
*
* This method extracts the insurance selection from submitted form data,
* validates the selection against the participant's eligibility criteria,
* and updates the participant DTO with the valid selection. If the insurance
* is no longer appropriate for the participant, it is automatically cleared.
* This method handles both explicit insurance selection from form data and automatic
* reassignment when the participant's travel price changes. It maintains the same
* insurance type but adjusts to the appropriate price tier when needed.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit)
@@ -83,38 +92,67 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
if (null === $participant || !$bookingDto instanceof BookingCreateDto) {
return;
}
$insuranceId = $submittedData['insurance'] ?? null;
// Clear insurance if no selection
if (null === $insuranceId || '' === $insuranceId) {
$participant->insurance = null;
return;
}
// Find the selected insurance from available travel insurances
$selectedInsuranceId = $this->getFieldValue($submittedData, $this->getFieldName());
$currentInsurance = $participant->insurance;
$availableInsurances = $bookingDto->travel->insurances ?? [];
$selectedInsurance = $this->findInsuranceById($availableInsurances, $insuranceId);
if (null === $selectedInsurance) {
$participant->insurance = null;
// Handle explicit insurance selection from form
if (null !== $selectedInsuranceId) {
$selectedInsurance = $this->findInsuranceById($availableInsurances, $selectedInsuranceId);
return;
// Check if the selected insurance is still eligible for this participant
if (null !== $selectedInsurance) {
$eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances($availableInsurances, $participant, $bookingDto);
$isSelectedInsuranceEligible = $this->isInsuranceInList($selectedInsurance, $eligibleInsurances);
if ($isSelectedInsuranceEligible) {
// Insurance is still eligible - use it directly
$participant->insurance = $selectedInsurance;
return;
} else {
// Insurance is no longer eligible - try to reassign to same type with new price tier
$reassignedInsurance = $this->insuranceMatchingService->reassignInsuranceForPriceChange(
$availableInsurances,
$selectedInsurance,
$participant,
$bookingDto
);
$participant->insurance = $reassignedInsurance;
return;
}
} else {
// Insurance not found - clear selection
$participant->insurance = null;
return;
}
}
// Validate insurance eligibility for this participant
$eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances(
[$selectedInsurance],
$participant,
$bookingDto
);
// Handle automatic reassignment if participant had an insurance but it's no longer eligible
if (null !== $currentInsurance) {
// Check if current insurance is still eligible
$eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances($availableInsurances, $participant, $bookingDto);
$isCurrentInsuranceStillEligible = $this->isInsuranceInList($currentInsurance, $eligibleInsurances);
// Set insurance only if it's eligible for this participant
$participant->insurance = !empty($eligibleInsurances) ? $selectedInsurance : null;
if (!$isCurrentInsuranceStillEligible) {
// Try to reassign to same insurance type with appropriate price tier
$reassignedInsurance = $this->insuranceMatchingService->reassignInsuranceForPriceChange(
$availableInsurances,
$currentInsurance,
$participant,
$bookingDto
);
$participant->insurance = $reassignedInsurance; // null if no suitable match found
return;
}
}
// No insurance selected or reassignment needed - keep current state (may be null)
}
/**
@@ -163,4 +201,24 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
return null;
}
/**
* Checks if a specific insurance exists in a list of insurances.
*
* @param Insurance $targetInsurance The insurance to find
* @param array<Insurance> $insuranceList The list to search in
*
* @return bool True if the insurance is found in the list
*/
private function isInsuranceInList(Insurance $targetInsurance, array $insuranceList): bool
{
foreach ($insuranceList as $insurance) {
if ($insurance->id === $targetInsurance->id || (string) $insurance->id === (string) $targetInsurance->id) {
return true;
}
}
return false;
}
}