Files
myep/src/Service/InsuranceManager.php
T
2026-08-05 15:11:37 +02:00

435 lines
17 KiB
PHP

<?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;
/**
* Consolidated service for all insurance-related operations.
*
* Handles insurance eligibility evaluation, filtering, caching, and assignment logic.
* Uses request-scoped instance-level caching to optimize performance for bookings with many participants.
* This service is stateless and has no dependencies to avoid circular dependency issues.
*/
class InsuranceManager
{
use SortByPriceTrait;
/** @var array<string, array<Insurance>> Request-scoped cache for selectable insurances */
private array $selectableInsurancesCache = [];
/** @var array<string, array<Insurance>> 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<Insurance> 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<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
}
// 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);
})();
}
/**
* Returns eligible insurances for a participant, pricing family and non-family
* insurances on the correct basis.
*
* Family insurances must be priced by the total booking price, not the individual
* participant's price - this splits the given insurances by type and evaluates
* each group against the appropriate price before merging the results back together.
*
* @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 $individualPrice The participant's individual travel price (excluding insurance)
* @param float $totalBookingPrice The total booking price across all participants (excluding insurance)
*
* @return array<Insurance> Filtered array of eligible insurances, sorted by price
*/
public function getEligibleInsurancesForParticipant(
array $insurances,
ParticipantDto $participant,
BookingDto $booking,
float $individualPrice,
float $totalBookingPrice,
): array {
$nonFamilyInsurances = array_values(array_filter($insurances, static fn (Insurance $i) => false === $i->familyInsurance));
$familyInsurances = array_values(array_filter($insurances, static fn (Insurance $i) => true === $i->familyInsurance));
$eligibleInsurances = $this->getEligibleInsurances($nonFamilyInsurances, $participant, $booking, $individualPrice);
if (!empty($familyInsurances)) {
$eligibleFamilyInsurances = $this->getEligibleInsurances($familyInsurances, $participant, $booking, $totalBookingPrice);
$eligibleInsurances = $this->sortByPrice(array_merge($eligibleInsurances, $eligibleFamilyInsurances));
}
return $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 {
// Group insurances of the same type
$sameTypeInsurances = $this->filterByType($availableInsurances, $selectedInsurance);
// Family insurance: assign to applicant only, priced by total booking price
if (true === $selectedInsurance->familyInsurance) {
$totalPrice = array_sum($participantPrices);
$applicant = $booking->getParticipant(0);
$eligibleInsurances = null !== $applicant
? $this->getEligibleInsurances($sameTypeInsurances, $applicant, $booking, $totalPrice)
: [];
$tieredInsurance = !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
$assignments = [];
foreach ($booking->getParticipants() as $index => $participant) {
$assignments[$index] = 0 === $index ? $tieredInsurance : null;
}
return $assignments;
}
// Non-family insurance: assign to each participant with their individual price tier
$assignments = [];
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, $participant, $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, ParticipantDto $participant, BookingDto $booking): bool
{
if (true === $insurance->familyInsurance) {
if (false === $booking->isFamilyBooking()) {
return false; // Family insurance only available for family bookings
}
if (0 !== $participant->index) {
return false; // Family insurance only assignable to the applicant
}
}
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;
}
}