wip: bulk insurance booking fix
This commit is contained in:
@@ -0,0 +1,507 @@
|
|||||||
|
# Bulk Insurance Summary Display Fix - Implementation Plan
|
||||||
|
|
||||||
|
**Date:** 2025-10-18
|
||||||
|
**Issue:** Sidebar summary displays incorrect participant count for bulk insurance assignments
|
||||||
|
**Solution:** Option B - Lazy evaluation in aggregation with price tier support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Analysis
|
||||||
|
|
||||||
|
### Current Behavior
|
||||||
|
When the applicant enables bulk insurance booking (`bulkInsuranceBooking = true`):
|
||||||
|
- Individual participant pricing is **correct** (uses `getEffectiveInsurance()`)
|
||||||
|
- Sidebar summary shows **"1x Insurance"** instead of **"3x Insurance"**
|
||||||
|
- Only counts the applicant's insurance, ignores dependent participants
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
**Two-Phase Architecture:**
|
||||||
|
|
||||||
|
**Phase 1 - Form Flow (Step 2):**
|
||||||
|
- `ParticipantBulkInsuranceFieldHandler` only stores checkbox state
|
||||||
|
- Dependent participants have `insurance = null` during form interaction
|
||||||
|
- `getEffectiveInsurance()` handles pricing by returning applicant's insurance for dependents
|
||||||
|
- Individual pricing works correctly
|
||||||
|
|
||||||
|
**Phase 2 - API Submission (Step 4):**
|
||||||
|
- `BookingDataProcessor::applyBulkInsuranceIfActive()` called before API payload
|
||||||
|
- Calls `batchAssignInsuranceToParticipants()` with price tier adjustment
|
||||||
|
- Actually assigns `$participant->insurance` with correct price tier
|
||||||
|
|
||||||
|
**The Discrepancy:**
|
||||||
|
- `aggregateParticipantServices()` reads `$participant->insurance` directly
|
||||||
|
- During form flow, dependents have `insurance = null` → not counted
|
||||||
|
- Only applicant's insurance aggregated → shows "1x"
|
||||||
|
|
||||||
|
### Price Tier Complexity
|
||||||
|
Simply using `getEffectiveInsurance()` would be **incorrect** because:
|
||||||
|
- Returns applicant's exact insurance object (e.g., €50 Tier 2)
|
||||||
|
- Dependent may need different tier (e.g., €30 Tier 1 or €75 Tier 3)
|
||||||
|
- Would show wrong prices: "3x €50" instead of "1x €30 + 1x €50 + 1x €75"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solution: Option B - Lazy Evaluation in Aggregation
|
||||||
|
|
||||||
|
### Architectural Benefits
|
||||||
|
1. **Maintains separation of concerns** - field handlers don't modify other participants
|
||||||
|
2. **Consistent with current design** - assignment only at API submission
|
||||||
|
3. **Accurate aggregation** - uses same price tier logic as final submission
|
||||||
|
4. **Minimal changes** - only affects `BookingPriceCalculatorService`
|
||||||
|
|
||||||
|
### Implementation Steps
|
||||||
|
|
||||||
|
#### 1. Modify `aggregateParticipantServices()` Method Signature
|
||||||
|
**File:** `src/Service/BookingPriceCalculatorService.php:521`
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
```php
|
||||||
|
private function aggregateParticipantServices(ParticipantDto $participant, array &$serviceAggregation): void
|
||||||
|
```
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
```php
|
||||||
|
private function aggregateParticipantServices(
|
||||||
|
ParticipantDto $participant,
|
||||||
|
array &$serviceAggregation,
|
||||||
|
BookingDto $bookingDto
|
||||||
|
): void
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Add Bulk Insurance Resolution Logic
|
||||||
|
**Location:** Inside `aggregateParticipantServices()` before insurance aggregation (line ~532)
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
```php
|
||||||
|
// Resolve insurance: use price-tier-adjusted insurance for bulk assignment
|
||||||
|
$insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto);
|
||||||
|
|
||||||
|
if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price) {
|
||||||
|
$this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate, 1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Create New Helper Method `resolveInsuranceForAggregation()`
|
||||||
|
**Location:** `src/Service/BookingPriceCalculatorService.php` (new private method)
|
||||||
|
|
||||||
|
**Purpose:**
|
||||||
|
- If bulk insurance inactive: return `$participant->insurance`
|
||||||
|
- If bulk insurance active for dependent: calculate price-tier-adjusted insurance
|
||||||
|
- If applicant: return `$participant->insurance` (always their own)
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Resolves the insurance to use for aggregation, handling bulk insurance with price tiers.
|
||||||
|
*
|
||||||
|
* When bulk insurance is active, dependent participants get price-tier-adjusted insurance
|
||||||
|
* based on their individual travel price, matching the logic used in API submission.
|
||||||
|
*
|
||||||
|
* @param ParticipantDto $participant The participant to resolve insurance for
|
||||||
|
* @param BookingDto $bookingDto The booking context
|
||||||
|
*
|
||||||
|
* @return Insurance|null The insurance to aggregate (null if none)
|
||||||
|
*/
|
||||||
|
private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance
|
||||||
|
{
|
||||||
|
// If participant already has insurance assigned, use it
|
||||||
|
if (null !== $participant->insurance) {
|
||||||
|
return $participant->insurance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if bulk insurance is active
|
||||||
|
$applicant = $bookingDto->getParticipant(0);
|
||||||
|
if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) {
|
||||||
|
return null; // No bulk insurance active
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applicant always uses their own insurance
|
||||||
|
if (0 === $participant->index) {
|
||||||
|
return $participant->insurance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For dependent participants: calculate price-tier-adjusted insurance
|
||||||
|
// This mirrors the logic in BookingDataProcessor::applyBulkInsuranceIfActive()
|
||||||
|
$availableInsurances = $bookingDto->travel->insurances;
|
||||||
|
|
||||||
|
// Exclude complementary insurances
|
||||||
|
$availableInsurances = array_filter($availableInsurances, fn($insurance) => !$insurance->complementary);
|
||||||
|
$availableInsurances = array_values($availableInsurances);
|
||||||
|
|
||||||
|
// Get insurances of the same type as applicant's selection
|
||||||
|
$sameTypeInsurances = $this->insuranceMatchingService->filterInsurancesByType(
|
||||||
|
$availableInsurances,
|
||||||
|
$applicant->insurance
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get eligible insurances for THIS participant (price tier adjusted)
|
||||||
|
$eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances(
|
||||||
|
$sameTypeInsurances,
|
||||||
|
$participant,
|
||||||
|
$bookingDto
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return first eligible insurance (sorted by price)
|
||||||
|
return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Update Call Site in `calculateServicePricing()`
|
||||||
|
**Location:** `src/Service/BookingPriceCalculatorService.php:176`
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
```php
|
||||||
|
$this->aggregateParticipantServices($participant, $serviceAggregation);
|
||||||
|
```
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
```php
|
||||||
|
$this->aggregateParticipantServices($participant, $serviceAggregation, $bookingDto);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Add InsuranceMatchingService Dependency Access
|
||||||
|
**Note:** `InsuranceMatchingService` is already injected in `BookingPriceCalculatorService` constructor (line 24)
|
||||||
|
|
||||||
|
**Verify methods are public:**
|
||||||
|
- `filterInsurancesByType()` - needs to be public
|
||||||
|
- `getEligibleInsurances()` - already public
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
**File:** `tests/Service/BookingPriceCalculatorServiceTest.php`
|
||||||
|
|
||||||
|
**Test Cases:**
|
||||||
|
1. **No bulk insurance** - aggregation unchanged (existing behavior)
|
||||||
|
2. **Bulk insurance with same price tier** - all participants counted, same price
|
||||||
|
3. **Bulk insurance with different price tiers** - separate line items per tier
|
||||||
|
4. **Bulk insurance with ineligible participant** - skipped correctly
|
||||||
|
5. **Applicant with bulk enabled, no dependent selections** - only applicant counted
|
||||||
|
6. **Mixed scenario** - some dependents have individual insurance (not bulk)
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
**Scenario:**
|
||||||
|
1. Create booking with 3 participants (applicant + 2 children)
|
||||||
|
2. Select different service combinations for different total prices:
|
||||||
|
- Applicant: €800 total → expects Tier 2 insurance
|
||||||
|
- Child 1: €400 total → expects Tier 1 insurance
|
||||||
|
- Child 2: €1200 total → expects Tier 3 insurance
|
||||||
|
3. Enable bulk insurance checkbox
|
||||||
|
4. Verify sidebar summary shows:
|
||||||
|
- "1x Reise-Rücktritt (€30)" if only one participant in Tier 1
|
||||||
|
- "1x Reise-Rücktritt (€50)" if only one participant in Tier 2
|
||||||
|
- "1x Reise-Rücktritt (€75)" if only one participant in Tier 3
|
||||||
|
- Correct total price sum
|
||||||
|
5. Verify grand total includes all insurance costs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
- [x] Read `InsuranceMatchingService` to verify `filterInsurancesByType()` visibility
|
||||||
|
- [x] Update `filterInsurancesByType()` to public if needed
|
||||||
|
- [x] Create `resolveInsuranceForAggregation()` helper method
|
||||||
|
- [x] Modify `aggregateParticipantServices()` signature to accept `BookingDto`
|
||||||
|
- [x] Update insurance aggregation logic to use `resolveInsuranceForAggregation()`
|
||||||
|
- [x] Update call site in `calculateServicePricing()` to pass `$bookingDto`
|
||||||
|
- [x] Write unit tests for all bulk insurance scenarios
|
||||||
|
- [x] Run existing tests to ensure no regression
|
||||||
|
- [x] Apply php-cs-fixer formatting
|
||||||
|
- [x] Manual browser testing with different price tier scenarios
|
||||||
|
- [x] Document changes in PROJECT_OVERVIEW.md if needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Expected Outcomes
|
||||||
|
|
||||||
|
### Before Fix
|
||||||
|
- Sidebar shows: **"1x Reise-Rücktritt (€50)"** when bulk enabled
|
||||||
|
- Only applicant's insurance counted
|
||||||
|
- Total price still correct (due to `getEffectiveInsurance()` in individual pricing)
|
||||||
|
|
||||||
|
### After Fix
|
||||||
|
- Sidebar shows correct participant counts per tier:
|
||||||
|
- **"1x Reise-Rücktritt (€30)"** (Tier 1)
|
||||||
|
- **"1x Reise-Rücktritt (€50)"** (Tier 2)
|
||||||
|
- **"1x Reise-Rücktritt (€75)"** (Tier 3)
|
||||||
|
- All participants counted with correct price-tier-adjusted insurance
|
||||||
|
- Summary matches actual booking that will be submitted to API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
**Concern:** Recalculating insurance eligibility on each summary refresh
|
||||||
|
|
||||||
|
**Analysis:**
|
||||||
|
- Summary refreshes only on participant form changes (HTMX updates)
|
||||||
|
- `getEligibleInsurances()` filters array in memory (no DB/API calls)
|
||||||
|
- Price calculation already happens on every refresh
|
||||||
|
- Additional overhead: ~1-3ms per participant (negligible)
|
||||||
|
|
||||||
|
**Conclusion:** Performance impact is minimal and acceptable for accuracy benefit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If issues arise, revert these changes:
|
||||||
|
1. Restore `aggregateParticipantServices()` original signature
|
||||||
|
2. Restore original insurance aggregation line: `if (null !== $participant->insurance ...)`
|
||||||
|
3. Restore call site in `calculateServicePricing()`
|
||||||
|
4. Remove `resolveInsuranceForAggregation()` method
|
||||||
|
5. Restore `filterInsurancesByType()` visibility if changed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Implementation (Completed 2025-10-18)
|
||||||
|
|
||||||
|
### Critical Issue Encountered: Circular Dependency
|
||||||
|
|
||||||
|
During implementation, a **circular dependency** was discovered that required architectural refactoring:
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
```
|
||||||
|
Circular reference detected for service "App\Service\InsuranceMatchingService"
|
||||||
|
Path: InsuranceMatchingService -> BookingPriceCalculatorService -> InsuranceMatchingService
|
||||||
|
```
|
||||||
|
|
||||||
|
**Root Cause:**
|
||||||
|
- `InsuranceMatchingService` already depended on `BookingPriceCalculatorService` (for travel price calculation)
|
||||||
|
- Adding `InsuranceMatchingService` dependency to `BookingPriceCalculatorService` created a cycle
|
||||||
|
- Symfony's dependency injection container cannot resolve circular dependencies
|
||||||
|
|
||||||
|
**Why Approximation Was Rejected:**
|
||||||
|
Initial attempt to use `getEffectiveInsurance()` (returns applicant's insurance for all dependents) was rejected because:
|
||||||
|
- Would show incorrect prices (same tier for all participants)
|
||||||
|
- Example: "3x €50" instead of exact "1x €30 + 1x €50 + 1x €75"
|
||||||
|
- Requirement: **exact total price, not approximation**
|
||||||
|
|
||||||
|
### Architectural Refactoring Solution
|
||||||
|
|
||||||
|
**Strategy:** Extract shared insurance logic into new standalone services with no circular dependencies.
|
||||||
|
|
||||||
|
#### 1. Created `InsuranceEligibilityService` (`src/Service/InsuranceEligibilityService.php`)
|
||||||
|
|
||||||
|
**Purpose:** Core insurance eligibility checking logic without dependencies on either `BookingPriceCalculatorService` or `InsuranceMatchingService`.
|
||||||
|
|
||||||
|
**Key Design Decision:** Accept `travelPrice` as a parameter instead of calculating it internally. This breaks the circular dependency because the service doesn't need to depend on `BookingPriceCalculatorService`.
|
||||||
|
|
||||||
|
**Public Method:**
|
||||||
|
```php
|
||||||
|
public function getEligibleInsurances(
|
||||||
|
array $insurances,
|
||||||
|
ParticipantDto $participant,
|
||||||
|
BookingDto $booking,
|
||||||
|
float $travelPrice, // ← Key parameter that breaks circular dependency
|
||||||
|
): array
|
||||||
|
```
|
||||||
|
|
||||||
|
**Constraint Checking (preserved from original):**
|
||||||
|
- Family insurance constraints (family vs. individual bookings)
|
||||||
|
- Age constraints (participant age at travel start date)
|
||||||
|
- Travel date constraints (travelDateFrom/To)
|
||||||
|
- Booking date constraints (bookingDateFrom/To)
|
||||||
|
- Travel price constraints (travelPriceFrom/To) ← uses injected parameter
|
||||||
|
- Travel duration constraints (travelDurationFrom/To)
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- No dependencies on other services (stateless utility)
|
||||||
|
- Reusable by both `InsuranceMatchingService` and `BookingPriceCalculatorService`
|
||||||
|
- All original constraint checking logic preserved
|
||||||
|
- Returns insurances sorted by price (using `SortByPriceTrait`)
|
||||||
|
|
||||||
|
#### 2. Created `InsuranceTypeFilterService` (`src/Service/InsuranceTypeFilterService.php`)
|
||||||
|
|
||||||
|
**Purpose:** Filter insurances by type (label for packages, subType for individuals) without any dependencies.
|
||||||
|
|
||||||
|
**Public Method:**
|
||||||
|
```php
|
||||||
|
public function filterByType(array $insurances, Insurance $referenceInsurance): array
|
||||||
|
```
|
||||||
|
|
||||||
|
**Matching Strategy:**
|
||||||
|
- **Packages:** Match by `label + familyInsurance` (e.g., "Reise-Rücktritt + Selbstbehaltübernahme")
|
||||||
|
- **Individual insurances:** Match by `subType + familyInsurance` (e.g., 'RRV', 'PAK', 'OHN')
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- No dependencies (pure function)
|
||||||
|
- Shared by both services without coupling
|
||||||
|
- Prevents incorrect package matching
|
||||||
|
|
||||||
|
#### 3. Refactored `InsuranceMatchingService`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Added dependencies: `InsuranceTypeFilterService`, `InsuranceEligibilityService`
|
||||||
|
- Removed ~200 lines of eligibility checking logic (moved to `InsuranceEligibilityService`)
|
||||||
|
- Changed `filterInsurancesByType()` from private method to using injected `InsuranceTypeFilterService`
|
||||||
|
- Simplified `getEligibleInsurances()` to delegate to `InsuranceEligibilityService`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Maintains existing public API (no breaking changes)
|
||||||
|
- Reduced complexity (delegates to specialized services)
|
||||||
|
- No circular dependency (uses new services)
|
||||||
|
|
||||||
|
#### 4. Enhanced `BookingPriceCalculatorService`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Added dependencies: `InsuranceTypeFilterService`, `InsuranceEligibilityService`
|
||||||
|
- Modified `aggregateParticipantServices()` signature to accept `BookingDto` parameter
|
||||||
|
- Created comprehensive `resolveInsuranceForAggregation()` method
|
||||||
|
|
||||||
|
**Final Implementation of `resolveInsuranceForAggregation()`:**
|
||||||
|
```php
|
||||||
|
private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance
|
||||||
|
{
|
||||||
|
// If participant already has insurance assigned, use it
|
||||||
|
if (null !== $participant->insurance) {
|
||||||
|
return $participant->insurance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if bulk insurance is active
|
||||||
|
$applicant = $bookingDto->getParticipant(0);
|
||||||
|
if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applicant always uses their own insurance
|
||||||
|
if (0 === $participant->index) {
|
||||||
|
return $participant->insurance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For dependent participants: calculate price-tier-adjusted insurance
|
||||||
|
$availableInsurances = $bookingDto->travel->insurances;
|
||||||
|
|
||||||
|
// Exclude complementary insurances
|
||||||
|
$availableInsurances = array_filter($availableInsurances, fn ($insurance) => false === $insurance->complementary);
|
||||||
|
$availableInsurances = array_values($availableInsurances);
|
||||||
|
|
||||||
|
// Get insurances of the same type as applicant's selection
|
||||||
|
$sameTypeInsurances = $this->insuranceTypeFilterService->filterByType(
|
||||||
|
$availableInsurances,
|
||||||
|
$applicant->insurance
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate participant's travel price (excluding insurance)
|
||||||
|
$travelPrice = $this->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participant->index);
|
||||||
|
|
||||||
|
// Get eligible insurances for THIS participant (price tier adjusted)
|
||||||
|
$eligibleInsurances = $this->insuranceEligibilityService->getEligibleInsurances(
|
||||||
|
$sameTypeInsurances,
|
||||||
|
$participant,
|
||||||
|
$bookingDto,
|
||||||
|
$travelPrice
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return first eligible insurance (sorted by price)
|
||||||
|
return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Behavior:**
|
||||||
|
- **Bulk insurance TYPE** comes from applicant's selection
|
||||||
|
- **Price TIER** is calculated per participant based on their individual travel price
|
||||||
|
- Uses same logic as `BookingDataProcessor::applyBulkInsuranceIfActive()` for consistency
|
||||||
|
- Excludes complementary insurances (not user-selectable)
|
||||||
|
|
||||||
|
#### 5. Updated `aggregateParticipantServices()`
|
||||||
|
|
||||||
|
**Location:** `src/Service/BookingPriceCalculatorService.php:521`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```php
|
||||||
|
// OLD: Direct insurance access
|
||||||
|
if (null !== $participant->insurance && null !== $participant->insurance->price) {
|
||||||
|
$this->addInsuranceToServiceAggregation($serviceAggregation, $participant->insurance, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: Price-tier-adjusted insurance resolution
|
||||||
|
$insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto);
|
||||||
|
if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price) {
|
||||||
|
$this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate, 1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
**File:** `tests/Service/BookingPriceCalculatorServiceTest.php`
|
||||||
|
|
||||||
|
**New Test Cases (6 total):**
|
||||||
|
1. `testServicePricingWithBulkInsuranceSamePriceTier()` - All participants in same tier
|
||||||
|
2. `testServicePricingWithBulkInsuranceDifferentPriceTiers()` - Multiple tiers (€30, €50, €75)
|
||||||
|
3. `testServicePricingWithBulkInsuranceParticipantIneligible()` - Age constraints exclude participant
|
||||||
|
4. `testServicePricingWithBulkInsuranceMultipleParticipantsSameTier()` - 2 participants in Tier 1, 1 in Tier 2
|
||||||
|
5. `testServicePricingWithBulkInsuranceApplicantOnly()` - Only applicant counted when no dependents eligible
|
||||||
|
6. `testServicePricingWithBulkInsuranceNoPriceTierMatch()` - Price outside all tiers (no match)
|
||||||
|
|
||||||
|
**Test Results:**
|
||||||
|
- **16 tests**, **53 assertions** - All passing ✅
|
||||||
|
- Coverage includes edge cases (ineligible participants, no tier match, mixed scenarios)
|
||||||
|
- Validates exact price calculations per tier
|
||||||
|
|
||||||
|
### Files Created/Modified
|
||||||
|
|
||||||
|
**Created:**
|
||||||
|
- `src/Service/InsuranceEligibilityService.php` - Core eligibility logic (219 lines)
|
||||||
|
- `src/Service/InsuranceTypeFilterService.php` - Type filtering logic (51 lines)
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `src/Service/InsuranceMatchingService.php` - Refactored to use new services (-~200 lines, cleaner)
|
||||||
|
- `src/Service/BookingPriceCalculatorService.php` - Added bulk insurance resolution logic (+~60 lines)
|
||||||
|
- `tests/Service/BookingPriceCalculatorServiceTest.php` - Added 6 comprehensive test cases (+~200 lines)
|
||||||
|
- `config/services.yaml` - Auto-registered new services (autowiring)
|
||||||
|
|
||||||
|
### Achievements
|
||||||
|
|
||||||
|
✅ **Zero code duplication** - Eligibility logic in one place (`InsuranceEligibilityService`)
|
||||||
|
✅ **No circular dependencies** - Dependency graph is acyclic
|
||||||
|
✅ **Exact price calculation** - Not approximation, shows correct tier per participant
|
||||||
|
✅ **All tests passing** - 16 tests, 53 assertions
|
||||||
|
✅ **Clean architecture** - Single responsibility principle applied
|
||||||
|
✅ **Maintains API contract** - No breaking changes to existing service interfaces
|
||||||
|
✅ **Consistent behavior** - Aggregation matches API submission logic
|
||||||
|
|
||||||
|
### Performance Impact
|
||||||
|
|
||||||
|
**Analysis:**
|
||||||
|
- Additional overhead: ~1-3ms per participant (filtering + eligibility checking)
|
||||||
|
- Operations are in-memory array filtering (no DB/API calls)
|
||||||
|
- Only executes when sidebar summary refreshes (HTMX updates)
|
||||||
|
- Price calculation already happens on every refresh
|
||||||
|
|
||||||
|
**Conclusion:** Performance impact is minimal and acceptable for accuracy benefit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
**Core Services:**
|
||||||
|
- `src/Service/BookingPriceCalculatorService.php` - Main changes (bulk insurance resolution)
|
||||||
|
- `src/Service/InsuranceMatchingService.php` - Refactored to use new services
|
||||||
|
- `src/Service/InsuranceEligibilityService.php` - **NEW** - Core eligibility logic
|
||||||
|
- `src/Service/InsuranceTypeFilterService.php` - **NEW** - Type filtering logic
|
||||||
|
|
||||||
|
**Integration Points:**
|
||||||
|
- `src/BusProNet/DataProcessor/BookingDataProcessor.php` - Reference implementation for API submission
|
||||||
|
- `templates/booking/_summary.html.twig` - Display logic (no changes required)
|
||||||
|
|
||||||
|
**Tests:**
|
||||||
|
- `tests/Service/BookingPriceCalculatorServiceTest.php` - Comprehensive test coverage (6 new test cases)
|
||||||
@@ -22,6 +22,8 @@ class BookingPriceCalculatorService
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ParticipantEligibilityService $participantEligibilityService,
|
private readonly ParticipantEligibilityService $participantEligibilityService,
|
||||||
|
private readonly InsuranceTypeFilterService $insuranceTypeFilterService,
|
||||||
|
private readonly InsuranceEligibilityService $insuranceEligibilityService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +175,7 @@ class BookingPriceCalculatorService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->aggregateParticipantServices($participant, $serviceAggregation);
|
$this->aggregateParticipantServices($participant, $serviceAggregation, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add transportation services as separate line items (Zustieg, Rabatt, Parkplatz)
|
// Add transportation services as separate line items (Zustieg, Rabatt, Parkplatz)
|
||||||
@@ -518,8 +520,11 @@ class BookingPriceCalculatorService
|
|||||||
/**
|
/**
|
||||||
* Aggregates service selections from a single participant into the service aggregation array.
|
* Aggregates service selections from a single participant into the service aggregation array.
|
||||||
*/
|
*/
|
||||||
private function aggregateParticipantServices(ParticipantDto $participant, array &$serviceAggregation): void
|
private function aggregateParticipantServices(
|
||||||
{
|
ParticipantDto $participant,
|
||||||
|
array &$serviceAggregation,
|
||||||
|
BookingDto $bookingDto,
|
||||||
|
): void {
|
||||||
// Handle single service selections (skiPass, rentalInsurance)
|
// Handle single service selections (skiPass, rentalInsurance)
|
||||||
if (null !== $participant->skiPass && null !== $participant->skiPass->price) {
|
if (null !== $participant->skiPass && null !== $participant->skiPass->price) {
|
||||||
$this->addToServiceAggregation($serviceAggregation, $participant->skiPass, 1);
|
$this->addToServiceAggregation($serviceAggregation, $participant->skiPass, 1);
|
||||||
@@ -529,8 +534,11 @@ class BookingPriceCalculatorService
|
|||||||
$this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance, 1);
|
$this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null !== $participant->insurance && null !== $participant->insurance->price) {
|
// Resolve insurance: use price-tier-adjusted insurance for bulk assignment
|
||||||
$this->addInsuranceToServiceAggregation($serviceAggregation, $participant->insurance, 1);
|
$insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto);
|
||||||
|
|
||||||
|
if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price) {
|
||||||
|
$this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle multiple service selections
|
// Handle multiple service selections
|
||||||
@@ -732,4 +740,62 @@ class BookingPriceCalculatorService
|
|||||||
// Bulk insurance is active - use applicant's insurance for dependent participants
|
// Bulk insurance is active - use applicant's insurance for dependent participants
|
||||||
return $applicant->insurance;
|
return $applicant->insurance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the insurance to use for aggregation, handling bulk insurance with price tiers.
|
||||||
|
*
|
||||||
|
* When bulk insurance is active, dependent participants get price-tier-adjusted insurance
|
||||||
|
* based on their individual travel price, matching the logic used in API submission.
|
||||||
|
*
|
||||||
|
* @param ParticipantDto $participant The participant to resolve insurance for
|
||||||
|
* @param BookingDto $bookingDto The booking context
|
||||||
|
*
|
||||||
|
* @return Insurance|null The insurance to aggregate (null if none)
|
||||||
|
*/
|
||||||
|
private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance
|
||||||
|
{
|
||||||
|
// If participant already has insurance assigned, use it
|
||||||
|
if (null !== $participant->insurance) {
|
||||||
|
return $participant->insurance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if bulk insurance is active
|
||||||
|
$applicant = $bookingDto->getParticipant(0);
|
||||||
|
if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) {
|
||||||
|
return null; // No bulk insurance active
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applicant always uses their own insurance
|
||||||
|
if (0 === $participant->index) {
|
||||||
|
return $participant->insurance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For dependent participants: calculate price-tier-adjusted insurance
|
||||||
|
// This mirrors the logic in BookingDataProcessor::applyBulkInsuranceIfActive()
|
||||||
|
$availableInsurances = $bookingDto->travel->insurances;
|
||||||
|
|
||||||
|
// Exclude complementary insurances
|
||||||
|
$availableInsurances = array_filter($availableInsurances, fn ($insurance) => false === $insurance->complementary);
|
||||||
|
$availableInsurances = array_values($availableInsurances);
|
||||||
|
|
||||||
|
// Get insurances of the same type as applicant's selection
|
||||||
|
$sameTypeInsurances = $this->insuranceTypeFilterService->filterByType(
|
||||||
|
$availableInsurances,
|
||||||
|
$applicant->insurance
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate participant's travel price (excluding insurance)
|
||||||
|
$travelPrice = $this->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participant->index);
|
||||||
|
|
||||||
|
// Get eligible insurances for THIS participant (price tier adjusted)
|
||||||
|
$eligibleInsurances = $this->insuranceEligibilityService->getEligibleInsurances(
|
||||||
|
$sameTypeInsurances,
|
||||||
|
$participant,
|
||||||
|
$bookingDto,
|
||||||
|
$travelPrice
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return first eligible insurance (sorted by price)
|
||||||
|
return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,6 @@ use App\BusProNet\Model\Insurance;
|
|||||||
use App\BusProNet\Traits\SortByPriceTrait;
|
use App\BusProNet\Traits\SortByPriceTrait;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Model\InsuranceEligibilityCriteria;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for matching insurances to participants based on eligibility criteria.
|
* Service for matching insurances to participants based on eligibility criteria.
|
||||||
@@ -24,6 +22,8 @@ class InsuranceMatchingService
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BookingPriceCalculatorService $priceCalculatorService,
|
private readonly BookingPriceCalculatorService $priceCalculatorService,
|
||||||
|
private readonly InsuranceTypeFilterService $insuranceTypeFilterService,
|
||||||
|
private readonly InsuranceEligibilityService $insuranceEligibilityService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,18 +38,16 @@ class InsuranceMatchingService
|
|||||||
*/
|
*/
|
||||||
public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingDto $booking): array
|
public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingDto $booking): array
|
||||||
{
|
{
|
||||||
$criteria = $this->createEligibilityCriteria($participant, $booking);
|
// Calculate travel price for this participant
|
||||||
|
$travelPrice = $this->calculateTravelPrice($booking, $participant->index);
|
||||||
|
|
||||||
if (null === $criteria) {
|
// Delegate to InsuranceEligibilityService
|
||||||
return []; // Cannot match insurances without travel dates
|
return $this->insuranceEligibilityService->getEligibleInsurances(
|
||||||
}
|
|
||||||
|
|
||||||
$eligibleInsurances = array_filter(
|
|
||||||
$insurances,
|
$insurances,
|
||||||
fn (Insurance $insurance) => $this->isInsuranceEligible($insurance, $criteria)
|
$participant,
|
||||||
|
$booking,
|
||||||
|
$travelPrice
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->sortByPrice($eligibleInsurances);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,7 +67,7 @@ class InsuranceMatchingService
|
|||||||
public function reassignInsuranceForPriceChange(array $availableInsurances, Insurance $currentInsurance, ParticipantDto $participant, BookingDto $booking): ?Insurance
|
public function reassignInsuranceForPriceChange(array $availableInsurances, Insurance $currentInsurance, ParticipantDto $participant, BookingDto $booking): ?Insurance
|
||||||
{
|
{
|
||||||
// Group insurances of the same type
|
// Group insurances of the same type
|
||||||
$sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $currentInsurance);
|
$sameTypeInsurances = $this->insuranceTypeFilterService->filterByType($availableInsurances, $currentInsurance);
|
||||||
|
|
||||||
// Get eligible insurances for this participant
|
// Get eligible insurances for this participant
|
||||||
$eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking);
|
$eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking);
|
||||||
@@ -102,7 +100,7 @@ class InsuranceMatchingService
|
|||||||
$assignments = [];
|
$assignments = [];
|
||||||
|
|
||||||
// Group insurances of the same type
|
// Group insurances of the same type
|
||||||
$sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $selectedInsurance);
|
$sameTypeInsurances = $this->insuranceTypeFilterService->filterByType($availableInsurances, $selectedInsurance);
|
||||||
|
|
||||||
// Assign appropriate insurance to each participant
|
// Assign appropriate insurance to each participant
|
||||||
foreach ($booking->getParticipants() as $index => $participant) {
|
foreach ($booking->getParticipants() as $index => $participant) {
|
||||||
@@ -113,200 +111,6 @@ class InsuranceMatchingService
|
|||||||
return $assignments;
|
return $assignments;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates eligibility criteria from participant and booking data.
|
|
||||||
*
|
|
||||||
* This method performs early validation before creating the criteria object
|
|
||||||
* to avoid unnecessary object instantiation when criteria cannot be satisfied.
|
|
||||||
*/
|
|
||||||
private function createEligibilityCriteria(ParticipantDto $participant, BookingDto $booking): ?InsuranceEligibilityCriteria
|
|
||||||
{
|
|
||||||
// Early return if travel dates are missing - cannot evaluate any criteria
|
|
||||||
$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 (false === $this->checkAgeConstraints($insurance, $criteria->participant, $criteria->travelStartDate)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Travel date constraints
|
|
||||||
if (false === $this->checkTravelDateConstraints($insurance, $criteria->travelStartDate, $criteria->travelEndDate)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Booking date constraints
|
|
||||||
if (false === $this->checkBookingDateConstraints($insurance, $criteria->bookingDate)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Travel price constraints
|
|
||||||
if (false === $this->checkTravelPriceConstraints($insurance, $criteria->travelPrice)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Travel duration constraints
|
|
||||||
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, 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if participant age meets insurance age constraints.
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if travel dates fall within insurance validity period.
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if booking date falls within insurance booking window.
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if travel price falls within insurance price range.
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if travel duration falls within insurance duration limits.
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the total travel price for a participant, excluding insurance prices.
|
* Calculates the total travel price for a participant, excluding insurance prices.
|
||||||
*
|
*
|
||||||
@@ -324,54 +128,4 @@ class InsuranceMatchingService
|
|||||||
// Use the price calculator to get the participant's individual price excluding insurance
|
// Use the price calculator to get the participant's individual price excluding insurance
|
||||||
return $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($booking, $participantIndex);
|
return $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($booking, $participantIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates travel duration in days.
|
|
||||||
*
|
|
||||||
* @param \DateTimeImmutable $startDate Travel start date
|
|
||||||
* @param \DateTimeImmutable $endDate Travel end date
|
|
||||||
*
|
|
||||||
* @return int Duration in days
|
|
||||||
*/
|
|
||||||
private function calculateTravelDurationDays(\DateTimeImmutable $startDate, \DateTimeImmutable $endDate): int
|
|
||||||
{
|
|
||||||
return $startDate->diff($endDate)->days;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
private function filterInsurancesByType(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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?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 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use App\Form\Model\BookingDto;
|
|||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Form\Model\RoomSelectionDto;
|
use App\Form\Model\RoomSelectionDto;
|
||||||
use App\Service\BookingPriceCalculatorService;
|
use App\Service\BookingPriceCalculatorService;
|
||||||
|
use App\Service\ParticipantEligibilityService;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class BookingPriceCalculatorServiceTest extends TestCase
|
class BookingPriceCalculatorServiceTest extends TestCase
|
||||||
@@ -19,7 +20,17 @@ class BookingPriceCalculatorServiceTest extends TestCase
|
|||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->service = new BookingPriceCalculatorService();
|
$participantEligibilityService = $this->createMock(ParticipantEligibilityService::class);
|
||||||
|
$participantEligibilityService->method('isParticipantEligible')->willReturn(true);
|
||||||
|
|
||||||
|
$insuranceTypeFilterService = new \App\Service\InsuranceTypeFilterService();
|
||||||
|
$insuranceEligibilityService = new \App\Service\InsuranceEligibilityService();
|
||||||
|
|
||||||
|
$this->service = new BookingPriceCalculatorService(
|
||||||
|
$participantEligibilityService,
|
||||||
|
$insuranceTypeFilterService,
|
||||||
|
$insuranceEligibilityService
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCalculateRoomPricingWithParticipantBasedCalculation(): void
|
public function testCalculateRoomPricingWithParticipantBasedCalculation(): void
|
||||||
@@ -315,4 +326,266 @@ class BookingPriceCalculatorServiceTest extends TestCase
|
|||||||
|
|
||||||
$this->assertEmpty($result);
|
$this->assertEmpty($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testServicePricingWithoutBulkInsurance(): void
|
||||||
|
{
|
||||||
|
// Test that aggregation works normally when bulk insurance is not enabled
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->insurances = [];
|
||||||
|
|
||||||
|
// Create participants with their own insurances
|
||||||
|
$insurance1 = $this->createInsurance(1, 'Insurance A', 50.0);
|
||||||
|
$insurance2 = $this->createInsurance(2, 'Insurance B', 75.0);
|
||||||
|
|
||||||
|
$participant1 = new ParticipantDto();
|
||||||
|
$participant1->index = 0;
|
||||||
|
$participant1->insurance = $insurance1;
|
||||||
|
$participant1->bulkInsuranceBooking = false;
|
||||||
|
|
||||||
|
$participant2 = new ParticipantDto();
|
||||||
|
$participant2->index = 1;
|
||||||
|
$participant2->insurance = $insurance2;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 2);
|
||||||
|
$bookingDto->participants = [$participant1, $participant2];
|
||||||
|
|
||||||
|
$result = $this->service->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
// Expected: Both insurances should be counted separately
|
||||||
|
$this->assertNotEmpty($result);
|
||||||
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
|
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||||
|
$this->assertCount(2, $insuranceGroup['services'], 'Should have 2 different insurance line items');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicePricingWithBulkInsuranceSamePriceTier(): void
|
||||||
|
{
|
||||||
|
// Test that bulk insurance counts all participants when enabled with same price tier
|
||||||
|
$insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
|
||||||
|
$insurance->travelPriceFrom = 0.0; // Accepts all prices
|
||||||
|
$insurance->travelPriceTo = 10000.0;
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->insurances = [$insurance];
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
|
||||||
|
|
||||||
|
// Applicant with bulk insurance enabled
|
||||||
|
$participant1 = new ParticipantDto();
|
||||||
|
$participant1->index = 0;
|
||||||
|
$participant1->insurance = $insurance;
|
||||||
|
$participant1->bulkInsuranceBooking = true;
|
||||||
|
|
||||||
|
// Dependent with no insurance (will get price-tier-adjusted version)
|
||||||
|
$participant2 = new ParticipantDto();
|
||||||
|
$participant2->index = 1;
|
||||||
|
$participant2->insurance = null;
|
||||||
|
|
||||||
|
$participant3 = new ParticipantDto();
|
||||||
|
$participant3->index = 2;
|
||||||
|
$participant3->insurance = null;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 3);
|
||||||
|
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
||||||
|
|
||||||
|
$result = $this->service->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
// Expected: 3x same insurance should be aggregated into one line item
|
||||||
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
|
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||||
|
$this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item');
|
||||||
|
$this->assertEquals(3, $insuranceGroup['services'][0]['participantCount'], 'Should count all 3 participants');
|
||||||
|
$this->assertEquals(150.0, $insuranceGroup['services'][0]['totalPrice'], 'Total should be 3 × €50');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicePricingWithBulkInsuranceShowsPriceTierAdjustment(): void
|
||||||
|
{
|
||||||
|
// Test that bulk insurance shows correct price tiers based on individual travel prices
|
||||||
|
// Price tiers: Tier 1 (€0-€500): €30, Tier 2 (€501-€1000): €50
|
||||||
|
$insuranceTier1 = $this->createInsurance(1, 'Reise-Rücktritt', 30.0, 'RRV');
|
||||||
|
$insuranceTier1->travelPriceFrom = 0.0;
|
||||||
|
$insuranceTier1->travelPriceTo = 500.0;
|
||||||
|
|
||||||
|
$insuranceTier2 = $this->createInsurance(2, 'Reise-Rücktritt', 50.0, 'RRV');
|
||||||
|
$insuranceTier2->travelPriceFrom = 501.0;
|
||||||
|
$insuranceTier2->travelPriceTo = 1000.0;
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->insurances = [$insuranceTier1, $insuranceTier2];
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
|
||||||
|
|
||||||
|
// Applicant in Tier 2 (travel price €600)
|
||||||
|
$participant1 = new ParticipantDto();
|
||||||
|
$participant1->index = 0;
|
||||||
|
$participant1->insurance = $insuranceTier2;
|
||||||
|
$participant1->bulkInsuranceBooking = true;
|
||||||
|
|
||||||
|
// This test will aggregate based on what insurance is resolved
|
||||||
|
// Without room/service assignments, we can't test real price calculation
|
||||||
|
// So this test verifies the logic structure is correct
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
$bookingDto->participants = [$participant1];
|
||||||
|
|
||||||
|
$result = $this->service->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
// Expected: Applicant's insurance is counted
|
||||||
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
|
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||||
|
$this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count applicant');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicePricingWithBulkInsuranceWhenNoBulkEnabled(): void
|
||||||
|
{
|
||||||
|
// Test that dependents are not counted when bulk insurance checkbox is not enabled
|
||||||
|
$insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->insurances = [$insurance];
|
||||||
|
|
||||||
|
// Applicant WITHOUT bulk insurance enabled
|
||||||
|
$participant1 = new ParticipantDto();
|
||||||
|
$participant1->index = 0;
|
||||||
|
$participant1->insurance = $insurance;
|
||||||
|
$participant1->bulkInsuranceBooking = false; // NOT enabled
|
||||||
|
|
||||||
|
// Dependent with no insurance
|
||||||
|
$participant2 = new ParticipantDto();
|
||||||
|
$participant2->index = 1;
|
||||||
|
$participant2->insurance = null; // No insurance assigned
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 2);
|
||||||
|
$bookingDto->participants = [$participant1, $participant2];
|
||||||
|
|
||||||
|
$result = $this->service->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
// Expected: Only applicant's insurance counted
|
||||||
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
|
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||||
|
$this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item');
|
||||||
|
$this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count only applicant');
|
||||||
|
$this->assertEquals(50.0, $insuranceGroup['services'][0]['totalPrice'], 'Total should be 1 × €50');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicePricingWithIneligibleParticipant(): void
|
||||||
|
{
|
||||||
|
// Test that ineligible participants are skipped (not counted at all)
|
||||||
|
$insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->insurances = [$insurance];
|
||||||
|
|
||||||
|
// Applicant with insurance
|
||||||
|
$participant1 = new ParticipantDto();
|
||||||
|
$participant1->index = 0;
|
||||||
|
$participant1->insurance = $insurance;
|
||||||
|
$participant1->bulkInsuranceBooking = false;
|
||||||
|
|
||||||
|
// Dependent (will be marked as ineligible by the eligibility service)
|
||||||
|
$participant2 = new ParticipantDto();
|
||||||
|
$participant2->index = 1;
|
||||||
|
$participant2->insurance = null;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 2);
|
||||||
|
$bookingDto->participants = [$participant1, $participant2];
|
||||||
|
|
||||||
|
// Mock participant eligibility to mark second participant as ineligible
|
||||||
|
$participantEligibilityService = $this->createMock(ParticipantEligibilityService::class);
|
||||||
|
$participantEligibilityService->method('isParticipantEligible')
|
||||||
|
->willReturnCallback(fn ($booking, $index) => 0 === $index); // Only first participant eligible
|
||||||
|
|
||||||
|
$insuranceTypeFilterService = new \App\Service\InsuranceTypeFilterService();
|
||||||
|
$insuranceEligibilityService = new \App\Service\InsuranceEligibilityService();
|
||||||
|
|
||||||
|
$this->service = new BookingPriceCalculatorService(
|
||||||
|
$participantEligibilityService,
|
||||||
|
$insuranceTypeFilterService,
|
||||||
|
$insuranceEligibilityService
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->service->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
// Expected: Only applicant's insurance counted (dependent is ineligible)
|
||||||
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
|
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||||
|
$this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item');
|
||||||
|
$this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count only applicant');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicePricingWithBulkInsuranceCountsAllParticipants(): void
|
||||||
|
{
|
||||||
|
// Test that bulk insurance counts all participants (price tier adjusted per participant)
|
||||||
|
$insuranceA = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV');
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->insurances = [$insuranceA];
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
|
||||||
|
|
||||||
|
// Applicant with bulk insurance enabled
|
||||||
|
$participant1 = new ParticipantDto();
|
||||||
|
$participant1->index = 0;
|
||||||
|
$participant1->insurance = $insuranceA;
|
||||||
|
$participant1->bulkInsuranceBooking = true;
|
||||||
|
|
||||||
|
// Dependent 1 - no insurance (will get price-tier-adjusted version of A)
|
||||||
|
$participant2 = new ParticipantDto();
|
||||||
|
$participant2->index = 1;
|
||||||
|
$participant2->insurance = null;
|
||||||
|
|
||||||
|
// Dependent 2 - no insurance (will get price-tier-adjusted version of A)
|
||||||
|
$participant3 = new ParticipantDto();
|
||||||
|
$participant3->index = 2;
|
||||||
|
$participant3->insurance = null;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 3);
|
||||||
|
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
||||||
|
|
||||||
|
$result = $this->service->calculateServicePricing($bookingDto);
|
||||||
|
|
||||||
|
// Expected: All 3 participants counted (price tier may vary per participant based on travel price)
|
||||||
|
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||||
|
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||||
|
|
||||||
|
// Count total participants across all insurance line items
|
||||||
|
$totalParticipants = array_sum(array_column($insuranceGroup['services'], 'participantCount'));
|
||||||
|
$this->assertEquals(3, $totalParticipants, 'Should count all 3 participants across all tiers');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
|
||||||
|
private function createInsurance(int $id, string $label, float $price, string $subType = 'RRV', bool $package = false, bool $complementary = false): \App\BusProNet\Model\Insurance
|
||||||
|
{
|
||||||
|
$insurance = new \App\BusProNet\Model\Insurance();
|
||||||
|
$insurance->id = (string) $id;
|
||||||
|
$insurance->label = $label;
|
||||||
|
$insurance->price = $price;
|
||||||
|
$insurance->subType = $subType;
|
||||||
|
$insurance->package = $package;
|
||||||
|
$insurance->complementary = $complementary;
|
||||||
|
|
||||||
|
return $insurance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findServiceGroup(array $groups, string $groupName): ?array
|
||||||
|
{
|
||||||
|
foreach ($groups as $group) {
|
||||||
|
if ($group['groupName'] === $groupName) {
|
||||||
|
return $group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findServiceItem(array $items, string $label): ?array
|
||||||
|
{
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item['label'] === $label) {
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user