diff --git a/config/services.yaml b/config/services.yaml index 3a1f526..de5534d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -87,3 +87,4 @@ services: - 'App\Form\Service\ParticipantParkingFieldHandler' - 'App\Form\Service\ParticipantRentalInsuranceFieldHandler' - 'App\Form\Service\ParticipantLicensePlateFieldHandler' + - 'App\Form\Service\ParticipantInsuranceFieldHandler' diff --git a/docs/insurance-booking-implementation-plan.md b/docs/insurance-booking-implementation-plan.md index 4c86f5f..1afe00e 100644 --- a/docs/insurance-booking-implementation-plan.md +++ b/docs/insurance-booking-implementation-plan.md @@ -14,13 +14,19 @@ This document outlines the complete implementation plan for making travel insura - **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 +### ✅ Recently Completed +- **Phase 1**: Insurance subtype parsing and type resolution system ✅ +- **Phase 2**: Enhanced age calculation with reference date support ✅ +- **Phase 3**: Insurance matching service with comprehensive criteria validation ✅ +- **Phase 4**: ParticipantDto enhancement with insurance property ✅ +- **Phase 5**: Form field handler for insurance selection processing ✅ + +### ❌ Remaining Implementation +- Form integration and field configuration +- Controller integration and data handling - Frontend templates and UX +- Auto-reselection when participant price changes +- Advanced features and applicant control ## Key Technical Discoveries @@ -36,25 +42,25 @@ This document outlines the complete implementation plan for making travel insura ## Implementation Phases -### Phase 1: Insurance Type System +### Phase 1: Insurance Type System ✅ COMPLETED **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.1 Extend Insurance Model ✅ +- [x] Add `subType` property to `Insurance` model (from XML `unterart`) +- [x] Add computed `type` property (resolved via service) +- [x] 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.2 Update Insurance Parser ✅ +- [x] Modify `InsuranceParser::parseInsuranceNode()` to parse `unterart` attribute +- [x] Add subtype to individual insurance parsing +- [x] 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 +#### 1.3 Create Insurance Type Resolver ✅ +- [x] Create `InsuranceTypeResolver` service +- [x] Implement type resolution for individual insurances +- [x] Implement package type resolution via contained insurance analysis +- [x] Define type constants: `TRAVEL_CANCELLATION`, `TRAVEL_PROTECTION`, etc. +- [x] Handle family variants using `familyInsurance` boolean ```php // Type Constants @@ -62,27 +68,28 @@ const TRAVEL_CANCELLATION = 'TRAVEL_CANCELLATION'; const TRAVEL_CANCELLATION_FAMILY = 'TRAVEL_CANCELLATION_FAMILY'; const TRAVEL_PROTECTION = 'TRAVEL_PROTECTION'; const TRAVEL_PROTECTION_FAMILY = 'TRAVEL_PROTECTION_FAMILY'; +const DEDUCTIBLE = 'DEDUCTIBLE'; ``` -#### 1.4 Testing -- [ ] Create `InsuranceTypeResolverTest` -- [ ] Test individual insurance type resolution -- [ ] Test package type resolution -- [ ] Test family variant detection -- [ ] Verify existing insurance parsing still works +#### 1.4 Testing ✅ +- [x] Create `InsuranceTypeResolverTest` +- [x] Test individual insurance type resolution +- [x] Test package type resolution +- [x] Test family variant detection +- [x] Verify existing insurance parsing still works -### Phase 2: Enhanced Age Calculation +### Phase 2: Enhanced Age Calculation ✅ COMPLETED **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.1 Update ParticipantDto ✅ +- [x] Enhanced existing `getAge()` method with optional reference date parameter +- [x] Keep existing `getAge(): ?int` for backward compatibility +- [x] 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 +#### 2.2 Testing ✅ +- [x] Add comprehensive tests for reference date age calculation +- [x] Test edge cases (leap years, same day, etc.) +- [x] Verify existing age calculation still works ### Phase 3: Insurance Matching Service **Goal**: Implement comprehensive insurance matching with all criteria diff --git a/src/BusProNet/Model/Travel.php b/src/BusProNet/Model/Travel.php index 82219c6..1463a30 100644 --- a/src/BusProNet/Model/Travel.php +++ b/src/BusProNet/Model/Travel.php @@ -86,6 +86,12 @@ class Travel #[Groups(['api:single'])] public ?Guide $guide = null; + /** + * @var array Available insurances for this travel package + */ + #[Groups(['api:single'])] + public array $insurances = []; + /** * Retrieves additional services filtered by subtype, availability, and optionally by travel date range. * diff --git a/src/Form/Service/ParticipantInsuranceFieldHandler.php b/src/Form/Service/ParticipantInsuranceFieldHandler.php new file mode 100644 index 0000000..148b548 --- /dev/null +++ b/src/Form/Service/ParticipantInsuranceFieldHandler.php @@ -0,0 +1,166 @@ + $submittedData The submitted participant form data + * @param int $participantIndex The index of the participant being processed + * + * @return bool Always returns true for insurance selection fields + */ + public function shouldProcess(array $submittedData, int $participantIndex): bool + { + return true; // Always process to handle deselection cases + } + + /** + * Processes the insurance field for a specific participant. + * + * This method extracts the insurance selection from submitted form data, + * validates the selection against the participant's eligibility criteria, + * and updates the participant DTO with the valid selection. If the insurance + * is no longer appropriate for the participant, it is automatically cleared. + * + * @param array $submittedData The submitted participant form data + * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) + * @param int $participantIndex The index of the participant being processed + */ + public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void + { + $participant = $bookingDto->getParticipant($participantIndex); + if (null === $participant) { + return; + } + + $insuranceId = $submittedData['insurance'] ?? null; + + // Clear insurance if no selection + if (null === $insuranceId || '' === $insuranceId) { + $participant->insurance = null; + + return; + } + + // Find the selected insurance from available travel insurances + $availableInsurances = $bookingDto->travel->insurances ?? []; + $selectedInsurance = $this->findInsuranceById($availableInsurances, $insuranceId); + + if (null === $selectedInsurance) { + $participant->insurance = null; + + return; + } + + // Validate insurance eligibility for this participant + $eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances( + [$selectedInsurance], + $participant, + $bookingDto + ); + + // Set insurance only if it's eligible for this participant + $participant->insurance = !empty($eligibleInsurances) ? $selectedInsurance : null; + } + + /** + * Returns field state modifications that should be applied after processing. + * + * Currently no field state modifications are needed for insurance selection. + * + * @param array $submittedData The submitted participant form data + * @param BookingDtoInterface $bookingDto The booking DTO (potentially modified by processing) + * @param int $participantIndex The participant index being processed + * + * @return array> Empty array - no field state modifications + */ + public function getFieldStateModifications(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): array + { + return []; + } + + /** + * Returns field names whose state is affected by this handler's processing. + * + * Currently no other fields are affected by insurance selection. + * + * @return string[] Empty array - no other fields are affected + */ + public function getAffectedFieldNames(): array + { + return []; + } + + /** + * Finds an insurance by ID from the available insurances array. + * + * @param array $insurances Array of available insurances + * @param string|int $insuranceId The insurance ID to find + * + * @return Insurance|null The found insurance or null if not found + */ + private function findInsuranceById(array $insurances, string|int $insuranceId): ?Insurance + { + foreach ($insurances as $insurance) { + if ($insurance->id === $insuranceId || (string) $insurance->id === (string) $insuranceId) { + return $insurance; + } + } + + return null; + } +} diff --git a/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php b/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php new file mode 100644 index 0000000..86e4602 --- /dev/null +++ b/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php @@ -0,0 +1,236 @@ +insuranceMatchingService = $this->createMock(InsuranceMatchingService::class); + $this->handler = new ParticipantInsuranceFieldHandler($this->insuranceMatchingService); + } + + public function testGetFieldName(): void + { + $this->assertEquals('insurance', $this->handler->getFieldName()); + } + + public function testGetDependencies(): void + { + $dependencies = $this->handler->getDependencies(); + + $this->assertEquals(['dateOfBirth'], $dependencies); + } + + public function testShouldProcessAlwaysReturnsTrue(): void + { + $result = $this->handler->shouldProcess([], 0); + + $this->assertTrue($result); + } + + public function testProcessFieldSetsInsuranceToNullWhenNoParticipant(): void + { + $bookingDto = $this->createMockBookingDto(); + $bookingDto->method('getParticipant')->with(0)->willReturn(null); + + $this->handler->processField(['insurance' => '123'], $bookingDto, 0); + + // No assertions needed - just ensure no exceptions are thrown + $this->addToAssertionCount(1); + } + + public function testProcessFieldClearsInsuranceWhenNullSelection(): void + { + $participant = new ParticipantDto(); + $participant->insurance = $this->createInsurance('123'); + + $bookingDto = $this->createMockBookingDto(); + $bookingDto->method('getParticipant')->with(0)->willReturn($participant); + + $this->handler->processField(['insurance' => null], $bookingDto, 0); + + $this->assertNull($participant->insurance); + } + + public function testProcessFieldClearsInsuranceWhenEmptyStringSelection(): void + { + $participant = new ParticipantDto(); + $participant->insurance = $this->createInsurance('123'); + + $bookingDto = $this->createMockBookingDto(); + $bookingDto->method('getParticipant')->with(0)->willReturn($participant); + + $this->handler->processField(['insurance' => ''], $bookingDto, 0); + + $this->assertNull($participant->insurance); + } + + public function testProcessFieldClearsInsuranceWhenMissingFromData(): void + { + $participant = new ParticipantDto(); + $participant->insurance = $this->createInsurance('123'); + + $bookingDto = $this->createMockBookingDto(); + $bookingDto->method('getParticipant')->with(0)->willReturn($participant); + + $this->handler->processField([], $bookingDto, 0); + + $this->assertNull($participant->insurance); + } + + public function testProcessFieldClearsInsuranceWhenNotFoundInAvailableInsurances(): void + { + $participant = new ParticipantDto(); + $travel = new Travel(); + $travel->insurances = [$this->createInsurance('456')]; // Different ID + + $bookingDto = $this->createMockBookingDto(); + $bookingDto->method('getParticipant')->with(0)->willReturn($participant); + $bookingDto->travel = $travel; + + $this->handler->processField(['insurance' => '123'], $bookingDto, 0); + + $this->assertNull($participant->insurance); + } + + public function testProcessFieldSetsInsuranceWhenEligible(): void + { + $insurance = $this->createInsurance('123'); + $participant = new ParticipantDto(); + $travel = new Travel(); + $travel->insurances = [$insurance]; + + $bookingDto = $this->createMockBookingDto(); + $bookingDto->method('getParticipant')->with(0)->willReturn($participant); + $bookingDto->travel = $travel; + + $this->insuranceMatchingService + ->expects($this->once()) + ->method('getEligibleInsurances') + ->with([$insurance], $participant, $bookingDto) + ->willReturn([$insurance]); + + $this->handler->processField(['insurance' => '123'], $bookingDto, 0); + + $this->assertSame($insurance, $participant->insurance); + } + + public function testProcessFieldClearsInsuranceWhenNotEligible(): void + { + $insurance = $this->createInsurance('123'); + $participant = new ParticipantDto(); + $travel = new Travel(); + $travel->insurances = [$insurance]; + + $bookingDto = $this->createMockBookingDto(); + $bookingDto->method('getParticipant')->with(0)->willReturn($participant); + $bookingDto->travel = $travel; + + $this->insuranceMatchingService + ->expects($this->once()) + ->method('getEligibleInsurances') + ->with([$insurance], $participant, $bookingDto) + ->willReturn([]); // Not eligible + + $this->handler->processField(['insurance' => '123'], $bookingDto, 0); + + $this->assertNull($participant->insurance); + } + + public function testProcessFieldWorksWithStringAndIntegerIds(): void + { + $insurance = $this->createInsurance(123); // Integer ID + $participant = new ParticipantDto(); + $travel = new Travel(); + $travel->insurances = [$insurance]; + + $bookingDto = $this->createMockBookingDto(); + $bookingDto->method('getParticipant')->with(0)->willReturn($participant); + $bookingDto->travel = $travel; + + $this->insuranceMatchingService + ->expects($this->once()) + ->method('getEligibleInsurances') + ->willReturn([$insurance]); + + $this->handler->processField(['insurance' => '123'], $bookingDto, 0); // String selection + + $this->assertSame($insurance, $participant->insurance); + } + + public function testProcessFieldHandlesEmptyInsurancesArray(): void + { + $participant = new ParticipantDto(); + $travel = new Travel(); + $travel->insurances = []; // No insurances available + + $bookingDto = $this->createMockBookingDto(); + $bookingDto->method('getParticipant')->with(0)->willReturn($participant); + $bookingDto->travel = $travel; + + $this->handler->processField(['insurance' => '123'], $bookingDto, 0); + + $this->assertNull($participant->insurance); + } + + public function testProcessFieldHandlesNullInsurancesProperty(): void + { + $participant = new ParticipantDto(); + $travel = new Travel(); + // $travel->insurances not set, defaults to [] + + $bookingDto = $this->createMockBookingDto(); + $bookingDto->method('getParticipant')->with(0)->willReturn($participant); + $bookingDto->travel = $travel; + + $this->handler->processField(['insurance' => '123'], $bookingDto, 0); + + $this->assertNull($participant->insurance); + } + + public function testGetFieldStateModificationsReturnsEmptyArray(): void + { + $bookingDto = $this->createMockBookingDto(); + + $result = $this->handler->getFieldStateModifications([], $bookingDto, 0); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testGetAffectedFieldNamesReturnsEmptyArray(): void + { + $result = $this->handler->getAffectedFieldNames(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + private function createInsurance(string|int $id, string $label = 'Test Insurance'): Insurance + { + $insurance = new Insurance(); + $insurance->id = $id; + $insurance->label = $label; + + return $insurance; + } + + private function createMockBookingDto(): BookingCreateDto + { + return $this->createMock(BookingCreateDto::class); + } +}