feat: consolidated insurance service

This commit is contained in:
Björn Fromme
2025-10-20 09:22:03 +02:00
parent fb9c9d91ee
commit 38bc594ca4
12 changed files with 681 additions and 545 deletions
@@ -14,8 +14,8 @@ use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BankAccountDto;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Service\InsuranceMatchingService;
use App\Service\InsuranceTypeFilterService;
use App\Service\BookingPriceCalculatorService;
use App\Service\InsuranceService;
/**
* Processes booking form data and converts it into BusProNet API payload format.
@@ -29,8 +29,8 @@ use App\Service\InsuranceTypeFilterService;
class BookingDataProcessor
{
public function __construct(
private readonly InsuranceMatchingService $insuranceMatchingService,
private readonly InsuranceTypeFilterService $insuranceTypeFilterService,
private readonly InsuranceService $insuranceService,
private readonly BookingPriceCalculatorService $priceCalculatorService,
) {
}
@@ -1111,17 +1111,21 @@ class BookingDataProcessor
return;
}
// Get all available insurances from travel data
$availableInsurances = $bookingDto->travel->insurances;
// Get selectable (non-complementary) insurances with caching
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
// Exclude complementary insurances (only available as part of packages)
$availableInsurances = $this->insuranceTypeFilterService->filterNonComplementary($availableInsurances);
// Calculate travel prices for all participants (excluding insurance)
$participantPrices = [];
foreach ($bookingDto->getParticipants() as $index => $participant) {
$participantPrices[$index] = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index);
}
// Use InsuranceMatchingService for proper type-based assignment with price tier matching
$assignments = $this->insuranceMatchingService->batchAssignInsuranceToParticipants(
$availableInsurances,
// Use InsuranceService for proper type-based assignment with price tier matching
$assignments = $this->insuranceService->batchAssignInsuranceToParticipants(
$selectableInsurances,
$applicant->insurance,
$bookingDto
$bookingDto,
$participantPrices
);
// Apply assignments to dependent participants (skip applicant at index 0)
@@ -12,8 +12,8 @@ use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractFieldOptionsProvider;
use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory;
use App\Service\InsuranceMatchingService;
use App\Service\InsuranceTypeFilterService;
use App\Service\BookingPriceCalculatorService;
use App\Service\InsuranceService;
use App\Service\ServiceAvailabilityCalculator;
/**
@@ -45,13 +45,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 InsuranceService $insuranceService Service for insurance operations
* @param BookingPriceCalculatorService $priceCalculatorService Service for calculating participant prices
*/
public function __construct(
private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory,
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
private readonly InsuranceMatchingService $insuranceMatchingService,
private readonly InsuranceTypeFilterService $insuranceTypeFilterService,
private readonly InsuranceService $insuranceService,
private readonly BookingPriceCalculatorService $priceCalculatorService,
) {
parent::__construct();
}
@@ -756,16 +757,18 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return [];
}
$availableInsurances = $bookingDto->travel->insurances ?? [];
// Get selectable (non-complementary) insurances with caching
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
// Exclude complementary insurances (only available as part of packages)
$availableInsurances = $this->insuranceTypeFilterService->filterNonComplementary($availableInsurances);
// Calculate travel price for eligibility filtering
$travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex);
// Use insurance matching service to filter based on eligibility criteria
return $this->insuranceMatchingService->getEligibleInsurances(
$availableInsurances,
// Filter based on eligibility criteria for this participant
return $this->insuranceService->getEligibleInsurances(
$selectableInsurances,
$participant,
$bookingDto
$bookingDto,
$travelPrice
);
}
}
@@ -7,8 +7,8 @@ namespace App\Form\Service;
use App\BusProNet\Model\Insurance;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\InsuranceMatchingService;
use App\Service\InsuranceTypeFilterService;
use App\Service\BookingPriceCalculatorService;
use App\Service\InsuranceService;
/**
* Handles processing of the insurance field for booking participants.
@@ -35,8 +35,8 @@ use App\Service\InsuranceTypeFilterService;
class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly InsuranceMatchingService $insuranceMatchingService,
private readonly InsuranceTypeFilterService $insuranceTypeFilterService,
private readonly InsuranceService $insuranceService,
private readonly BookingPriceCalculatorService $priceCalculatorService,
) {
}
@@ -132,10 +132,12 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
$selectedInsuranceId = $this->getFieldValue($submittedData, $this->getFieldName());
$currentInsurance = $participant->insurance;
$availableInsurances = $bookingDto->travel->insurances ?? [];
// Exclude complementary insurances (only available as part of packages)
$availableInsurances = $this->insuranceTypeFilterService->filterNonComplementary($availableInsurances);
// Get selectable (non-complementary) insurances with caching
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
// Calculate travel price for eligibility checks
$travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex);
// Determine if this is a new user selection or just form resubmission
$isNewSelection = null !== $selectedInsuranceId
@@ -143,10 +145,10 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
// Handle explicit new insurance selection from user
if ($isNewSelection) {
$selectedInsurance = $this->findInsuranceById($availableInsurances, $selectedInsuranceId);
$selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId);
if (null !== $selectedInsurance) {
$eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances($availableInsurances, $participant, $bookingDto);
$eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice);
$isSelectedInsuranceEligible = $this->isInsuranceInList($selectedInsurance, $eligibleInsurances);
if ($isSelectedInsuranceEligible) {
@@ -154,11 +156,12 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
$participant->insurance = $selectedInsurance;
} else {
// New selection is not eligible - try to find alternative in same type
$reassignedInsurance = $this->insuranceMatchingService->reassignInsuranceForPriceChange(
$availableInsurances,
$reassignedInsurance = $this->insuranceService->reassignInsuranceForPriceChange(
$selectableInsurances,
$selectedInsurance,
$participant,
$bookingDto
$bookingDto,
$travelPrice
);
$participant->insurance = $reassignedInsurance;
@@ -178,16 +181,17 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
// Handle form resubmission with existing insurance (automatic reassignment check)
if (null !== $currentInsurance && null !== $selectedInsuranceId) {
// Check if current insurance is still eligible with updated participant data
$eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances($availableInsurances, $participant, $bookingDto);
$eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice);
$isCurrentInsuranceStillEligible = $this->isInsuranceInList($currentInsurance, $eligibleInsurances);
if (!$isCurrentInsuranceStillEligible) {
// Current insurance no longer eligible - try to reassign to same type with new price tier
$reassignedInsurance = $this->insuranceMatchingService->reassignInsuranceForPriceChange(
$availableInsurances,
$reassignedInsurance = $this->insuranceService->reassignInsuranceForPriceChange(
$selectableInsurances,
$currentInsurance,
$participant,
$bookingDto
$bookingDto,
$travelPrice
);
if (null !== $reassignedInsurance && $reassignedInsurance->id !== $currentInsurance->id) {
+7 -10
View File
@@ -22,8 +22,7 @@ class BookingPriceCalculatorService
{
public function __construct(
private readonly ParticipantEligibilityService $participantEligibilityService,
private readonly InsuranceTypeFilterService $insuranceTypeFilterService,
private readonly InsuranceEligibilityService $insuranceEligibilityService,
private readonly InsuranceService $insuranceService,
) {
}
@@ -772,22 +771,20 @@ class BookingPriceCalculatorService
// For dependent participants: calculate price-tier-adjusted insurance
// This mirrors the logic in BookingDataProcessor::applyBulkInsuranceIfActive()
$availableInsurances = $bookingDto->travel->insurances;
// Exclude complementary insurances (only available as part of packages)
$availableInsurances = $this->insuranceTypeFilterService->filterNonComplementary($availableInsurances);
// Get selectable (non-complementary) insurances with caching
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
// Get insurances of the same type as applicant's selection
$sameTypeInsurances = $this->insuranceTypeFilterService->filterByType(
$availableInsurances,
$sameTypeInsurances = $this->insuranceService->filterByType(
$selectableInsurances,
$applicant->insurance
);
// Calculate participant's travel price (excluding insurance)
// Calculate travel price for eligibility checks
$travelPrice = $this->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participant->index);
// Get eligible insurances for THIS participant (price tier adjusted)
$eligibleInsurances = $this->insuranceEligibilityService->getEligibleInsurances(
$eligibleInsurances = $this->insuranceService->getEligibleInsurances(
$sameTypeInsurances,
$participant,
$bookingDto,
-219
View File
@@ -1,219 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Traits\SortByPriceTrait;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use Carbon\Carbon;
/**
* Service for evaluating insurance eligibility without circular dependencies.
*
* This service contains the core eligibility logic that can be used by both
* InsuranceMatchingService and BookingPriceCalculatorService.
*/
class InsuranceEligibilityService
{
use SortByPriceTrait;
/**
* Filters insurances based on eligibility criteria.
*
* @param array<Insurance> $insurances Available insurances to filter
* @param ParticipantDto $participant The participant to match insurances for
* @param BookingDto $booking The booking context
* @param float $travelPrice The participant's travel price (excluding insurance)
*
* @return array<Insurance> Filtered array of eligible insurances, sorted by price
*/
public function getEligibleInsurances(
array $insurances,
ParticipantDto $participant,
BookingDto $booking,
float $travelPrice,
): array {
$travelStartDate = $booking->travel->dateFrom;
$travelEndDate = $booking->travel->dateTo;
if (null === $travelStartDate || null === $travelEndDate) {
return []; // Cannot evaluate without travel dates
}
$bookingDate = Carbon::now()->toDateTimeImmutable();
$travelDurationDays = $travelStartDate->diff($travelEndDate)->days;
$eligibleInsurances = array_filter(
$insurances,
fn (Insurance $insurance) => $this->isInsuranceEligible(
$insurance,
$participant,
$booking,
$travelStartDate,
$travelEndDate,
$bookingDate,
$travelPrice,
$travelDurationDays
)
);
return $this->sortByPrice($eligibleInsurances);
}
/**
* Checks if a specific insurance is eligible for given criteria.
*/
private function isInsuranceEligible(
Insurance $insurance,
ParticipantDto $participant,
BookingDto $booking,
\DateTimeImmutable $travelStartDate,
\DateTimeImmutable $travelEndDate,
\DateTimeImmutable $bookingDate,
float $travelPrice,
int $travelDurationDays,
): bool {
// Family insurance constraints
if (false === $this->checkFamilyInsuranceConstraints($insurance, $booking)) {
return false;
}
// Age constraints
if (false === $this->checkAgeConstraints($insurance, $participant, $travelStartDate)) {
return false;
}
// Travel date constraints
if (false === $this->checkTravelDateConstraints($insurance, $travelStartDate, $travelEndDate)) {
return false;
}
// Booking date constraints
if (false === $this->checkBookingDateConstraints($insurance, $bookingDate)) {
return false;
}
// Travel price constraints
if (false === $this->checkTravelPriceConstraints($insurance, $travelPrice)) {
return false;
}
// Travel duration constraints
if (false === $this->checkTravelDurationConstraints($insurance, $travelDurationDays)) {
return false;
}
return true;
}
private function checkFamilyInsuranceConstraints(Insurance $insurance, BookingDto $booking): bool
{
// Family booking detection only available in create mode
if (BookingDto::MODE_EDIT === $booking->getMode()) {
return true; // Skip family constraints for edit mode
}
$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 true;
}
private function checkAgeConstraints(Insurance $insurance, ParticipantDto $participant, \DateTimeImmutable $travelStartDate): bool
{
$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 true;
}
// Check minimum age
if (null !== $insurance->ageFrom && $participantAge < $insurance->ageFrom) {
return false;
}
// Check maximum age
if (null !== $insurance->ageTo && $participantAge > $insurance->ageTo) {
return false;
}
return true;
}
private function checkTravelDateConstraints(Insurance $insurance, \DateTimeImmutable $travelStartDate, \DateTimeImmutable $travelEndDate): bool
{
// Check travel start date
if (null !== $insurance->travelDateFrom && $travelStartDate < $insurance->travelDateFrom) {
return false;
}
if (null !== $insurance->travelDateTo && $travelStartDate > $insurance->travelDateTo) {
return false;
}
// Check travel end date
if (null !== $insurance->travelDateTo && $travelEndDate > $insurance->travelDateTo) {
return false;
}
return true;
}
private function checkBookingDateConstraints(Insurance $insurance, \DateTimeImmutable $bookingDate): bool
{
// Check booking window start
if (null !== $insurance->bookingDateFrom && $bookingDate < $insurance->bookingDateFrom) {
return false;
}
// Check booking window end
if (null !== $insurance->bookingDateTo && $bookingDate > $insurance->bookingDateTo) {
return false;
}
return true;
}
private function checkTravelPriceConstraints(Insurance $insurance, float $travelPrice): bool
{
// Check minimum price
if (null !== $insurance->travelPriceFrom && $travelPrice < $insurance->travelPriceFrom) {
return false;
}
// Check maximum price
if (null !== $insurance->travelPriceTo && $travelPrice > $insurance->travelPriceTo) {
return false;
}
return true;
}
private function checkTravelDurationConstraints(Insurance $insurance, int $travelDurationDays): bool
{
// Check minimum duration
if (null !== $insurance->travelDurationFrom && $travelDurationDays < $insurance->travelDurationFrom) {
return false;
}
// Check maximum duration
if (null !== $insurance->travelDurationTo && $travelDurationDays > $insurance->travelDurationTo) {
return false;
}
return true;
}
}
-131
View File
@@ -1,131 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Traits\SortByPriceTrait;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
/**
* Service for matching insurances to participants based on eligibility criteria.
*
* Evaluates insurance constraints including age limits, travel dates, booking windows,
* travel price ranges, and duration limits to determine which insurances are available
* for specific participants and booking scenarios.
*/
class InsuranceMatchingService
{
use SortByPriceTrait;
public function __construct(
private readonly BookingPriceCalculatorService $priceCalculatorService,
private readonly InsuranceTypeFilterService $insuranceTypeFilterService,
private readonly InsuranceEligibilityService $insuranceEligibilityService,
) {
}
/**
* Filters insurances based on participant and booking criteria.
*
* @param array<Insurance> $insurances Available insurances to filter
* @param ParticipantDto $participant The participant to match insurances for
* @param BookingDto $booking The booking context for additional criteria
*
* @return array<Insurance> Filtered array of eligible insurances
*/
public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingDto $booking): array
{
// Calculate travel price for this participant
$travelPrice = $this->calculateTravelPrice($booking, $participant->index);
// Delegate to InsuranceEligibilityService
return $this->insuranceEligibilityService->getEligibleInsurances(
$insurances,
$participant,
$booking,
$travelPrice
);
}
/**
* Auto-reassigns an insurance to the same type with appropriate price tier.
*
* 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.
*
* @param array<Insurance> $availableInsurances All available insurances
* @param Insurance $currentInsurance The currently selected insurance
* @param ParticipantDto $participant The participant to reassign for
* @param BookingDto $booking The booking context
*
* @return Insurance|null The reassigned insurance or null if no suitable match found
*/
public function reassignInsuranceForPriceChange(array $availableInsurances, Insurance $currentInsurance, ParticipantDto $participant, BookingDto $booking): ?Insurance
{
// Group insurances of the same type
$sameTypeInsurances = $this->insuranceTypeFilterService->filterByType($availableInsurances, $currentInsurance);
// 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.
*
* FUTURE FEATURE: This method will be used when implementing the "applicant assigns
* insurance to all participants" feature. The applicant's selection will be propagated
* to all participants with automatic price tier adjustment based on individual prices.
*
* 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 BookingDto $booking The booking with all participants
*
* @return array<int, Insurance|null> Array indexed by participant index with assigned insurances
*
* @internal Reserved for future feature implementation
*/
public function batchAssignInsuranceToParticipants(array $availableInsurances, Insurance $selectedInsurance, BookingDto $booking): array
{
$assignments = [];
// Group insurances of the same type
$sameTypeInsurances = $this->insuranceTypeFilterService->filterByType($availableInsurances, $selectedInsurance);
// 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;
}
/**
* 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 BookingDto $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(BookingDto $booking, int $participantIndex): float
{
// Use the price calculator to get the participant's individual price excluding insurance
return $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($booking, $participantIndex);
}
}
+353
View File
@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Travel;
use App\BusProNet\Traits\SortByPriceTrait;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use Carbon\Carbon;
use Spatie\Blink\Blink;
/**
* Consolidated service for all insurance-related operations.
*
* Handles insurance eligibility evaluation, filtering, caching, and assignment logic.
* Uses request-scoped caching via Blink to optimize performance for bookings with many participants.
* This service is stateless and has no dependencies to avoid circular dependency issues.
*/
class InsuranceService
{
use SortByPriceTrait;
private const CACHE_KEY_PREFIX = 'selectable_insurances_';
/**
* Returns selectable (non-complementary) insurances for a travel with request-scoped caching.
*
* Complementary insurances are only available as part of packages and cannot
* be directly selected by users. This method filters them out and caches the
* result per travel for efficient repeated access.
*
* @param Travel $travel The travel to get selectable insurances for
*
* @return array<Insurance> Array of selectable insurances
*/
public function getSelectableInsurances(Travel $travel): array
{
$cacheKey = self::CACHE_KEY_PREFIX.$travel->id;
return Blink::global()->once($cacheKey, function () use ($travel) {
return $this->filterNonComplementary($travel->insurances ?? []);
});
}
/**
* Filters insurances based on participant and booking criteria.
*
* @param array<Insurance> $insurances Available insurances to filter
* @param ParticipantDto $participant The participant to match insurances for
* @param BookingDto $booking The booking context for additional criteria
* @param float $travelPrice The participant's travel price (excluding insurance) for price tier matching
*
* @return array<Insurance> Filtered array of eligible insurances, sorted by price
*/
public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingDto $booking, float $travelPrice): array
{
$travelStartDate = $booking->travel->dateFrom;
$travelEndDate = $booking->travel->dateTo;
if (null === $travelStartDate || null === $travelEndDate) {
return []; // Cannot evaluate without travel dates
}
$bookingDate = Carbon::now()->toDateTimeImmutable();
$travelDurationDays = $travelStartDate->diff($travelEndDate)->days;
$eligibleInsurances = array_filter(
$insurances,
fn (Insurance $insurance) => $this->isInsuranceEligible(
$insurance,
$participant,
$booking,
$travelStartDate,
$travelEndDate,
$bookingDate,
$travelPrice,
$travelDurationDays
)
);
return $this->sortByPrice($eligibleInsurances);
}
/**
* Auto-reassigns an insurance to the same type with appropriate price tier.
*
* 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.
*
* @param array<Insurance> $availableInsurances All available insurances
* @param Insurance $currentInsurance The currently selected insurance
* @param ParticipantDto $participant The participant to reassign for
* @param BookingDto $booking The booking context
* @param float $travelPrice The participant's travel price (excluding insurance)
*
* @return Insurance|null The reassigned insurance or null if no suitable match found
*/
public function reassignInsuranceForPriceChange(array $availableInsurances, Insurance $currentInsurance, ParticipantDto $participant, BookingDto $booking, float $travelPrice): ?Insurance
{
// Group insurances of the same type
$sameTypeInsurances = $this->filterByType($availableInsurances, $currentInsurance);
// Get eligible insurances for this participant
$eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking, $travelPrice);
// 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 BookingDto $booking The booking with all participants
* @param array<int, float> $participantPrices Map of participant index to travel price (excluding insurance)
*
* @return array<int, Insurance|null> Array indexed by participant index with assigned insurances
*/
public function batchAssignInsuranceToParticipants(array $availableInsurances, Insurance $selectedInsurance, BookingDto $booking, array $participantPrices): array
{
$assignments = [];
// Group insurances of the same type
$sameTypeInsurances = $this->filterByType($availableInsurances, $selectedInsurance);
// Assign appropriate insurance to each participant
foreach ($booking->getParticipants() as $index => $participant) {
$travelPrice = $participantPrices[$index] ?? 0.0;
$eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking, $travelPrice);
$assignments[$index] = !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
}
return $assignments;
}
/**
* Filters insurances by type based on label (for packages) or subType (for individual insurances).
*
* This method groups insurances of the same type together for reassignment or batch assignment.
* Insurance type matching strategy:
* - **Packages**: Match by label + familyInsurance (packages with same label are different price tiers)
* - **Individual insurances**: Match by subType + familyInsurance
*
* Example: "Reise-Rücktritt + Selbstbehaltübernahme" at €10, €14, €26 are the same type,
* but different from "Reiseschutz Platin Auto/Bahn/Bus (Europa) + Selbstbehaltübernahme".
*
* @param array<Insurance> $insurances All available insurances to filter
* @param Insurance $referenceInsurance The insurance to match against
*
* @return array<Insurance> Filtered insurances of the same type
*/
public function filterByType(array $insurances, Insurance $referenceInsurance): array
{
// For packages, match by label (packages with same label are different price tiers of same type)
if (true === $referenceInsurance->package) {
return array_filter(
$insurances,
fn (Insurance $insurance) => true === $insurance->package
&& $insurance->label === $referenceInsurance->label
&& $insurance->familyInsurance === $referenceInsurance->familyInsurance
);
}
// For individual insurances, match by subType
return array_filter(
$insurances,
fn (Insurance $insurance) => false === $insurance->package
&& $insurance->subType === $referenceInsurance->subType
&& $insurance->familyInsurance === $referenceInsurance->familyInsurance
);
}
/**
* Filters out complementary insurances from an insurance array.
*
* Complementary insurances are only available as part of packages
* and cannot be directly selected by users.
*
* @param array<Insurance> $insurances Array of insurances to filter
*
* @return array<Insurance> Array containing only non-complementary insurances with reset keys
*/
private function filterNonComplementary(array $insurances): array
{
return array_values(
array_filter($insurances, fn (Insurance $insurance) => false === $insurance->complementary)
);
}
/**
* Checks if a specific insurance is eligible for given criteria.
*/
private function isInsuranceEligible(
Insurance $insurance,
ParticipantDto $participant,
BookingDto $booking,
\DateTimeImmutable $travelStartDate,
\DateTimeImmutable $travelEndDate,
\DateTimeImmutable $bookingDate,
float $travelPrice,
int $travelDurationDays,
): bool {
// Family insurance constraints
if (false === $this->checkFamilyInsuranceConstraints($insurance, $booking)) {
return false;
}
// Age constraints
if (false === $this->checkAgeConstraints($insurance, $participant, $travelStartDate)) {
return false;
}
// Travel date constraints
if (false === $this->checkTravelDateConstraints($insurance, $travelStartDate, $travelEndDate)) {
return false;
}
// Booking date constraints
if (false === $this->checkBookingDateConstraints($insurance, $bookingDate)) {
return false;
}
// Travel price constraints
if (false === $this->checkTravelPriceConstraints($insurance, $travelPrice)) {
return false;
}
// Travel duration constraints
if (false === $this->checkTravelDurationConstraints($insurance, $travelDurationDays)) {
return false;
}
return true;
}
private function checkFamilyInsuranceConstraints(Insurance $insurance, BookingDto $booking): bool
{
// Family booking detection only available in create mode
if (BookingDto::MODE_EDIT === $booking->getMode()) {
return true; // Skip family constraints for edit mode
}
$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 true;
}
private function checkAgeConstraints(Insurance $insurance, ParticipantDto $participant, \DateTimeImmutable $travelStartDate): bool
{
$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 true;
}
// Check minimum age
if (null !== $insurance->ageFrom && $participantAge < $insurance->ageFrom) {
return false;
}
// Check maximum age
if (null !== $insurance->ageTo && $participantAge > $insurance->ageTo) {
return false;
}
return true;
}
private function checkTravelDateConstraints(Insurance $insurance, \DateTimeImmutable $travelStartDate, \DateTimeImmutable $travelEndDate): bool
{
// Check travel start date
if (null !== $insurance->travelDateFrom && $travelStartDate < $insurance->travelDateFrom) {
return false;
}
if (null !== $insurance->travelDateTo && $travelStartDate > $insurance->travelDateTo) {
return false;
}
// Check travel end date
if (null !== $insurance->travelDateTo && $travelEndDate > $insurance->travelDateTo) {
return false;
}
return true;
}
private function checkBookingDateConstraints(Insurance $insurance, \DateTimeImmutable $bookingDate): bool
{
// Check booking window start
if (null !== $insurance->bookingDateFrom && $bookingDate < $insurance->bookingDateFrom) {
return false;
}
// Check booking window end
if (null !== $insurance->bookingDateTo && $bookingDate > $insurance->bookingDateTo) {
return false;
}
return true;
}
private function checkTravelPriceConstraints(Insurance $insurance, float $travelPrice): bool
{
// Check minimum price
if (null !== $insurance->travelPriceFrom && $travelPrice < $insurance->travelPriceFrom) {
return false;
}
// Check maximum price
if (null !== $insurance->travelPriceTo && $travelPrice > $insurance->travelPriceTo) {
return false;
}
return true;
}
private function checkTravelDurationConstraints(Insurance $insurance, int $travelDurationDays): bool
{
// Check minimum duration
if (null !== $insurance->travelDurationFrom && $travelDurationDays < $insurance->travelDurationFrom) {
return false;
}
// Check maximum duration
if (null !== $insurance->travelDurationTo && $travelDurationDays > $insurance->travelDurationTo) {
return false;
}
return true;
}
}
@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Insurance;
/**
* Service for filtering insurances by type without circular dependencies.
*/
class InsuranceTypeFilterService
{
/**
* Filters out complementary insurances from an insurance array.
*
* Complementary insurances are only available as part of packages
* and cannot be directly selected by users.
*
* @param array<Insurance> $insurances Array of insurances to filter
*
* @return array<Insurance> Array containing only non-complementary insurances with reset keys
*/
public function filterNonComplementary(array $insurances): array
{
return array_values(
array_filter($insurances, fn (Insurance $insurance) => false === $insurance->complementary)
);
}
/**
* Filters insurances by type based on label (for packages) or subType (for individual insurances).
*
* This method groups insurances of the same type together for reassignment or batch assignment.
* Insurance type matching strategy:
* - **Packages**: Match by label + familyInsurance (packages with same label are different price tiers)
* - **Individual insurances**: Match by subType + familyInsurance
*
* Example: "Reise-Rücktritt + Selbstbehaltübernahme" at €10, €14, €26 are the same type,
* but different from "Reiseschutz Platin Auto/Bahn/Bus (Europa) + Selbstbehaltübernahme".
*
* @param array<Insurance> $insurances All available insurances to filter
* @param Insurance $referenceInsurance The insurance to match against
*
* @return array<Insurance> Filtered insurances of the same type
*/
public function filterByType(array $insurances, Insurance $referenceInsurance): array
{
// For packages, match by label (packages with same label are different price tiers of same type)
if (true === $referenceInsurance->package) {
return array_filter(
$insurances,
fn (Insurance $insurance) => true === $insurance->package
&& $insurance->label === $referenceInsurance->label
&& $insurance->familyInsurance === $referenceInsurance->familyInsurance
);
}
// For individual insurances, match by subType
return array_filter(
$insurances,
fn (Insurance $insurance) => false === $insurance->package
&& $insurance->subType === $referenceInsurance->subType
&& $insurance->familyInsurance === $referenceInsurance->familyInsurance
);
}
}