wip: insurance booking phases 1 and 2

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 411166ffba
commit 241785b428
8 changed files with 1109 additions and 4 deletions
@@ -0,0 +1,402 @@
# Insurance Booking System Implementation Plan
## Overview
This document outlines the complete implementation plan for making travel insurances bookable per participant in the MyEP Next Booking system. The implementation includes sophisticated criteria matching, automatic re-selection when participant prices change, and comprehensive form integration.
## Current System Status
### ✅ Already Implemented
- **Insurance XML Parsing**: `InsuranceParser` and `InsuranceLoader` classes
- **Insurance Model**: Complete with price ranges, age constraints, dates, family flags
- **Family Status Logic**: `BookingCreateDto::isFamilyBooking()` method
- **Individual Pricing**: `BookingPriceCalculatorService::calculateIndividualParticipantPrice()` method
- **Field Handler Architecture**: Existing pattern for dynamic form fields
- **HTMX Integration**: Real-time form updates infrastructure
### ❌ Missing Implementation
- Insurance subtype parsing and type resolution system
- Participant insurance field and selection logic
- Insurance matching service with criteria validation
- Auto-reselection when participant price changes
- Form integration and field handlers
- Frontend templates and UX
## Key Technical Discoveries
### Insurance XML Structure Analysis
- **Individual Insurances**: Have `unterart` (subtype) attribute (RRV, PAK, OHN)
- **Insurance Packages**: No subtype but contain individual insurances
- **Family Detection**: Use `familienversicherung` boolean attribute (not label parsing)
- **Package Type Resolution**: Analyze contained insurances to determine dominant type
### Age Calculation Requirement
- **Critical**: Use travel start date for age calculation, not current date
- Insurance eligibility based on participant's age at time of travel
## Implementation Phases
### Phase 1: Insurance Type System
**Goal**: Add type resolution capability to distinguish insurance types
#### 1.1 Extend Insurance Model
- [ ] Add `subType` property to `Insurance` model (from XML `unterart`)
- [ ] Add computed `type` property (resolved via service)
- [ ] Update serialization groups if needed
#### 1.2 Update Insurance Parser
- [ ] Modify `InsuranceParser::parseInsuranceNode()` to parse `unterart` attribute
- [ ] Add subtype to individual insurance parsing
- [ ] Ensure package parsing maintains existing functionality
#### 1.3 Create Insurance Type Resolver
- [ ] Create `InsuranceTypeResolver` service
- [ ] Implement type resolution for individual insurances
- [ ] Implement package type resolution via contained insurance analysis
- [ ] Define type constants: `TRAVEL_CANCELLATION`, `TRAVEL_PROTECTION`, etc.
- [ ] Handle family variants using `familyInsurance` boolean
```php
// Type Constants
const TRAVEL_CANCELLATION = 'TRAVEL_CANCELLATION';
const TRAVEL_CANCELLATION_FAMILY = 'TRAVEL_CANCELLATION_FAMILY';
const TRAVEL_PROTECTION = 'TRAVEL_PROTECTION';
const TRAVEL_PROTECTION_FAMILY = 'TRAVEL_PROTECTION_FAMILY';
```
#### 1.4 Testing
- [ ] Create `InsuranceTypeResolverTest`
- [ ] Test individual insurance type resolution
- [ ] Test package type resolution
- [ ] Test family variant detection
- [ ] Verify existing insurance parsing still works
### Phase 2: Enhanced Age Calculation
**Goal**: Support age calculation at specific dates (travel start date)
#### 2.1 Update ParticipantDto
- [ ] Add `getAgeAtDate(\DateTimeImmutable $referenceDate): ?int` method
- [ ] Keep existing `getAge(): ?int` for backward compatibility
- [ ] Ensure proper null handling for missing birth dates
#### 2.2 Testing
- [ ] Add tests for `getAgeAtDate()` method
- [ ] Test edge cases (leap years, same day, etc.)
- [ ] Verify existing age calculation still works
### Phase 3: Insurance Matching Service
**Goal**: Implement comprehensive insurance matching with all criteria
#### 3.1 Create Insurance Matching Service
- [ ] Create `InsuranceMatchingService` class
- [ ] Implement `getMatchingInsurances()` method with all criteria:
- Age at travel date validation
- Price range validation
- Booking date validation
- Travel date validation
- Family insurance validation
- [ ] Implement `getMatchingInsurancesByType()` for auto-reselection
- [ ] Add "No Insurance" option handling
#### 3.2 Auto-Reselection Logic
- [ ] Implement `handlePriceChange()` method
- [ ] Find matching insurance of same type when price changes
- [ ] Maintain coverage when possible, fallback to null
- [ ] Log auto-reselection events for debugging
#### 3.3 Service Registration
- [ ] Register service in `services.yaml`
- [ ] Configure dependencies (InsuranceLoader, InsuranceTypeResolver)
#### 3.4 Testing
- [ ] Create comprehensive `InsuranceMatchingServiceTest`
- [ ] Test all matching criteria combinations
- [ ] Test auto-reselection scenarios
- [ ] Test edge cases and boundary conditions
### Phase 4: Participant DTO Enhancement
**Goal**: Add insurance property and integrate with pricing
#### 4.1 Extend ParticipantDto
- [ ] Add `?Insurance $insurance = null` property
- [ ] Add validation groups if needed
- [ ] Ensure proper serialization/deserialization
#### 4.2 Update Price Calculation
- [ ] Modify `BookingPriceCalculatorService::calculateParticipantServiceTotal()`
- [ ] Include insurance price in participant total
- [ ] Update `calculateIndividualParticipantPrice()` to include insurance
- [ ] Update `calculateAllParticipantIndividualPrices()` accordingly
#### 4.3 Testing
- [ ] Update `BookingPriceCalculatorServiceTest`
- [ ] Test price calculation with insurance
- [ ] Test pricing without insurance
- [ ] Verify individual participant pricing includes insurance
### Phase 5: Form Field Handler
**Goal**: Create form field handler for insurance selection
#### 5.1 Create Insurance Field Handler
- [ ] Create `ParticipantInsuranceFieldHandler` extending `AbstractParticipantFieldHandler`
- [ ] Implement field processing logic
- [ ] Handle insurance selection validation
- [ ] Implement auto-reselection on price changes
- [ ] Clear insurance if criteria no longer match
#### 5.2 Field Dependencies
- [ ] Define dependencies: `dateOfBirth` (for age calculation)
- [ ] Handle field visibility based on available insurances
- [ ] Implement proper error handling
#### 5.3 Service Registration
- [ ] Register handler in `services.yaml` with proper tags
- [ ] Set appropriate priority in relation to other handlers
#### 5.4 Testing
- [ ] Create `ParticipantInsuranceFieldHandlerTest`
- [ ] Test field processing
- [ ] Test auto-reselection scenarios
- [ ] Test validation logic
### Phase 6: Form Integration
**Goal**: Add insurance field to participant form
#### 6.1 Update Form Type
- [ ] Modify `BookingCreateParticipantType`
- [ ] Add insurance choice field
- [ ] Configure field type and options
- [ ] Add HTMX trigger attributes
#### 6.2 Field Options Provider
- [ ] Add insurance field to `ParticipantFieldOptionsProvider`
- [ ] Generate "No Insurance" option
- [ ] Generate valid insurance options per participant
- [ ] Use `InsuranceMatchingService` for filtering
- [ ] Format options with price and type information
#### 6.3 Field State Provider Integration
- [ ] Update `CreateFieldStateProvider` if needed
- [ ] Handle field visibility conditions
- [ ] Register field state conditions
#### 6.4 Testing
- [ ] Test form field rendering
- [ ] Test field options generation
- [ ] Test HTMX integration
### Phase 7: Controller Integration
**Goal**: Update controllers to handle insurance data
#### 7.1 Update Step 2 Controller
- [ ] Ensure insurance data is included in template variables
- [ ] Handle insurance-related HTMX updates
- [ ] Update participant price calculations to include insurance
#### 7.2 Error Handling
- [ ] Add proper error handling for insurance validation
- [ ] Handle insurance matching failures gracefully
- [ ] Provide user-friendly error messages
#### 7.3 Testing
- [ ] Test controller responses include insurance data
- [ ] Test HTMX updates work correctly
- [ ] Test error scenarios
### Phase 8: Frontend Templates
**Goal**: Add insurance field to templates and display pricing
#### 8.1 Template Updates
- [ ] Add insurance field to `create_step_2.html.twig`
- [ ] Position field appropriately in participant sections
- [ ] Add proper labeling and help text
- [ ] Configure HTMX attributes for real-time updates
#### 8.2 Pricing Display
- [ ] Update participant headers to show insurance pricing
- [ ] Include insurance in individual participant price display
- [ ] Update booking summary to include insurance totals
#### 8.3 UX Enhancements
- [ ] Show criteria-based availability information
- [ ] Display auto-reselection notifications
- [ ] Add loading states for HTMX updates
### Phase 9: Advanced Features
**Goal**: Implement applicant control and bulk operations
#### 9.1 Applicant Insurance Control
- [ ] Add special UI for first participant (applicant)
- [ ] Implement bulk insurance selection
- [ ] Respect individual criteria while allowing bulk operations
- [ ] Add override capabilities with proper validation
#### 9.2 Real-time Price Integration
- [ ] Monitor price changes via HTMX
- [ ] Trigger auto-reselection when participant price changes
- [ ] Provide visual feedback for automatic changes
- [ ] Update pricing summary dynamically
#### 9.3 Enhanced UX
- [ ] Add insurance type grouping in selection
- [ ] Implement insurance comparison features
- [ ] Add detailed insurance information display
- [ ] Improve mobile responsiveness
### Phase 10: Testing & Quality Assurance
**Goal**: Comprehensive testing and code quality
#### 10.1 Integration Testing
- [ ] Create end-to-end insurance booking tests
- [ ] Test complete booking flow with insurances
- [ ] Test auto-reselection scenarios
- [ ] Test applicant control features
#### 10.2 Performance Testing
- [ ] Test insurance matching performance with large datasets
- [ ] Optimize insurance filtering algorithms
- [ ] Test HTMX update performance
#### 10.3 Code Quality
- [ ] Run PHP CS Fixer on all new files
- [ ] Ensure PSR-12 compliance
- [ ] Add proper PHPDoc documentation
- [ ] Review and optimize service dependencies
#### 10.4 User Acceptance Testing
- [ ] Test with real insurance data
- [ ] Validate business logic with stakeholders
- [ ] Test edge cases and error scenarios
- [ ] Gather user feedback on UX
## Technical Architecture
### New Classes Overview
```
src/Service/
├── InsuranceTypeResolver.php # Type resolution for insurances and packages
├── InsuranceMatchingService.php # Criteria-based insurance matching
└── Insurance/
├── Criteria/
│ ├── AgeCriterion.php # Age-based matching
│ ├── PriceCriterion.php # Price range matching
│ ├── DateCriterion.php # Date validation
│ └── FamilyCriterion.php # Family insurance validation
└── Matcher/
└── InsuranceMatcher.php # Core matching logic
src/Form/Service/
├── ParticipantInsuranceFieldHandler.php # Form field processing
└── Insurance/
└── InsuranceFieldOptionsProvider.php # Field options generation
tests/Service/
├── InsuranceTypeResolverTest.php
├── InsuranceMatchingServiceTest.php
└── Insurance/
└── Criteria/
├── AgeCriterionTest.php
├── PriceCriterionTest.php
├── DateCriterionTest.php
└── FamilyCriterionTest.php
tests/Form/Service/
└── ParticipantInsuranceFieldHandlerTest.php
```
### Database Changes
- **None required** - All insurance data loaded from XML
### Configuration Changes
- Service registrations in `services.yaml`
- Field handler tags and priorities
- Form field configurations
## Implementation Guidelines
### Code Standards
- Follow PSR-12 coding standards
- Use `declare(strict_types=1)` on all files
- Apply Symfony coding standards via php-cs-fixer
- Use English for all variable and constant names
- Follow existing architectural patterns
### Testing Standards
- Minimum 90% code coverage for new classes
- Unit tests for all service methods
- Integration tests for form processing
- End-to-end tests for complete booking flow
- Performance tests for matching algorithms
### Documentation Standards
- PHPDoc for all public methods
- Business logic documentation in comments
- Update CLAUDE.md with new features
- Create user documentation for insurance features
## Risk Mitigation
### Technical Risks
1. **Performance**: Large insurance datasets could slow matching
- **Mitigation**: Implement caching and optimize algorithms
2. **Complexity**: Auto-reselection logic could introduce bugs
- **Mitigation**: Comprehensive testing and logging
3. **Data Integrity**: Price changes could cause inconsistent states
- **Mitigation**: Atomic operations and validation
### Business Risks
1. **Incorrect Matching**: Wrong insurance eligibility could cause issues
- **Mitigation**: Thorough testing with real data and stakeholder validation
2. **User Confusion**: Complex insurance options could confuse users
- **Mitigation**: Clear UX design and comprehensive help text
## Success Criteria
### Functional Requirements
- ✅ Participants can select appropriate insurances based on all criteria
- ✅ Auto-reselection works when participant price changes
- ✅ Applicant can manage insurances for all participants
- ✅ Real-time pricing updates include insurance costs
- ✅ Form validation prevents invalid insurance selections
### Performance Requirements
- ✅ Insurance matching completes within 100ms for typical datasets
- ✅ HTMX updates complete within 500ms
- ✅ Page load times remain under 2 seconds
### Quality Requirements
- ✅ 90%+ code coverage for new functionality
- ✅ Zero critical bugs in production
- ✅ PSR-12 compliance for all new code
- ✅ Comprehensive documentation
## Future Enhancements
### Phase 11+ (Future)
- Insurance comparison tools
- Advanced filtering and search
- Insurance recommendation engine
- Historical insurance selection analytics
- Multi-language insurance descriptions
- PDF insurance documentation generation
## Dependencies
### External Dependencies
- Insurance XML data must be available and properly formatted
- BusProNet API integration for insurance booking
- Travel data must include proper date information
### Internal Dependencies
- Existing individual pricing calculation system
- Field handler architecture
- HTMX integration infrastructure
- Family status determination logic
---
**Document Version**: 1.0
**Last Updated**: 2025-09-26
**Author**: Claude Code Implementation Plan
**Status**: Ready for Implementation
+3
View File
@@ -25,6 +25,9 @@ class Insurance
#[Groups(['api:single', 'api:list'])]
public ?string $label = null;
#[Groups(['api:single', 'api:list'])]
public ?string $subType = null;
#[Groups(['api:single', 'api:list'])]
public ?float $price = null;
@@ -74,6 +74,7 @@ class InsuranceParser extends AbstractParser
$insurance->id = $isPackage ? $idValue : (int) $idValue;
$insurance->code = $node->attr('code');
$insurance->label = $node->attr('bezeichnung');
$insurance->subType = $node->attr('unterart');
$insurance->price = $node->attr('preis') ?
$this->stringToFloat($node->attr('preis')) : null;
$insurance->familyInsurance = $this->stringToBool($node->attr('familienversicherung'));
+6 -4
View File
@@ -108,21 +108,23 @@ class ParticipantDto
}
/**
* Calculates the participant's current age in complete years.
* Calculates the participant's age in complete years.
*
* Uses the same logic as existing age evaluators in the system
* for consistency across age-related calculations.
*
* @param \DateTimeImmutable|null $referenceDate The date to calculate age at (defaults to current date)
*
* @return int|null The calculated age in complete years, or null if no birth date
*/
public function getAge(): ?int
public function getAge(?\DateTimeImmutable $referenceDate = null): ?int
{
if (null === $this->dateOfBirth) {
return null;
}
$today = new \DateTimeImmutable();
$referenceDate = $referenceDate ?? new \DateTimeImmutable();
return $this->dateOfBirth->diff($today)->y;
return $this->dateOfBirth->diff($referenceDate)->y;
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Insurance;
/**
* Resolves insurance types for both individual insurances and packages.
*
* This service provides consistent type resolution by mapping insurance subtypes
* to standardized type constants. For packages, it analyzes contained insurances
* to determine the dominant (non-deductible) type and applies family variants
* based on the familyInsurance flag.
*/
class InsuranceTypeResolver
{
public const TRAVEL_CANCELLATION = 'TRAVEL_CANCELLATION';
public const TRAVEL_CANCELLATION_FAMILY = 'TRAVEL_CANCELLATION_FAMILY';
public const TRAVEL_PROTECTION = 'TRAVEL_PROTECTION';
public const TRAVEL_PROTECTION_FAMILY = 'TRAVEL_PROTECTION_FAMILY';
public const DEDUCTIBLE = 'DEDUCTIBLE';
/**
* Resolves the standardized type for an insurance or package.
*
* @param Insurance $insurance The insurance to resolve the type for
*
* @return string|null The resolved type constant or null if unresolvable
*/
public function resolveType(Insurance $insurance): ?string
{
if (!$insurance->package) {
return $this->mapSubtypeToType($insurance->subType, $insurance->familyInsurance);
}
return $this->resolvePackageType($insurance);
}
/**
* Resolves the type for an insurance package by analyzing contained insurances.
*
* @param Insurance $package The package to analyze
*
* @return string|null The resolved type or null if no primary type found
*/
private function resolvePackageType(Insurance $package): ?string
{
// Get primary (non-deductible) insurance type from contained insurances
$primaryType = null;
foreach ($package->containedInsurances as $contained) {
$baseType = $this->mapSubtypeToBaseType($contained->subType);
if ($baseType && self::DEDUCTIBLE !== $baseType) {
$primaryType = $baseType;
break;
}
}
// Apply family suffix using package's familyInsurance flag
return $primaryType ? $primaryType.($package->familyInsurance ? '_FAMILY' : '') : null;
}
/**
* Maps a subtype to a full type including family variant.
*
* @param string|null $subtype The subtype to map
* @param bool $isFamily Whether this is a family insurance
*
* @return string|null The mapped type or null if unmappable
*/
private function mapSubtypeToType(?string $subtype, bool $isFamily): ?string
{
$baseType = $this->mapSubtypeToBaseType($subtype);
return $baseType ? $baseType.($isFamily ? '_FAMILY' : '') : null;
}
/**
* Maps a subtype to its base type constant.
*
* @param string|null $subtype The subtype to map
*
* @return string|null The base type constant or null if unmappable
*/
private function mapSubtypeToBaseType(?string $subtype): ?string
{
return match ($subtype) {
'RRV' => self::TRAVEL_CANCELLATION,
'PAK' => self::TRAVEL_PROTECTION,
'OHN' => self::DEDUCTIBLE,
default => null,
};
}
/**
* Gets all available insurance type constants.
*
* @return array<string> Array of all type constants
*/
public function getAllTypes(): array
{
return [
self::TRAVEL_CANCELLATION,
self::TRAVEL_CANCELLATION_FAMILY,
self::TRAVEL_PROTECTION,
self::TRAVEL_PROTECTION_FAMILY,
self::DEDUCTIBLE,
];
}
/**
* Checks if a type is a family variant.
*
* @param string $type The type to check
*
* @return bool True if the type is a family variant
*/
public function isFamilyType(string $type): bool
{
return str_ends_with($type, '_FAMILY');
}
/**
* Gets the base type from a family type.
*
* @param string $type The type to get the base for
*
* @return string The base type (without _FAMILY suffix)
*/
public function getBaseType(string $type): string
{
return str_replace('_FAMILY', '', $type);
}
}
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\XmlParser;
use App\BusProNet\XmlParser\InsuranceParser;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DomCrawler\Crawler;
class InsuranceParserTest extends TestCase
{
private InsuranceParser $parser;
protected function setUp(): void
{
$this->parser = new InsuranceParser();
}
public function testParseIndividualInsuranceWithSubtype(): void
{
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
<versicherungsstammdaten>
<versicherungen>
<versicherung id="1" idbuspro="178917" code="903100" bezeichnung="Reise-Rücktritt" preis="5,00" preisartprozent="False" unterart="RRV" familienversicherung="False" paarversicherung="False" zusatzversicherung="False" reisedatumvon="01.09.2024" reisedatumbis="31.12.2030" buchungdatumvon="01.03.2025" buchungdatumbis="31.12.2030" altervon="0" alterbis="64" reisepreisvon="0,00" reisepreisbis="100,00" reisedauervon="0" reisedauerbis="255" />
<versicherung id="2" idbuspro="177236" code="913320" bezeichnung="Reiseschutz Platin Auto/Bahn/Bus (Europa)" preis="9,00" preisartprozent="False" unterart="PAK" familienversicherung="True" paarversicherung="False" zusatzversicherung="False" reisedatumvon="01.09.2024" reisedatumbis="31.12.2030" buchungdatumvon="01.03.2025" buchungdatumbis="31.12.2030" altervon="0" alterbis="64" reisepreisvon="0,00" reisepreisbis="100,00" reisedauervon="1" reisedauerbis="45" />
<versicherung id="3" idbuspro="177270" code="903551" bezeichnung="Selbstbehaltübernahme" preis="5,00" preisartprozent="False" unterart="OHN" familienversicherung="False" paarversicherung="False" zusatzversicherung="True" reisedatumvon="01.09.2024" reisedatumbis="31.12.2030" buchungdatumvon="01.03.2025" buchungdatumbis="31.12.2030" altervon="0" alterbis="64" reisepreisvon="0,00" reisepreisbis="600,00" reisedauervon="0" reisedauerbis="255" />
</versicherungen>
<versicherungspakete>
<versicherungspaket id="1" idbuspro="P1000320" bezeichnung="Reiseschutz Platin Auto/Bahn/Bus (Europa) + Selbstbehaltübernahme" preis="14,00" preisartprozent="False" reisedatumvon="01.09.2024" reisedatumbis="31.12.2030" altervon="0" alterbis="64" reisepreisvon="0" reisepreisbis="100,00" familienversicherung="False">
<enthalteneversicherungen>
<enthalteneversicherung idbuspro="177236" />
<enthalteneversicherung idbuspro="177270" />
</enthalteneversicherungen>
</versicherungspaket>
</versicherungspakete>
</versicherungsstammdaten>';
$crawler = new Crawler($xmlContent);
$insurances = $this->parser->parse($crawler);
// Test individual insurances + package (3 individual + 1 package = 4 total)
$this->assertCount(4, $insurances);
// Test travel cancellation insurance (RRV)
$cancellationInsurance = $insurances[178917];
$this->assertEquals(178917, $cancellationInsurance->id);
$this->assertEquals('903100', $cancellationInsurance->code);
$this->assertEquals('Reise-Rücktritt', $cancellationInsurance->label);
$this->assertEquals('RRV', $cancellationInsurance->subType);
$this->assertEquals(5.0, $cancellationInsurance->price);
$this->assertFalse($cancellationInsurance->familyInsurance);
$this->assertFalse($cancellationInsurance->package);
// Test travel protection insurance (PAK) with family flag
$protectionInsurance = $insurances[177236];
$this->assertEquals(177236, $protectionInsurance->id);
$this->assertEquals('913320', $protectionInsurance->code);
$this->assertEquals('Reiseschutz Platin Auto/Bahn/Bus (Europa)', $protectionInsurance->label);
$this->assertEquals('PAK', $protectionInsurance->subType);
$this->assertEquals(9.0, $protectionInsurance->price);
$this->assertTrue($protectionInsurance->familyInsurance);
$this->assertFalse($protectionInsurance->package);
// Test deductible insurance (OHN) - should be included because it's referenced by package
$deductibleInsurance = $insurances[177270];
$this->assertEquals(177270, $deductibleInsurance->id);
$this->assertEquals('903551', $deductibleInsurance->code);
$this->assertEquals('Selbstbehaltübernahme', $deductibleInsurance->label);
$this->assertEquals('OHN', $deductibleInsurance->subType);
$this->assertEquals(5.0, $deductibleInsurance->price);
$this->assertFalse($deductibleInsurance->familyInsurance);
$this->assertFalse($deductibleInsurance->package);
// Test package
$package = $insurances['P1000320'];
$this->assertEquals('P1000320', $package->id);
$this->assertEquals('Reiseschutz Platin Auto/Bahn/Bus (Europa) + Selbstbehaltübernahme', $package->label);
$this->assertNull($package->subType); // Packages don't have subtypes
$this->assertEquals(14.0, $package->price);
$this->assertFalse($package->familyInsurance);
$this->assertTrue($package->package);
$this->assertEquals([177236, 177270], $package->containedInsuranceIds);
}
public function testParsePackageWithoutSubtype(): void
{
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
<versicherungsstammdaten>
<versicherungen>
<versicherung id="1" idbuspro="177236" code="913320" bezeichnung="Test Insurance" preis="9,00" preisartprozent="False" unterart="PAK" familienversicherung="False" paarversicherung="False" zusatzversicherung="False" />
</versicherungen>
<versicherungspakete>
<versicherungspaket id="1" idbuspro="P1000320" bezeichnung="Test Package" preis="14,00" familienversicherung="True">
<enthalteneversicherungen>
<enthalteneversicherung idbuspro="177236" />
</enthalteneversicherungen>
</versicherungspaket>
</versicherungspakete>
</versicherungsstammdaten>';
$crawler = new Crawler($xmlContent);
$insurances = $this->parser->parse($crawler);
$this->assertCount(2, $insurances);
// Test package has no subType but has familyInsurance flag
$package = $insurances['P1000320'];
$this->assertEquals('P1000320', $package->id);
$this->assertNull($package->subType);
$this->assertTrue($package->familyInsurance);
$this->assertTrue($package->package);
}
public function testParseInsuranceWithNullSubtype(): void
{
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
<versicherungsstammdaten>
<versicherungen>
<versicherung id="1" idbuspro="178917" code="903100" bezeichnung="Test Insurance" preis="5,00" preisartprozent="False" familienversicherung="False" paarversicherung="False" zusatzversicherung="False" />
</versicherungen>
</versicherungsstammdaten>';
$crawler = new Crawler($xmlContent);
$insurances = $this->parser->parse($crawler);
$this->assertCount(1, $insurances);
$insurance = $insurances[178917];
$this->assertEquals(178917, $insurance->id);
$this->assertNull($insurance->subType); // Missing unterart attribute should result in null
$this->assertFalse($insurance->package);
}
public function testParseEmptyXml(): void
{
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
<versicherungsstammdaten>
<versicherungen>
</versicherungen>
<versicherungspakete>
</versicherungspakete>
</versicherungsstammdaten>';
$crawler = new Crawler($xmlContent);
$insurances = $this->parser->parse($crawler);
$this->assertEmpty($insurances);
}
public function testParseZusatzversicherungFiltering(): void
{
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
<versicherungsstammdaten>
<versicherungen>
<versicherung id="1" idbuspro="178917" code="903100" bezeichnung="Normal Insurance" preis="5,00" preisartprozent="False" unterart="RRV" familienversicherung="False" paarversicherung="False" zusatzversicherung="False" />
<versicherung id="2" idbuspro="177270" code="903551" bezeichnung="Zusatz Insurance Referenced" preis="5,00" preisartprozent="False" unterart="OHN" familienversicherung="False" paarversicherung="False" zusatzversicherung="True" />
<versicherung id="3" idbuspro="999999" code="999999" bezeichnung="Zusatz Insurance Not Referenced" preis="5,00" preisartprozent="False" unterart="OHN" familienversicherung="False" paarversicherung="False" zusatzversicherung="True" />
</versicherungen>
<versicherungspakete>
<versicherungspaket id="1" idbuspro="P1000320" bezeichnung="Test Package" preis="14,00" familienversicherung="False">
<enthalteneversicherungen>
<enthalteneversicherung idbuspro="177270" />
</enthalteneversicherungen>
</versicherungspaket>
</versicherungspakete>
</versicherungsstammdaten>';
$crawler = new Crawler($xmlContent);
$insurances = $this->parser->parse($crawler);
// Should include: normal insurance (178917), referenced zusatz (177270), and package (P1000320)
// Should exclude: unreferenced zusatz (999999)
$this->assertCount(3, $insurances);
$this->assertArrayHasKey(178917, $insurances);
$this->assertArrayHasKey(177270, $insurances);
$this->assertArrayHasKey('P1000320', $insurances);
$this->assertArrayNotHasKey(999999, $insurances);
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Model;
use App\Form\Model\ParticipantDto;
use PHPUnit\Framework\TestCase;
class ParticipantDtoTest extends TestCase
{
public function testGetAgeReturnsNullWhenDateOfBirthIsNull(): void
{
$participant = new ParticipantDto();
$participant->dateOfBirth = null;
$this->assertNull($participant->getAge());
}
public function testGetAgeCalculatesCurrentAgeWhenNoReferenceDateProvided(): void
{
$participant = new ParticipantDto();
$participant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
$expectedAge = (new \DateTimeImmutable())->diff($participant->dateOfBirth)->y;
$actualAge = $participant->getAge();
$this->assertEquals($expectedAge, $actualAge);
}
public function testGetAgeCalculatesAgeAtSpecificReferenceDate(): void
{
$participant = new ParticipantDto();
$participant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
$referenceDate = new \DateTimeImmutable('2024-06-14'); // Day before 34th birthday
$result = $participant->getAge($referenceDate);
$this->assertEquals(33, $result); // Still 33 years old
}
public function testGetAgeCalculatesAgeAtSpecificReferenceDateAfterBirthday(): void
{
$participant = new ParticipantDto();
$participant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
$referenceDate = new \DateTimeImmutable('2024-06-15'); // Exactly 34th birthday
$result = $participant->getAge($referenceDate);
$this->assertEquals(34, $result); // Now 34 years old
}
public function testGetAgeCalculatesAgeForInsuranceEligibilityScenario(): void
{
// Travel starts on 2024-08-01, participant born 1989-12-25
$participant = new ParticipantDto();
$participant->dateOfBirth = new \DateTimeImmutable('1989-12-25');
$travelStartDate = new \DateTimeImmutable('2024-08-01');
$result = $participant->getAge($travelStartDate);
// On 2024-08-01, they are still 34 (birthday not yet reached)
$this->assertEquals(34, $result);
}
public function testGetAgeCalculatesAgeForInsuranceEligibilityScenarioAfterBirthday(): void
{
// Travel starts on 2024-12-26, participant born 1989-12-25
$participant = new ParticipantDto();
$participant->dateOfBirth = new \DateTimeImmutable('1989-12-25');
$travelStartDate = new \DateTimeImmutable('2024-12-26');
$result = $participant->getAge($travelStartDate);
// On 2024-12-26, they just turned 35
$this->assertEquals(35, $result);
}
public function testGetAgeHandlesLeapYearScenarios(): void
{
// Born on Feb 29, 2000 (leap year)
$participant = new ParticipantDto();
$participant->dateOfBirth = new \DateTimeImmutable('2000-02-29');
$referenceDate = new \DateTimeImmutable('2023-02-28'); // Non-leap year
$result = $participant->getAge($referenceDate);
$this->assertEquals(22, $result); // Still 22, birthday is technically next day
}
public function testGetAgeReturnsNullWithReferenceDateWhenDateOfBirthIsNull(): void
{
$participant = new ParticipantDto();
$participant->dateOfBirth = null;
$referenceDate = new \DateTimeImmutable('2024-08-01');
$result = $participant->getAge($referenceDate);
$this->assertNull($result);
}
public function testGetAgeWithYoungParticipant(): void
{
// Child born in 2020
$participant = new ParticipantDto();
$participant->dateOfBirth = new \DateTimeImmutable('2020-03-15');
$referenceDate = new \DateTimeImmutable('2024-08-01');
$result = $participant->getAge($referenceDate);
$this->assertEquals(4, $result);
}
public function testGetAgeWithElderlyParticipant(): void
{
// Elderly participant born in 1940
$participant = new ParticipantDto();
$participant->dateOfBirth = new \DateTimeImmutable('1940-05-20');
$referenceDate = new \DateTimeImmutable('2024-08-01');
$result = $participant->getAge($referenceDate);
$this->assertEquals(84, $result);
}
}
+257
View File
@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Insurance;
use App\Service\InsuranceTypeResolver;
use PHPUnit\Framework\TestCase;
class InsuranceTypeResolverTest extends TestCase
{
private InsuranceTypeResolver $resolver;
protected function setUp(): void
{
$this->resolver = new InsuranceTypeResolver();
}
public function testResolveTypeForIndividualTravelCancellationInsurance(): void
{
$insurance = new Insurance();
$insurance->package = false;
$insurance->subType = 'RRV';
$insurance->familyInsurance = false;
$result = $this->resolver->resolveType($insurance);
$this->assertEquals(InsuranceTypeResolver::TRAVEL_CANCELLATION, $result);
}
public function testResolveTypeForIndividualFamilyTravelCancellationInsurance(): void
{
$insurance = new Insurance();
$insurance->package = false;
$insurance->subType = 'RRV';
$insurance->familyInsurance = true;
$result = $this->resolver->resolveType($insurance);
$this->assertEquals(InsuranceTypeResolver::TRAVEL_CANCELLATION_FAMILY, $result);
}
public function testResolveTypeForIndividualTravelProtectionInsurance(): void
{
$insurance = new Insurance();
$insurance->package = false;
$insurance->subType = 'PAK';
$insurance->familyInsurance = false;
$result = $this->resolver->resolveType($insurance);
$this->assertEquals(InsuranceTypeResolver::TRAVEL_PROTECTION, $result);
}
public function testResolveTypeForIndividualFamilyTravelProtectionInsurance(): void
{
$insurance = new Insurance();
$insurance->package = false;
$insurance->subType = 'PAK';
$insurance->familyInsurance = true;
$result = $this->resolver->resolveType($insurance);
$this->assertEquals(InsuranceTypeResolver::TRAVEL_PROTECTION_FAMILY, $result);
}
public function testResolveTypeForIndividualDeductibleInsurance(): void
{
$insurance = new Insurance();
$insurance->package = false;
$insurance->subType = 'OHN';
$insurance->familyInsurance = false;
$result = $this->resolver->resolveType($insurance);
$this->assertEquals(InsuranceTypeResolver::DEDUCTIBLE, $result);
}
public function testResolveTypeForIndividualUnknownSubtype(): void
{
$insurance = new Insurance();
$insurance->package = false;
$insurance->subType = 'UNKNOWN';
$insurance->familyInsurance = false;
$result = $this->resolver->resolveType($insurance);
$this->assertNull($result);
}
public function testResolveTypeForIndividualNullSubtype(): void
{
$insurance = new Insurance();
$insurance->package = false;
$insurance->subType = null;
$insurance->familyInsurance = false;
$result = $this->resolver->resolveType($insurance);
$this->assertNull($result);
}
public function testResolveTypeForPackageWithTravelProtectionContent(): void
{
// Create contained insurances
$protectionInsurance = new Insurance();
$protectionInsurance->subType = 'PAK';
$deductibleInsurance = new Insurance();
$deductibleInsurance->subType = 'OHN';
// Create package
$package = new Insurance();
$package->package = true;
$package->familyInsurance = false;
$package->containedInsurances = [$protectionInsurance, $deductibleInsurance];
$result = $this->resolver->resolveType($package);
$this->assertEquals(InsuranceTypeResolver::TRAVEL_PROTECTION, $result);
}
public function testResolveTypeForFamilyPackageWithTravelProtectionContent(): void
{
// Create contained insurances
$protectionInsurance = new Insurance();
$protectionInsurance->subType = 'PAK';
$deductibleInsurance = new Insurance();
$deductibleInsurance->subType = 'OHN';
// Create family package
$package = new Insurance();
$package->package = true;
$package->familyInsurance = true;
$package->containedInsurances = [$protectionInsurance, $deductibleInsurance];
$result = $this->resolver->resolveType($package);
$this->assertEquals(InsuranceTypeResolver::TRAVEL_PROTECTION_FAMILY, $result);
}
public function testResolveTypeForPackageWithTravelCancellationContent(): void
{
// Create contained insurances
$cancellationInsurance = new Insurance();
$cancellationInsurance->subType = 'RRV';
$deductibleInsurance = new Insurance();
$deductibleInsurance->subType = 'OHN';
// Create package
$package = new Insurance();
$package->package = true;
$package->familyInsurance = false;
$package->containedInsurances = [$cancellationInsurance, $deductibleInsurance];
$result = $this->resolver->resolveType($package);
$this->assertEquals(InsuranceTypeResolver::TRAVEL_CANCELLATION, $result);
}
public function testResolveTypeForPackageWithOnlyDeductibleContent(): void
{
// Create contained insurance (only deductible)
$deductibleInsurance = new Insurance();
$deductibleInsurance->subType = 'OHN';
// Create package
$package = new Insurance();
$package->package = true;
$package->familyInsurance = false;
$package->containedInsurances = [$deductibleInsurance];
$result = $this->resolver->resolveType($package);
$this->assertNull($result);
}
public function testResolveTypeForPackageWithNoContainedInsurances(): void
{
$package = new Insurance();
$package->package = true;
$package->familyInsurance = false;
$package->containedInsurances = [];
$result = $this->resolver->resolveType($package);
$this->assertNull($result);
}
public function testResolveTypeForPackageWithMultiplePrimaryTypes(): void
{
// Create contained insurances with multiple primary types
$protectionInsurance = new Insurance();
$protectionInsurance->subType = 'PAK';
$cancellationInsurance = new Insurance();
$cancellationInsurance->subType = 'RRV';
// Create package - should return first non-deductible type found
$package = new Insurance();
$package->package = true;
$package->familyInsurance = false;
$package->containedInsurances = [$protectionInsurance, $cancellationInsurance];
$result = $this->resolver->resolveType($package);
$this->assertEquals(InsuranceTypeResolver::TRAVEL_PROTECTION, $result);
}
public function testGetAllTypes(): void
{
$types = $this->resolver->getAllTypes();
$expectedTypes = [
InsuranceTypeResolver::TRAVEL_CANCELLATION,
InsuranceTypeResolver::TRAVEL_CANCELLATION_FAMILY,
InsuranceTypeResolver::TRAVEL_PROTECTION,
InsuranceTypeResolver::TRAVEL_PROTECTION_FAMILY,
InsuranceTypeResolver::DEDUCTIBLE,
];
$this->assertEquals($expectedTypes, $types);
$this->assertCount(5, $types);
}
public function testIsFamilyType(): void
{
$this->assertTrue($this->resolver->isFamilyType(InsuranceTypeResolver::TRAVEL_CANCELLATION_FAMILY));
$this->assertTrue($this->resolver->isFamilyType(InsuranceTypeResolver::TRAVEL_PROTECTION_FAMILY));
$this->assertFalse($this->resolver->isFamilyType(InsuranceTypeResolver::TRAVEL_CANCELLATION));
$this->assertFalse($this->resolver->isFamilyType(InsuranceTypeResolver::TRAVEL_PROTECTION));
$this->assertFalse($this->resolver->isFamilyType(InsuranceTypeResolver::DEDUCTIBLE));
}
public function testGetBaseType(): void
{
$this->assertEquals(
InsuranceTypeResolver::TRAVEL_CANCELLATION,
$this->resolver->getBaseType(InsuranceTypeResolver::TRAVEL_CANCELLATION_FAMILY)
);
$this->assertEquals(
InsuranceTypeResolver::TRAVEL_PROTECTION,
$this->resolver->getBaseType(InsuranceTypeResolver::TRAVEL_PROTECTION_FAMILY)
);
$this->assertEquals(
InsuranceTypeResolver::TRAVEL_CANCELLATION,
$this->resolver->getBaseType(InsuranceTypeResolver::TRAVEL_CANCELLATION)
);
$this->assertEquals(
InsuranceTypeResolver::DEDUCTIBLE,
$this->resolver->getBaseType(InsuranceTypeResolver::DEDUCTIBLE)
);
}
}