> Request-scoped cache for selectable insurances */ private array $selectableInsurancesCache = []; /** @var array> Request-scoped cache for eligible insurances */ private array $eligibleInsurancesCache = []; /** * 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 Array of selectable insurances */ public function getSelectableInsurances(Travel $travel): array { $cacheKey = 'selectable_insurances_'.$travel->id; return $this->selectableInsurancesCache[$cacheKey] ??= $this->filterNonComplementary($travel->insurances ?? []); } /** * Filters insurances based on participant and booking criteria. * * @param array $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 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 } // Generate cache key based on all eligibility criteria // Use spl_object_id() for insurance identification (not $insurance->id): // - Works with test fixtures where IDs may be null // - Negligible overhead (~0.002ms for 20 insurances vs ~50ms saved by caching) // - Insurance objects are stable within a single request (loaded from session) $insuranceHashes = array_map(fn (Insurance $i) => spl_object_id($i), $insurances); $cacheKey = sprintf( 'eligible_insurances_%d_%s_%s_%s_%s_%s', $participant->index, number_format(round($travelPrice, 2), 2, '.', ''), $booking->getMode(), $travelStartDate->format('Y-m-d'), $participant->dateOfBirth?->format('Y-m-d') ?? 'no_dob', md5(implode('_', $insuranceHashes)) ); return $this->eligibleInsurancesCache[$cacheKey] ??= (function () use ($insurances, $participant, $booking, $travelStartDate, $travelEndDate, $travelPrice) { $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 $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 $availableInsurances All available insurances * @param Insurance $selectedInsurance The insurance selected by the applicant * @param BookingDto $booking The booking with all participants * @param array $participantPrices Map of participant index to travel price (excluding insurance) * * @return array 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 $insurances All available insurances to filter * @param Insurance $referenceInsurance The insurance to match against * * @return array 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 $insurances Array of insurances to filter * * @return array 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; } }