chore: update documentation
This commit is contained in:
@@ -122,6 +122,8 @@ Critical for correct pricing and auto-reassignment:
|
||||
- **Auto-reassignment**: Maintains insurance type when price tier changes
|
||||
- **Age constraints**: Absolute age (at travel date) vs birth year
|
||||
- **Hydration**: `TravelDataService::hydrateInsurancePackageRelationships()` rebuilds package relationships after cache deserialization
|
||||
- **ID Type**: Insurance IDs are strings (not integers) - ensure all test fixtures use string IDs
|
||||
- **Mutability**: Insurances are always readonly in edit mode (API limitation) - see `InsuranceMutabilityCondition`
|
||||
|
||||
### Transportation Services
|
||||
- **Unified pickup field**: Single field for both directions (BPN API limitation)
|
||||
@@ -190,6 +192,13 @@ Critical for correct pricing and auto-reassignment:
|
||||
- Ensure dependencies declared correctly
|
||||
- Check sync pattern: only sync fields in original submission
|
||||
|
||||
### Writing Tests
|
||||
- **Insurance IDs**: Always use strings, not integers (e.g., `'100'` not `100`)
|
||||
- **Room properties**: Use `$label` property, not `$name`
|
||||
- **Participant names**: Index 0 expects "Anmelder:in", others expect "Teilnehmer:in N" (1-based)
|
||||
- **Mock dependencies**: Ensure all constructor dependencies have mocks (especially new ones like `InsuranceLoader`, `InsuranceTypeFilterService`)
|
||||
- **Insurance mutability**: In edit mode, insurances are always readonly (API limitation)
|
||||
|
||||
## File Locations
|
||||
|
||||
### Key Design Patterns
|
||||
@@ -295,12 +304,21 @@ Edit mode session requires proper cleanup to prevent dirty state persistence:
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
./vendor/bin/phpunit # All tests
|
||||
./vendor/bin/phpunit # All tests (182 tests, 465 assertions)
|
||||
./vendor/bin/phpunit tests/Service/ # Service layer
|
||||
./vendor/bin/phpunit tests/BusProNet/ # API integration
|
||||
./vendor/bin/php-cs-fixer fix # Code style (Symfony ruleset)
|
||||
./vendor/bin/phpunit tests/Form/ # Form processing and field handlers
|
||||
/opt/homebrew/bin/php-cs-fixer fix --rules=@Symfony # Code style (Symfony ruleset)
|
||||
```
|
||||
|
||||
**Test Coverage Areas:**
|
||||
- BusProNet data loaders and processors
|
||||
- XML parsers (travels, hotels, bookings, insurances)
|
||||
- Form DTOs and field handlers
|
||||
- Service layer (pricing, insurance matching, room assignment)
|
||||
- Conditional field system
|
||||
- Utility classes
|
||||
|
||||
## Development Environment
|
||||
|
||||
```bash
|
||||
@@ -314,16 +332,19 @@ ddev exec "php -r 'opcache_reset()';" # Clear opcache after code cha
|
||||
## Important Notes
|
||||
|
||||
- **Room prices are per person**
|
||||
- **Room model uses `$label` property** (not `$name`) - ensure test fixtures use correct property
|
||||
- **Zero prices display without suffix** (e.g., "Vollpension" not "Vollpension (€0,00)")
|
||||
- **All services sorted by price** (cheapest first) via `SortByPriceTrait`
|
||||
- **Field sync pattern critical**: Only sync fields in original submission to avoid "extra fields" errors
|
||||
- **Insurance handler requires mode awareness**: Skips processing in edit mode (API doesn't return insurance data)
|
||||
- **Insurance IDs are strings**: All insurance IDs must be strings, not integers (type safety)
|
||||
- **Clear opcache after code changes** affecting hydration or serialization
|
||||
- **HTMX targeting consistency**: All swaps target `#main-content` with `innerHTML`, sidebar via OOB swap
|
||||
- **Validation pattern**: Both create and edit flows use validation-only forms that wrap card UI for standard Symfony form handling
|
||||
- **Card error indicators**: `ParticipantValidationTrait::extractParticipantErrorIndices()` parses form errors to highlight invalid participant cards
|
||||
- **Edit mode service availability**: Services with `available <= 0` remain visible and editable for participants who already have them (prevents fingerprint false positives)
|
||||
- **Session cleanup on exit**: All exit paths from edit mode (save, discard, cancel) properly clear session to reset dirty state
|
||||
- **Participant naming convention**: Index 0 is "Anmelder:in", others are "Teilnehmer:in N" (1-based, not 0-based)
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,307 +0,0 @@
|
||||
# Insurance Service Refactoring Plan
|
||||
|
||||
**Date Created:** 2025-10-18
|
||||
**Date Completed:** 2025-10-18
|
||||
**Status:** ✅ Completed
|
||||
**Goal:** Eliminate code duplication by leveraging newly created `InsuranceTypeFilterService`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
During the bulk insurance summary fix implementation, we created two new services:
|
||||
- `InsuranceEligibilityService` - Core eligibility checking logic
|
||||
- `InsuranceTypeFilterService` - Type-based insurance filtering
|
||||
|
||||
This refactoring plan identifies and eliminates remaining code duplications that can benefit from these new services.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. Complementary Insurance Filtering Duplication ⚠️
|
||||
|
||||
**Issue:** Exact same filtering code appears in 4 different locations:
|
||||
|
||||
```php
|
||||
// Exclude complementary insurances
|
||||
$availableInsurances = array_filter($availableInsurances, fn ($insurance) => !$insurance->complementary);
|
||||
$availableInsurances = array_values($availableInsurances);
|
||||
```
|
||||
|
||||
**Locations:**
|
||||
1. `src/Service/BookingPriceCalculatorService.php:778` - In `resolveInsuranceForAggregation()`
|
||||
2. `src/BusProNet/DataProcessor/BookingDataProcessor.php:1117` - In `applyBulkInsuranceIfActive()`
|
||||
3. `src/Form/Service/ParticipantInsuranceFieldHandler.php:137` - In `processField()`
|
||||
4. `src/Form/Service/ParticipantFieldOptionsProvider.php:760` - In `getEligibleInsurances()`
|
||||
|
||||
**Impact:**
|
||||
- ~8 lines of duplicated code (2 lines × 4 locations)
|
||||
- Risk of inconsistent updates if business logic changes
|
||||
- Same filtering logic with identical comments
|
||||
|
||||
### 2. Unused Value Object 🗑️
|
||||
|
||||
**Issue:** `InsuranceEligibilityCriteria` value object exists but is never used
|
||||
|
||||
**File:** `src/Model/InsuranceEligibilityCriteria.php` (29 lines)
|
||||
|
||||
**Analysis:**
|
||||
- Likely created earlier but abandoned during refactoring
|
||||
- `InsuranceEligibilityService::getEligibleInsurances()` uses individual parameters instead
|
||||
- No references in codebase (confirmed via grep)
|
||||
|
||||
---
|
||||
|
||||
## Solution Design
|
||||
|
||||
### New Method in InsuranceTypeFilterService
|
||||
|
||||
Add a centralized helper method for filtering non-complementary insurances:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Filters out complementary insurances from an insurance array.
|
||||
*
|
||||
* Complementary insurances are only available as part of packages
|
||||
* and cannot be directly selected by users.
|
||||
*
|
||||
* @param array<Insurance> $insurances Array of insurances to filter
|
||||
*
|
||||
* @return array<Insurance> Array containing only non-complementary insurances with reset keys
|
||||
*/
|
||||
public function filterNonComplementary(array $insurances): array
|
||||
{
|
||||
return array_values(
|
||||
array_filter($insurances, fn ($insurance) => false === $insurance->complementary)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Phase 1: Add Shared Method
|
||||
|
||||
- [x] **Add `filterNonComplementary()` to InsuranceTypeFilterService**
|
||||
- File: `src/Service/InsuranceTypeFilterService.php`
|
||||
- Add public method with comprehensive PHPDoc
|
||||
- Use explicit comparison (`false === $insurance->complementary`)
|
||||
- Include `array_values()` to reset array keys
|
||||
- Follow Symfony coding standards
|
||||
|
||||
### Phase 2: Refactor Existing Code
|
||||
|
||||
- [x] **Refactor BookingPriceCalculatorService**
|
||||
- File: `src/Service/BookingPriceCalculatorService.php:778`
|
||||
- Location: `resolveInsuranceForAggregation()` method
|
||||
- Already has `InsuranceTypeFilterService` dependency ✅
|
||||
- Replaced 2 lines with single method call
|
||||
|
||||
- [x] **Refactor BookingDataProcessor**
|
||||
- File: `src/BusProNet/DataProcessor/BookingDataProcessor.php:1118`
|
||||
- Location: `applyBulkInsuranceIfActive()` method
|
||||
- Added `InsuranceTypeFilterService` dependency to constructor
|
||||
- Replaced 3 lines with single method call
|
||||
|
||||
- [x] **Refactor ParticipantInsuranceFieldHandler**
|
||||
- File: `src/Form/Service/ParticipantInsuranceFieldHandler.php:138`
|
||||
- Location: `processField()` method
|
||||
- Added `InsuranceTypeFilterService` dependency to constructor
|
||||
- Replaced 2 lines with single method call
|
||||
|
||||
- [x] **Refactor ParticipantFieldOptionsProvider**
|
||||
- File: `src/Form/Service/ParticipantFieldOptionsProvider.php:763`
|
||||
- Location: `getEligibleInsurances()` method
|
||||
- Added `InsuranceTypeFilterService` dependency to constructor
|
||||
- Replaced 2 lines with single method call
|
||||
|
||||
### Phase 3: Cleanup
|
||||
|
||||
- [x] **Delete unused InsuranceEligibilityCriteria value object**
|
||||
- File: `src/Model/InsuranceEligibilityCriteria.php`
|
||||
- Removed entire file (29 lines)
|
||||
- No references to update (confirmed unused)
|
||||
|
||||
### Phase 4: Testing & Quality
|
||||
|
||||
- [x] **Run test suite**
|
||||
- Executed: `./vendor/bin/phpunit`
|
||||
- Updated test files with new dependencies:
|
||||
- `tests/BusProNet/DataProcessor/BookingDataProcessorTest.php`
|
||||
- `tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php`
|
||||
- All refactoring-related tests passing
|
||||
- No behavioral changes introduced
|
||||
|
||||
- [x] **Apply code formatting**
|
||||
- Applied php-cs-fixer to all modified files
|
||||
- 2 files automatically formatted (ParticipantInsuranceFieldHandler.php, ParticipantFieldOptionsProvider.php)
|
||||
- All other files already compliant with Symfony coding standards
|
||||
|
||||
- [ ] **Manual verification** (Pending browser testing)
|
||||
- Test bulk insurance assignment in browser
|
||||
- Verify insurance field options display correctly
|
||||
- Check sidebar summary displays correct insurance counts
|
||||
- Test insurance auto-reassignment on price changes
|
||||
|
||||
---
|
||||
|
||||
## Expected Outcomes
|
||||
|
||||
### Code Quality Improvements
|
||||
|
||||
✅ **Reduced duplication:** ~8 lines removed, centralized in one method
|
||||
✅ **Single source of truth:** Complementary filtering logic in one place
|
||||
✅ **Better maintainability:** Future changes only need to update one method
|
||||
✅ **Cleaner code:** Removed unused value object (29 lines)
|
||||
✅ **Consistent behavior:** All locations use identical filtering logic
|
||||
|
||||
### Files Modified Summary
|
||||
|
||||
| File | Change Type | Impact |
|
||||
|------|-------------|--------|
|
||||
| `InsuranceTypeFilterService.php` | Added method | +15 lines |
|
||||
| `BookingPriceCalculatorService.php` | Refactored | -1 line |
|
||||
| `BookingDataProcessor.php` | Added dependency + refactored | +1 dependency, -1 line |
|
||||
| `ParticipantInsuranceFieldHandler.php` | Added dependency + refactored | +1 dependency, -1 line |
|
||||
| `ParticipantFieldOptionsProvider.php` | Added dependency + refactored | +1 dependency, -1 line |
|
||||
| `InsuranceEligibilityCriteria.php` | Deleted | -29 lines |
|
||||
|
||||
### Net Impact
|
||||
|
||||
- **~25 lines removed** from codebase
|
||||
- **4 new service dependencies** added
|
||||
- **0 test changes** required (behavior unchanged)
|
||||
- **Improved architecture:** Shared service for insurance filtering
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**Risk Level:** 🟢 Low
|
||||
|
||||
**Rationale:**
|
||||
- Pure refactoring with no behavior changes
|
||||
- Existing tests provide comprehensive safety net
|
||||
- New method is simple utility with no complex logic
|
||||
- Easy to rollback if issues arise
|
||||
- All changes are isolated and independent
|
||||
|
||||
**Rollback Strategy:**
|
||||
- Revert commits in reverse order
|
||||
- All existing code paths remain functional
|
||||
- No database schema changes
|
||||
- No API contract changes
|
||||
|
||||
---
|
||||
|
||||
## Dependencies Required
|
||||
|
||||
### New Constructor Dependencies
|
||||
|
||||
**BookingDataProcessor:**
|
||||
```php
|
||||
public function __construct(
|
||||
private readonly InsuranceMatchingService $insuranceMatchingService,
|
||||
private readonly InsuranceTypeFilterService $insuranceTypeFilterService, // NEW
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**ParticipantInsuranceFieldHandler:**
|
||||
```php
|
||||
public function __construct(
|
||||
private readonly InsuranceMatchingService $insuranceMatchingService,
|
||||
private readonly InsuranceTypeFilterService $insuranceTypeFilterService, // NEW
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**ParticipantFieldOptionsProvider:**
|
||||
```php
|
||||
public function __construct(
|
||||
private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory,
|
||||
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
|
||||
private readonly InsuranceMatchingService $insuranceMatchingService,
|
||||
private readonly InsuranceTypeFilterService $insuranceTypeFilterService, // NEW
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Symfony autowiring will automatically inject these dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All changes maintain backward compatibility
|
||||
- No API contract modifications
|
||||
- No database schema changes
|
||||
- No configuration updates required
|
||||
- Follows established architectural patterns
|
||||
- Consistent with recent bulk insurance refactoring
|
||||
|
||||
---
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- ✅ All checklist items completed
|
||||
- ✅ All tests passing (16 tests, 53 assertions minimum)
|
||||
- ✅ Code formatted with php-cs-fixer
|
||||
- ✅ Manual testing confirms no regressions
|
||||
- ✅ Documentation updated (this file marked as "Completed")
|
||||
- ✅ Git commit with clear description
|
||||
|
||||
---
|
||||
|
||||
## Implementation Results
|
||||
|
||||
### Summary
|
||||
|
||||
The refactoring was completed successfully on **2025-10-18** with all planned objectives achieved:
|
||||
|
||||
- ✅ **Code duplication eliminated:** 8 lines of duplicated complementary insurance filtering removed
|
||||
- ✅ **Centralized filtering:** Single `filterNonComplementary()` method in `InsuranceTypeFilterService`
|
||||
- ✅ **Dead code removed:** Deleted unused `InsuranceEligibilityCriteria` value object (29 lines)
|
||||
- ✅ **Tests updated:** Modified 2 test files to accommodate new dependencies
|
||||
- ✅ **Code quality:** All files formatted with php-cs-fixer following Symfony standards
|
||||
- ✅ **Zero regressions:** All refactoring-related tests passing
|
||||
|
||||
### Actual Changes
|
||||
|
||||
**Files Modified:**
|
||||
1. `src/Service/InsuranceTypeFilterService.php` - Added `filterNonComplementary()` method
|
||||
2. `src/Service/BookingPriceCalculatorService.php` - Refactored to use new method (line 778)
|
||||
3. `src/BusProNet/DataProcessor/BookingDataProcessor.php` - Added dependency + refactored (line 1118)
|
||||
4. `src/Form/Service/ParticipantInsuranceFieldHandler.php` - Added dependency + refactored (line 138)
|
||||
5. `src/Form/Service/ParticipantFieldOptionsProvider.php` - Added dependency + refactored (line 763)
|
||||
|
||||
**Files Deleted:**
|
||||
1. `src/Model/InsuranceEligibilityCriteria.php` - Unused value object removed
|
||||
|
||||
**Test Files Updated:**
|
||||
1. `tests/BusProNet/DataProcessor/BookingDataProcessorTest.php` - Added mock for `InsuranceTypeFilterService`
|
||||
2. `tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php` - Added mock for `InsuranceTypeFilterService`
|
||||
|
||||
### Test Results
|
||||
|
||||
```
|
||||
Tests: 190, Assertions: 362, Errors: 41, Failures: 8
|
||||
```
|
||||
|
||||
**Note:** The remaining errors and failures are pre-existing issues unrelated to this refactoring. All refactoring-specific tests (BookingDataProcessorTest and ParticipantInsuranceFieldHandlerTest) are passing successfully.
|
||||
|
||||
### Next Steps
|
||||
|
||||
- [ ] Manual browser testing to verify insurance functionality in production-like environment
|
||||
- [ ] Monitor application logs after deployment for any unexpected issues
|
||||
- [ ] Update related documentation if business logic changes in the future
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/bulk-insurance-summary-fix-plan.md` - Original implementation that created the new services
|
||||
- `CLAUDE.md` - Project coding standards and guidelines
|
||||
- Symfony Service Container documentation
|
||||
Reference in New Issue
Block a user