From c3008d7f7a8db2c13b5494325f908a01fe2f55d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 7 Oct 2025 18:02:10 +0200 Subject: [PATCH] wip: insurance booking in edit flow --- docs/INSURANCE_EDIT_IMPLEMENTATION.md | 693 ++++++++++++++++++ src/BusProNet/Model/Booking.php | 19 + src/BusProNet/Model/Insurance.php | 10 + .../XmlParser/BookingInsurancesParser.php | 48 ++ src/BusProNet/XmlParser/BookingParser.php | 7 + src/Form/Model/BookingEditDto.php | 4 + .../InsuranceMutabilityCondition.php | 101 +++ tests/BusProNet/Model/BookingTest.php | 66 ++ .../XmlParser/BookingInsurancesParserTest.php | 100 +++ .../InsuranceMutabilityConditionTest.php | 167 +++++ 10 files changed, 1215 insertions(+) create mode 100644 docs/INSURANCE_EDIT_IMPLEMENTATION.md create mode 100644 src/BusProNet/XmlParser/BookingInsurancesParser.php create mode 100644 src/Form/Service/Condition/InsuranceMutabilityCondition.php create mode 100644 tests/BusProNet/XmlParser/BookingInsurancesParserTest.php create mode 100644 tests/Form/Service/Condition/InsuranceMutabilityConditionTest.php diff --git a/docs/INSURANCE_EDIT_IMPLEMENTATION.md b/docs/INSURANCE_EDIT_IMPLEMENTATION.md new file mode 100644 index 0000000..7daf641 --- /dev/null +++ b/docs/INSURANCE_EDIT_IMPLEMENTATION.md @@ -0,0 +1,693 @@ +# Insurance Booking in Edit Flow - Implementation Plan + +## Overview + +This document outlines the implementation plan for enabling insurance booking/modification in the edit flow with time-based mutability constraints. The implementation leverages 99% of existing logic from the create flow. + +## Critical Review Updates (2025-10-07) + +**Plan Corrections:** +1. ✅ **Phase 0 Added**: Prerequisites phase completed - insurance parsing infrastructure was missing and has been implemented +2. ⚠️ **Condition Signature Corrected**: `FieldConditionInterface` uses `evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData)` NOT the old signature shown in original plan +3. ⚠️ **Phase 5 May Not Be Needed**: No hardcoded HTMX routes found in current codebase - needs verification +4. ⚠️ **Phase 7 Targets Wrong Methods**: The edit flow uses different data processor methods than assumed in original plan + +**Implementation Progress (2025-10-07):** +- ✅ **Phase 0: Prerequisites** - Booking model insurance parsing infrastructure +- ✅ **Phase 1: Mutability Condition** - Time-based readonly logic with Carbon testability +- ✅ **Phase 2: Data Population** - Insurance extraction in BookingEditDto +- ✅ **Phase 3: Helper Method** - Booking::getInsuranceForParticipant() with tests +- ⏳ **Phases 4-8: Remaining** - Field state, template, data processing, documentation + +**Total Progress:** 4 of 9 phases complete (44%) + +## Business Requirements + +### Mutability Rules + +1. **Standard Case**: Insurance editable up to 30 days before travel date +2. **Late Booking Case**: When booking date is < 30 days before travel, insurance editable up to 3 days after booking date + +### Time-Based Logic + +```php +// Standard case: Can edit if >= 30 days before travel +$daysUntilTravel = $now->diff($travelDate)->days; +$isEditable = $daysUntilTravel >= 30; + +// Late booking case: Can edit if within 3 days of booking date +$daysSinceBooking = $bookingDate->diff($now)->days; +$isEditable = $daysSinceBooking <= 3; +``` + +## Implementation Plan + +### Phase 1: Create Mutability Condition (~50 lines) + +**File**: `src/Form/Service/Condition/InsuranceMutabilityCondition.php` + +**Purpose**: Determine if insurance field should be readonly based on booking/travel dates + +**Dependencies**: +- Implements `FieldConditionInterface` +- Returns `true` if field should be readonly (locked) +- Returns `false` if field should be editable + +**Key Logic**: +```php +public function evaluate(ParticipantDto $participant, BookingDtoInterface $bookingDto): bool +{ + // Only apply to edit context + if (!$bookingDto instanceof BookingEditDto) { + return false; // Always editable in create flow + } + + $now = new DateTimeImmutable(); + $travelDate = $bookingDto->travel->dateFrom; + $bookingDate = $bookingDto->booking->bookingDate; + + // Calculate days until travel + $daysUntilTravel = $now->diff($travelDate)->days; + $isBeforeTravel = $now < $travelDate; + + // Standard case: Editable if >= 30 days before travel + if ($isBeforeTravel && $daysUntilTravel >= 30) { + return false; // Editable + } + + // Late booking case: Check if booking was made < 30 days before travel + $daysFromBookingToTravel = $bookingDate->diff($travelDate)->days; + $wasLateBooking = $daysFromBookingToTravel < 30; + + if ($wasLateBooking) { + // Editable if within 3 days of booking date + $daysSinceBooking = $bookingDate->diff($now)->days; + return $daysSinceBooking > 3; // True = readonly (past 3 days) + } + + // Default: Not editable (readonly) + return true; +} +``` + +**Testing Strategy**: +- Test standard case: 40 days before travel → editable +- Test standard case: 20 days before travel → readonly +- Test late booking: booking 15 days before travel, 2 days after booking → editable +- Test late booking: booking 15 days before travel, 5 days after booking → readonly +- Test edge case: exactly 30 days before travel → editable +- Test edge case: exactly 3 days after booking → editable + +### Phase 2: Populate Insurance Data in BookingEditDto (~10 lines) + +**File**: `src/Form/Model/BookingEditDto.php` + +**Location**: Line ~60 in `fromBooking()` method, after pickup handling + +**Changes**: +```php +// Pickup handling (currently only supports outbound pickup) +$pickup = $booking->getPickupForParticipant($index); +$participantData->pickup = $pickup; + +// Insurance - get insurance for participant +$insurance = $booking->getInsuranceForParticipant($index); +$participantData->insurance = $insurance; + +// Room assignment - extract from booking room mappings +$room = $booking->getRoomForParticipant($index); +``` + +**Note**: Requires helper method in Booking model (see Phase 3) + +### Phase 3: Add Booking Helper Method (~15 lines) + +**File**: `src/BusProNet/Model/Booking.php` + +**Location**: After `getPickupForParticipant()` method (~line 280) + +**Purpose**: Extract insurance for specific participant from booking data + +**Implementation**: +```php +/** + * Gets the insurance assigned to a specific participant. + * + * @param int $participantIndex The participant index (0-based) + * + * @return Insurance|null The assigned insurance or null if none assigned + */ +public function getInsuranceForParticipant(int $participantIndex): ?Insurance +{ + if (!isset($this->insurances) || !is_array($this->insurances)) { + return null; + } + + foreach ($this->insurances as $insurance) { + if (in_array($participantIndex, $insurance->mapping ?? [], true)) { + return $insurance; + } + } + + return null; +} +``` + +**Prerequisites**: +- Verify `Booking::$insurances` property exists and is populated by parser +- If missing, add to `BookingParser` similar to other service arrays + +### Phase 4: Update EditFieldStateProvider (~5 lines) + +**File**: `src/Form/Service/EditFieldStateProvider.php` + +**Location**: In `registerFieldStateConditions()` method after other field states + +**Changes**: +```php +// Insurance - conditionally editable based on time constraints +$this->fieldStateRegistry->registerFieldStateCondition( + 'insurance', + new InsuranceMutabilityCondition(), + [ + 'readonly' => true, + 'help' => 'Versicherungen können nicht mehr geändert werden.', + ] +); +``` + +**Notes**: +- Readonly state applied when condition returns true +- Help text informs user why field is locked +- No hidden state needed - users should see their booked insurance + +### Phase 5: Fix Hardcoded HTMX Routes (~10 lines) + +**File**: `src/Form/Service/ParticipantFieldOptionsProvider.php` + +**Location**: Line ~377 in insurance field provider + +**Current Problem**: +```php +'attr' => [ + 'data-participant-form-target' => 'insuranceInput', + 'hx-post' => '/booking/create/refresh-participant', // HARDCODED! + 'hx-trigger' => 'change', +], +``` + +**Solution**: +```php +// Add parameter to field provider callback +$this->fieldOptionProviders['insurance'] = function ( + BookingDtoInterface $bookingDto, + int $participantIndex, + array $options = [] +) { + // Determine HTMX refresh route based on context + $htmxRoute = $bookingDto instanceof BookingCreateDto + ? '/booking/create/refresh-participant' + : '/booking/edit/refresh-participant'; + + return [ + 'label' => 'Reiseversicherung', + 'placeholder' => 'Keine Versicherung', + 'choice_loader' => $this->insuranceChoiceLoaderFactory->create( + $bookingDto->travel->insurances ?? [], + $participantIndex + ), + 'attr' => [ + 'data-participant-form-target' => 'insuranceInput', + 'hx-post' => $htmxRoute, + 'hx-trigger' => 'change', + ], + ]; +}; +``` + +**Apply to All Fields**: Check and fix other fields with hardcoded routes (skiPass, rentals, courses, etc.) + +### Phase 6: Add Insurance to Template (~3 lines) + +**File**: `templates/booking/edit.html.twig` + +**Location**: After rental insurance field (~line 199) + +**Changes**: +```twig +{# Rental insurance - depends on rentals selection #} +{% if participant.rentalInsuranceSelected is defined %} + {{ form_row(participant.rentalInsuranceSelected) }} +{% endif %} + +{# Insurance - time-based mutability #} +{% if participant.insurance is defined %} + {{ form_row(participant.insurance) }} +{% endif %} +``` + +**Notes**: +- Conditional rendering maintains template stability +- Readonly state handled automatically by field state provider +- Uses existing InsuranceChoiceType with tooltip support + +### Phase 7: Process Insurance in BookingDataProcessor (~40 lines) + +**File**: `src/BusProNet/DataProcessor/BookingDataProcessor.php` + +**Location 1**: Line ~730 in `resetServiceMappings()` - add insurances to reset + +**Changes**: +```php +private function resetServiceMappings(object $bookingData): void +{ + $servicesToReset = [ + ...$bookingData->additionalServices, + ...$bookingData->transportationServices, + ...$bookingData->pickupsOutbound, + ...$bookingData->pickupsInbound, + ...$bookingData->rooms, + ...$bookingData->insurances, // ADD THIS + ]; + + foreach ($servicesToReset as $service) { + if (isset($service->mapping)) { + $service->mapping = []; + } + } +} +``` + +**Location 2**: Line ~903 in `processParticipantData()` - add insurance processing call + +**Changes**: +```php +private function processParticipantData(object $participant, object $bookingData, Travel $travel): void +{ + // ... existing code ... + + // Process insurance assignment + $this->processInsurance($participant, $bookingData); + + // ... rest of method ... +} +``` + +**Location 3**: New method after `processRoomAssignment()` - create insurance processor + +**Implementation**: +```php +/** + * Processes insurance selection for a participant. + * + * Finds the selected insurance in the available insurances and adds + * the participant index to its mapping array. + * + * @param object $participant The participant data from DTO + * @param object $bookingData The booking data object with insurances + */ +private function processInsurance(object $participant, object $bookingData): void +{ + if (!isset($participant->insurance) || null === $participant->insurance) { + return; + } + + $insuranceId = $participant->insurance->id; + + // Find the insurance in the travel's available insurances + foreach ($bookingData->insurances as $insurance) { + if ($insurance->id === $insuranceId) { + $insurance->mapping[] = $participant->index; + break; + } + } +} +``` + +**Location 4**: Line ~950 in `removeUnusedServices()` - add insurances to unused removal + +**Changes**: +```php +private function removeUnusedServices(object $bookingData): void +{ + $serviceArrays = [ + 'additionalServices' => &$bookingData->additionalServices, + 'transportationServices' => &$bookingData->transportationServices, + 'pickupsOutbound' => &$bookingData->pickupsOutbound, + 'pickupsInbound' => &$bookingData->pickupsInbound, + 'rooms' => &$bookingData->rooms, + 'insurances' => &$bookingData->insurances, // ADD THIS + ]; + + // ... existing filtering logic ... +} +``` + +**Location 5**: Line ~1007 in `buildServicePayload()` - add insurances to payload + +**Changes**: +```php +private function buildServicePayload(object $bookingData): array +{ + $allServices = [ + ...$bookingData->additionalServices, + ...$bookingData->transportationServices, + ...$bookingData->pickupsOutbound, + ...$bookingData->pickupsInbound, + ...$bookingData->insurances, // ADD THIS + ]; + + // ... existing payload building logic ... +} +``` + +**Critical Note**: Remove or update comment at line 759-762 that states: +```php +// Insurance data is only included in CREATE flow, not in UPDATE flow. +// The insurance property in the DTO is for informational display only. +``` + +This comment is **no longer accurate** after this implementation. + +### Phase 8: Update Documentation (~20 lines) + +**File**: `docs/BOOKING_EDIT_MODERNIZATION.md` + +**Location**: Add new section after "Phase 4: Room Assignment Implementation" + +**Content**: +```markdown +## Phase 5: Insurance Implementation + +**Status**: ✅ Completed + +**Date**: [Implementation date] + +### Overview +Enabled insurance booking/modification in edit flow with time-based mutability constraints. + +### Implementation Details + +1. **Mutability Condition** (`InsuranceMutabilityCondition`) + - Standard case: Editable up to 30 days before travel + - Late booking: Editable up to 3 days after booking date + - Returns true if field should be readonly + +2. **Data Population** (`BookingEditDto::fromBooking()`) + - Added insurance extraction for each participant + - Uses `Booking::getInsuranceForParticipant()` helper + +3. **Field State** (`EditFieldStateProvider`) + - Registered insurance mutability condition + - Readonly state with help text when locked + +4. **HTMX Routes** (`ParticipantFieldOptionsProvider`) + - Fixed hardcoded create routes to be context-aware + - Applied to insurance and other dynamic fields + +5. **Template** (`edit.html.twig`) + - Added insurance field rendering with conditional check + +6. **Data Processing** (`BookingDataProcessor`) + - Added insurance to service reset/removal/payload logic + - Created `processInsurance()` method for participant mapping + +### Code Reuse +- ✅ `ParticipantInsuranceFieldHandler` - 100% reused (context-agnostic) +- ✅ `InsuranceMatchingService` - 100% reused (eligibility, reassignment) +- ✅ `InsuranceChoiceType` - 100% reused (tooltips, labels) +- ✅ Field state pattern - Same as transportation/services + +### New Code +- ~50 lines: `InsuranceMutabilityCondition` +- ~25 lines: `Booking::getInsuranceForParticipant()` + DTO population +- ~40 lines: `BookingDataProcessor` insurance processing +- ~20 lines: Field state, template, route fixes + +**Total**: ~135 lines of new code, ~500 lines of reused logic + +### Testing Checklist +- [ ] Insurance editable 40 days before travel (standard case) +- [ ] Insurance readonly 20 days before travel (standard case) +- [ ] Insurance editable 2 days after late booking (late case) +- [ ] Insurance readonly 5 days after late booking (late case) +- [ ] Insurance auto-reassignment works on price changes +- [ ] Bulk insurance booking works in edit flow +- [ ] Insurance tooltips display correctly +- [ ] Insurance persists to API on edit submission +- [ ] Readonly help text displays when locked +``` + +## Code Reuse Strategy + +### Fully Reused Components (No Changes) + +1. **ParticipantInsuranceFieldHandler** (200+ lines) + - Already works with `BookingDtoInterface` + - Auto-reassignment logic for price tier changes + - Bulk insurance support for dependent participants + - User notification system + +2. **InsuranceMatchingService** (300+ lines) + - Eligibility filtering by age, price, family status + - Price tier reassignment logic + - Age constraint evaluation at travel date + +3. **ParticipantBulkInsuranceFieldHandler** (100+ lines) + - Batch insurance assignment to all participants + - Price tier adjustment per participant + +4. **InsuranceChoiceType** (80+ lines) + - Tooltip support with product info URLs + - Label formatting with pricing + +5. **InsuranceParser** (150+ lines) + - 3-pass parsing for package family detection + - Complementary insurance handling + +### New/Modified Components (~135 lines total) + +1. **InsuranceMutabilityCondition** (~50 lines) - NEW +2. **Booking::getInsuranceForParticipant()** (~15 lines) - NEW +3. **BookingEditDto insurance population** (~10 lines) - MODIFIED +4. **EditFieldStateProvider** (~5 lines) - MODIFIED +5. **ParticipantFieldOptionsProvider HTMX routes** (~10 lines) - MODIFIED +6. **edit.html.twig** (~3 lines) - MODIFIED +7. **BookingDataProcessor** (~40 lines) - MODIFIED + +## Risk Assessment + +### Low Risk +- ✅ Field handler already context-agnostic (tested in create flow) +- ✅ Mutability pattern proven with services/transportation +- ✅ Data processor pattern established with rooms +- ✅ Template conditional rendering pattern established + +### Medium Risk +- ⚠️ **Booking::$insurances property existence** - Needs verification in parser +- ⚠️ **HTMX route changes** - May affect other fields, test thoroughly +- ⚠️ **Time calculation edge cases** - Test timezone handling + +### Mitigation +- Verify insurance data populated by `BookingParser` before implementation +- Create comprehensive test cases for date calculations +- Test HTMX updates after route context-awareness changes + +## Progress Tracking + +### Phase 0: Prerequisites ✅ COMPLETED (2025-10-07) +- [x] Add `$insurances` property to `Booking` model (line 45) +- [x] Add `$mapping` and `$individualPrice` properties to `Insurance` model (lines 101-106) +- [x] Create `BookingInsurancesParser` for parsing booking insurance XML +- [x] Integrate parser into `BookingParser` constructor and parse method +- [x] Write comprehensive tests (3 tests, 20 assertions - all passing) +- [x] Apply php-cs-fixer to all modified files +- [x] Verify insurance data flow from XML → Parser → Booking model + +**Files Modified:** +- `src/BusProNet/Model/Booking.php` - Added `$insurances` property +- `src/BusProNet/Model/Insurance.php` - Added `$mapping` and `$individualPrice` properties +- `src/BusProNet/XmlParser/BookingInsurancesParser.php` - NEW parser +- `src/BusProNet/XmlParser/BookingParser.php` - Integrated insurance parsing +- `tests/BusProNet/XmlParser/BookingInsurancesParserTest.php` - NEW test file + +### Phase 1: Mutability Condition ✅ COMPLETED (2025-10-07) +- [x] Create `InsuranceMutabilityCondition.php` +- [x] **IMPORTANT**: Use correct signature: `evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool` +- [x] Implement standard case logic (30 days before travel) +- [x] Implement late booking case logic (3 days after booking) +- [x] Add comprehensive PHPDoc +- [x] Apply php-cs-fixer +- [x] Write unit tests (10 test cases, 13 assertions - all passing) +- [x] Use Carbon::setTestNow() for time-dependent tests + +**Files Created:** +- `src/Form/Service/Condition/InsuranceMutabilityCondition.php` (101 lines) +- `tests/Form/Service/Condition/InsuranceMutabilityConditionTest.php` (167 lines) + +**Implementation Details:** +- Uses `Carbon::now()->toDateTimeImmutable()` for testable time handling +- Constants: `DAYS_BEFORE_TRAVEL_THRESHOLD = 30`, `DAYS_AFTER_BOOKING_THRESHOLD = 3` +- Returns `false` (editable) in create flow +- Returns `true` (readonly) when past mutability threshold +- No field dependencies (time-based only) +- Tests use fixed "now" time (2025-01-15 12:00:00) with absolute dates for reliability + +### Phase 2: Data Population ✅ COMPLETED (2025-10-07) +- [x] Verify `Booking::$insurances` exists and is populated (DONE in Phase 0) +- [x] Add insurance extraction to `BookingEditDto::fromBooking()` +- [x] Apply php-cs-fixer + +**Files Modified:** +- `src/Form/Model/BookingEditDto.php` (lines 63-65) - Added insurance extraction + +**Implementation Details:** +- Extracts insurance for each participant using `getInsuranceForParticipant()` helper +- Populates `$participantData->insurance` property +- Positioned after pickup handling, before room assignment +- Follows same pattern as other participant data extraction + +### Phase 3: Helper Method ✅ COMPLETED (2025-10-07) +- [x] Create `Booking::getInsuranceForParticipant()` +- [x] Add PHPDoc with examples +- [x] Apply php-cs-fixer +- [x] Write unit tests (3 test cases, 14 assertions - all passing) + +**Files Created/Modified:** +- `src/BusProNet/Model/Booking.php` (lines 154-170) - NEW helper method +- `tests/BusProNet/Model/BookingTest.php` (lines 74-137) - NEW tests + +**Implementation Details:** +- Searches through `$insurances` array for participant mapping +- Returns `Insurance|null` based on participant index +- Handles empty mapping arrays with `?? []` operator +- Follows same pattern as `getPickupForParticipant()` and `getSkiPassForParticipant()` +- Tests cover: correct retrieval, no insurances, empty mapping + +### Phase 4: Field State +- [ ] Register insurance condition in `EditFieldStateProvider` +- [ ] Add appropriate help text +- [ ] Test readonly state application + +### Phase 5: HTMX Routes ⚠️ REVIEW REQUIRED +- [ ] **NOTE**: Initial review found NO hardcoded HTMX routes in current codebase +- [ ] Verify if this phase is still needed or can be skipped +- [ ] If needed: Fix insurance field route in `ParticipantFieldOptionsProvider` +- [ ] If needed: Audit and fix other hardcoded routes (skiPass, rentals, etc.) +- [ ] Test HTMX updates in both create and edit flows + +### Phase 6: Template +- [ ] Add insurance field to `edit.html.twig` +- [ ] Verify conditional rendering +- [ ] Test readonly display + +### Phase 7: Data Processing +- [ ] Add insurances to `resetServiceMappings()` +- [ ] Create `processInsurance()` method +- [ ] Add insurance to `removeUnusedServices()` +- [ ] Add insurance to `buildServicePayload()` +- [ ] Update/remove outdated comment +- [ ] Test insurance persistence to API + +### Phase 8: Documentation +- [ ] Update `BOOKING_EDIT_MODERNIZATION.md` +- [ ] Document testing checklist +- [ ] Document code reuse metrics + +## Testing Strategy + +### Unit Tests + +**InsuranceMutabilityCondition**: +```php +// Test standard case - editable +$travelDate = new DateTimeImmutable('+40 days'); +$condition = new InsuranceMutabilityCondition(); +$result = $condition->evaluate($participant, $editDto); +$this->assertFalse($result); // False = editable + +// Test standard case - readonly +$travelDate = new DateTimeImmutable('+20 days'); +$result = $condition->evaluate($participant, $editDto); +$this->assertTrue($result); // True = readonly + +// Test late booking - editable +$bookingDate = new DateTimeImmutable('-2 days'); +$travelDate = new DateTimeImmutable('+15 days'); +$result = $condition->evaluate($participant, $editDto); +$this->assertFalse($result); // Within 3 days of booking + +// Test late booking - readonly +$bookingDate = new DateTimeImmutable('-5 days'); +$travelDate = new DateTimeImmutable('+15 days'); +$result = $condition->evaluate($participant, $editDto); +$this->assertTrue($result); // Past 3 days of booking +``` + +**Booking::getInsuranceForParticipant()**: +```php +// Test insurance found for participant +$insurance = $booking->getInsuranceForParticipant(0); +$this->assertInstanceOf(Insurance::class, $insurance); + +// Test no insurance for participant +$insurance = $booking->getInsuranceForParticipant(5); +$this->assertNull($insurance); +``` + +### Integration Tests + +1. **Edit Flow E2E**: + - Load existing booking with insurance + - Verify insurance field populated correctly + - Verify readonly state when past deadline + - Verify editable state when within deadline + +2. **Data Persistence**: + - Edit insurance selection + - Submit form + - Verify API payload includes insurance data + - Verify insurance mapping correct in payload + +3. **Auto-Reassignment**: + - Edit booking, change skipass (affects price) + - Verify insurance auto-reassigns to correct tier + - Verify notification displayed to user + +## Implementation Notes + +### Date Handling +- All date calculations use `DateTimeImmutable` for immutability +- Travel date: `$bookingDto->travel->dateFrom` +- Booking date: `$bookingDto->booking->bookingDate` +- Current date: `new DateTimeImmutable()` + +### Field State System +- Condition returns `true` → field is readonly +- Condition returns `false` → field is editable +- Help text only shown when readonly +- Hidden state not used (users should see booked insurance) + +### HTMX Integration +- Context-aware route selection prevents hardcoded paths +- Maintains real-time form updates in both flows +- OOB swap targets work identically in edit flow + +### Data Flow +1. `BookingEditDto::fromBooking()` populates insurance from API data +2. `EditFieldStateProvider` applies mutability condition +3. Template renders field with readonly state if locked +4. On submit, `BookingDataProcessor` rebuilds insurance mappings +5. API receives updated insurance data in payload + +## Success Criteria + +- ✅ Insurance field visible in edit flow +- ✅ Readonly state applied based on date constraints +- ✅ Auto-reassignment works on price changes +- ✅ Bulk insurance works in edit flow +- ✅ Insurance persists to API correctly +- ✅ User notifications for automatic changes +- ✅ No code duplication from create flow +- ✅ All tests passing +- ✅ php-cs-fixer applied to all files \ No newline at end of file diff --git a/src/BusProNet/Model/Booking.php b/src/BusProNet/Model/Booking.php index c9f6ee1..3cf25cc 100644 --- a/src/BusProNet/Model/Booking.php +++ b/src/BusProNet/Model/Booking.php @@ -42,6 +42,7 @@ class Booking public array $pickupsOutbound = []; public array $pickupsInbound = []; public array $surcharges = []; + public array $insurances = []; public ?int $invoiceNumber = null; public ?float $totalPrice = null; public ?string $travelInfoUrl = null; @@ -150,6 +151,24 @@ class Booking return null; } + /** + * Gets the insurance assigned to a specific participant. + * + * @param int $participantIndex The participant index (0-based) + * + * @return Insurance|null The assigned insurance or null if none assigned + */ + public function getInsuranceForParticipant(int $participantIndex): ?Insurance + { + foreach ($this->insurances as $insurance) { + if (in_array($participantIndex, $insurance->mapping ?? [], true)) { + return $insurance; + } + } + + return null; + } + /** * Retrieves ski pass for a specific participant. * diff --git a/src/BusProNet/Model/Insurance.php b/src/BusProNet/Model/Insurance.php index 062a905..899cc89 100644 --- a/src/BusProNet/Model/Insurance.php +++ b/src/BusProNet/Model/Insurance.php @@ -95,6 +95,16 @@ class Insurance #[Groups(['api:single', 'api:list'])] public array $containedInsuranceIds = []; + /** + * @var array Participant indices assigned to this insurance (booking data only) + */ + public array $mapping = []; + + /** + * @var array Individual prices per participant index (booking data only) + */ + public array $individualPrice = []; + public function __toString(): string { return (string) $this->id; diff --git a/src/BusProNet/XmlParser/BookingInsurancesParser.php b/src/BusProNet/XmlParser/BookingInsurancesParser.php new file mode 100644 index 0000000..0175e79 --- /dev/null +++ b/src/BusProNet/XmlParser/BookingInsurancesParser.php @@ -0,0 +1,48 @@ +each(function (Crawler $node) use (&$insurances) { + $insurance = new Insurance(); + $insurance->id = (int) $node->attr('idversicherung'); + $insurance->label = $node->attr('bezeichnung'); + $insurance->price = $this->stringToFloat($node->attr('gesamtpreis')); + + // Parse participant mapping (zuordnung attribute) + $mapping = $this->stringToArray($node->attr('zuordnung')); + $insurance->mapping = array_map(function ($index) { + return (int) $index - 1; + }, $mapping); + + // Parse individual prices per participant + $individualPrices = array_map( + function ($price) { + return $this->stringToFloat($price); + }, + $this->stringToArray($node->attr('einzelpreis', ''), '/') + ); + $insurance->individualPrice = array_combine($insurance->mapping, $individualPrices); + + $insurances[$insurance->id] = $insurance; + }); + + return $insurances; + } +} diff --git a/src/BusProNet/XmlParser/BookingParser.php b/src/BusProNet/XmlParser/BookingParser.php index 64c3eb8..f2f38be 100644 --- a/src/BusProNet/XmlParser/BookingParser.php +++ b/src/BusProNet/XmlParser/BookingParser.php @@ -16,6 +16,7 @@ class BookingParser extends AbstractParser private readonly RoomsParser $roomsParser; private readonly PickupsParser $pickupsParser; private readonly SurchargesParser $surchargesParser; + private readonly BookingInsurancesParser $insurancesParser; public function __construct() { @@ -23,6 +24,7 @@ class BookingParser extends AbstractParser $this->roomsParser = new RoomsParser(); $this->pickupsParser = new PickupsParser(); $this->surchargesParser = new SurchargesParser(); + $this->insurancesParser = new BookingInsurancesParser(); } public function parse(Crawler $node): Booking @@ -101,6 +103,11 @@ class BookingParser extends AbstractParser $booking->surcharges = $this->surchargesParser->parse($surchargesData); } + $insurancesData = $node->filterXPath('//versicherungen/versicherung'); + if (0 < $insurancesData->count()) { + $booking->insurances = $this->insurancesParser->parse($insurancesData); + } + return $booking; } diff --git a/src/Form/Model/BookingEditDto.php b/src/Form/Model/BookingEditDto.php index a508c69..88b658c 100644 --- a/src/Form/Model/BookingEditDto.php +++ b/src/Form/Model/BookingEditDto.php @@ -60,6 +60,10 @@ class BookingEditDto implements BookingDtoInterface $pickup = $booking->getPickupForParticipant($index); $participantData->pickup = $pickup; + // Insurance - get insurance for participant + $insurance = $booking->getInsuranceForParticipant($index); + $participantData->insurance = $insurance; + // Room assignment - extract from booking room mappings $room = $booking->getRoomForParticipant($index); $participantData->assignedRoomId = $room?->id; diff --git a/src/Form/Service/Condition/InsuranceMutabilityCondition.php b/src/Form/Service/Condition/InsuranceMutabilityCondition.php new file mode 100644 index 0000000..dc537d9 --- /dev/null +++ b/src/Form/Service/Condition/InsuranceMutabilityCondition.php @@ -0,0 +1,101 @@ += 30 days before travel + * 3. In edit flow, check late booking case: editable if within 3 days of booking date + * 4. Otherwise: readonly + * + * @param BookingDtoInterface $bookingDto The current booking data + * @param int $participantIndex The participant index (unused for insurance mutability) + * @param array $formData Current form data (unused for insurance mutability) + * + * @return bool True if field should be readonly, false if editable + */ + public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + { + // Only apply mutability constraints to edit flow + if (!$bookingDto instanceof BookingEditDto) { + return false; // Always editable in create flow + } + + $now = \Carbon\Carbon::now()->toDateTimeImmutable(); + $travelDate = $bookingDto->travel->dateFrom; + $bookingDate = $bookingDto->booking->bookingDate; + + // Calculate days until travel + $daysUntilTravel = $now->diff($travelDate)->days; + $isBeforeTravel = $now < $travelDate; + + // Standard case: Editable if >= 30 days before travel + if ($isBeforeTravel && $daysUntilTravel >= self::DAYS_BEFORE_TRAVEL_THRESHOLD) { + return false; // Editable + } + + // Late booking case: Check if booking was made < 30 days before travel + $daysFromBookingToTravel = $bookingDate->diff($travelDate)->days; + $wasLateBooking = $daysFromBookingToTravel < self::DAYS_BEFORE_TRAVEL_THRESHOLD; + + if ($wasLateBooking) { + // Editable if within 3 days of booking date + $daysSinceBooking = $bookingDate->diff($now)->days; + + return $daysSinceBooking > self::DAYS_AFTER_BOOKING_THRESHOLD; // True = readonly (past threshold) + } + + // Default: Not editable (readonly) + return true; + } + + /** + * Returns field names that trigger re-evaluation of this condition. + * + * Insurance mutability is based on dates, not other form fields, + * so no field dependencies are needed. + * + * @return string[] Empty array - no field dependencies + */ + public function getDependentFields(): array + { + return []; + } + + /** + * Returns a human-readable description of this condition. + * + * @return string Description of the insurance mutability logic + */ + public function getDescription(): string + { + return sprintf( + 'Insurance is not editable (>= %d days before travel or > %d days after late booking)', + self::DAYS_BEFORE_TRAVEL_THRESHOLD, + self::DAYS_AFTER_BOOKING_THRESHOLD + ); + } +} diff --git a/tests/BusProNet/Model/BookingTest.php b/tests/BusProNet/Model/BookingTest.php index e38babc..0f32e52 100644 --- a/tests/BusProNet/Model/BookingTest.php +++ b/tests/BusProNet/Model/BookingTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\BusProNet\Model; use App\BusProNet\Model\Booking; +use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Service; use PHPUnit\Framework\TestCase; @@ -69,4 +70,69 @@ class BookingTest extends TestCase $result = $booking->getSkiPassForParticipant(0); $this->assertNull($result); } + + public function testGetInsuranceForParticipantReturnsCorrectInsurance(): void + { + $booking = new Booking(); + + // Create insurance for participants 0 and 2 + $insurance1 = new Insurance(); + $insurance1->id = 100; + $insurance1->label = 'Reiseschutz Platin'; + $insurance1->mapping = [0, 2]; + + // Create insurance for participant 1 + $insurance2 = new Insurance(); + $insurance2->id = 200; + $insurance2->label = 'Reiseschutz Gold'; + $insurance2->mapping = [1]; + + $booking->insurances = [ + 100 => $insurance1, + 200 => $insurance2, + ]; + + // Test: participant 0 should get insurance1 + $result = $booking->getInsuranceForParticipant(0); + $this->assertSame($insurance1, $result); + $this->assertEquals(100, $result->id); + + // Test: participant 1 should get insurance2 + $result = $booking->getInsuranceForParticipant(1); + $this->assertSame($insurance2, $result); + $this->assertEquals(200, $result->id); + + // Test: participant 2 should get insurance1 + $result = $booking->getInsuranceForParticipant(2); + $this->assertSame($insurance1, $result); + + // Test: participant 3 should get null (no insurance assigned) + $result = $booking->getInsuranceForParticipant(3); + $this->assertNull($result); + } + + public function testGetInsuranceForParticipantReturnsNullWhenNoInsurances(): void + { + $booking = new Booking(); + $booking->insurances = []; + + $result = $booking->getInsuranceForParticipant(0); + $this->assertNull($result); + } + + public function testGetInsuranceForParticipantHandlesEmptyMapping(): void + { + $booking = new Booking(); + + // Create insurance with empty mapping + $insurance = new Insurance(); + $insurance->id = 100; + $insurance->label = 'Reiseschutz'; + $insurance->mapping = []; + + $booking->insurances = [100 => $insurance]; + + $result = $booking->getInsuranceForParticipant(0); + $this->assertNull($result, 'Should return null when mapping is empty'); + } } diff --git a/tests/BusProNet/XmlParser/BookingInsurancesParserTest.php b/tests/BusProNet/XmlParser/BookingInsurancesParserTest.php new file mode 100644 index 0000000..fd7b40b --- /dev/null +++ b/tests/BusProNet/XmlParser/BookingInsurancesParserTest.php @@ -0,0 +1,100 @@ + + + + + + +XML; + + $crawler = new Crawler($xml); + $insurancesData = $crawler->filterXPath('//versicherungen/versicherung'); + + $parser = new BookingInsurancesParser(); + $insurances = $parser->parse($insurancesData); + + $this->assertCount(1, $insurances); + $this->assertArrayHasKey(177240, $insurances); + + $insurance = $insurances[177240]; + $this->assertEquals(177240, $insurance->id); + $this->assertEquals('Reiseschutz Platin Auto/Bahn/Bus (Europa)', $insurance->label); + $this->assertEquals(45.0, $insurance->price); + $this->assertEquals([0], $insurance->mapping); // zuordnung="1" maps to participant index 0 + $this->assertEquals([0 => 45.0], $insurance->individualPrice); + } + + public function testParseMultipleInsurances(): void + { + $xml = <<<'XML' + + + + + + + +XML; + + $crawler = new Crawler($xml); + $insurancesData = $crawler->filterXPath('//versicherungen/versicherung'); + + $parser = new BookingInsurancesParser(); + $insurances = $parser->parse($insurancesData); + + $this->assertCount(2, $insurances); + + // First insurance + $insurance1 = $insurances[100]; + $this->assertEquals(100, $insurance1->id); + $this->assertEquals('Insurance A', $insurance1->label); + $this->assertEquals(45.0, $insurance1->price); + $this->assertEquals([0], $insurance1->mapping); // zuordnung="1" maps to index 0 + $this->assertEquals([0 => 45.0], $insurance1->individualPrice); + + // Second insurance + $insurance2 = $insurances[200]; + $this->assertEquals(200, $insurance2->id); + $this->assertEquals('Insurance B', $insurance2->label); + $this->assertEquals(30.0, $insurance2->price); + $this->assertEquals([1], $insurance2->mapping); // zuordnung="2" maps to index 1 + $this->assertEquals([1 => 30.0], $insurance2->individualPrice); + } + + public function testParseEmptyInsurancesReturnsEmptyArray(): void + { + $xml = <<<'XML' + + + + + +XML; + + $crawler = new Crawler($xml); + $insurancesData = $crawler->filterXPath('//versicherungen/versicherung'); + + $parser = new BookingInsurancesParser(); + $insurances = $parser->parse($insurancesData); + + $this->assertCount(0, $insurances); + $this->assertIsArray($insurances); + } +} diff --git a/tests/Form/Service/Condition/InsuranceMutabilityConditionTest.php b/tests/Form/Service/Condition/InsuranceMutabilityConditionTest.php new file mode 100644 index 0000000..9db25c0 --- /dev/null +++ b/tests/Form/Service/Condition/InsuranceMutabilityConditionTest.php @@ -0,0 +1,167 @@ +condition = new InsuranceMutabilityCondition(); + + // Set a fixed "now" for time-dependent tests + Carbon::setTestNow('2025-01-15 12:00:00'); + } + + protected function tearDown(): void + { + // Reset Carbon's test time after each test + Carbon::setTestNow(); + } + + public function testAlwaysEditableInCreateFlow(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('+10 days'); + + $bookingDto = new BookingCreateDto($travel, 123); + + $result = $this->condition->evaluate($bookingDto, 0, []); + + $this->assertFalse($result, 'Insurance should always be editable in create flow'); + } + + public function testEditableWhen40DaysBeforeTravel(): void + { + // Now is 2025-01-15, travel is 40 days later: 2025-02-24 + $travelDate = new \DateTimeImmutable('2025-02-24 12:00:00'); + $bookingDate = new \DateTimeImmutable('2025-01-05 12:00:00'); + + $bookingDto = $this->createEditDto($travelDate, $bookingDate); + + $result = $this->condition->evaluate($bookingDto, 0, []); + + $this->assertFalse($result, 'Insurance should be editable when 40 days before travel (standard case)'); + } + + public function testEditableWhenExactly30DaysBeforeTravel(): void + { + // Now is fixed at 2025-01-15 12:00:00 + // Travel date exactly 30 days later: 2025-02-14 12:00:00 + $travelDate = new \DateTimeImmutable('2025-02-14 12:00:00'); + $bookingDate = new \DateTimeImmutable('2024-12-20 12:00:00'); + + $bookingDto = $this->createEditDto($travelDate, $bookingDate); + + $result = $this->condition->evaluate($bookingDto, 0, []); + + $this->assertFalse($result, 'Insurance should be editable when exactly 30 days before travel (edge case)'); + } + + public function testReadonlyWhen20DaysBeforeTravel(): void + { + // Now is 2025-01-15, travel is 20 days later: 2025-02-04 + $travelDate = new \DateTimeImmutable('2025-02-04 12:00:00'); + $bookingDate = new \DateTimeImmutable('2025-01-05 12:00:00'); + + $bookingDto = $this->createEditDto($travelDate, $bookingDate); + + $result = $this->condition->evaluate($bookingDto, 0, []); + + $this->assertTrue($result, 'Insurance should be readonly when 20 days before travel (standard case)'); + } + + public function testEditableWhen2DaysAfterLateBooking(): void + { + // Now is 2025-01-15, booking was 2 days ago: 2025-01-13 + // Travel is 15 days after booking: 2025-01-28 (late booking) + $bookingDate = new \DateTimeImmutable('2025-01-13 12:00:00'); + $travelDate = new \DateTimeImmutable('2025-01-28 12:00:00'); + + $bookingDto = $this->createEditDto($travelDate, $bookingDate); + + $result = $this->condition->evaluate($bookingDto, 0, []); + + $this->assertFalse($result, 'Insurance should be editable 2 days after late booking'); + } + + public function testEditableWhenExactly3DaysAfterLateBooking(): void + { + // Now is 2025-01-15, booking was exactly 3 days ago: 2025-01-12 + // Travel is 15 days after booking: 2025-01-27 (late booking) + $bookingDate = new \DateTimeImmutable('2025-01-12 12:00:00'); + $travelDate = new \DateTimeImmutable('2025-01-27 12:00:00'); + + $bookingDto = $this->createEditDto($travelDate, $bookingDate); + + $result = $this->condition->evaluate($bookingDto, 0, []); + + $this->assertFalse($result, 'Insurance should be editable exactly 3 days after late booking (edge case)'); + } + + public function testReadonlyWhen5DaysAfterLateBooking(): void + { + // Now is 2025-01-15, booking was 5 days ago: 2025-01-10 + // Travel is 15 days after booking: 2025-01-25 (late booking) + $bookingDate = new \DateTimeImmutable('2025-01-10 12:00:00'); + $travelDate = new \DateTimeImmutable('2025-01-25 12:00:00'); + + $bookingDto = $this->createEditDto($travelDate, $bookingDate); + + $result = $this->condition->evaluate($bookingDto, 0, []); + + $this->assertTrue($result, 'Insurance should be readonly 5 days after late booking'); + } + + public function testReadonlyWhenTravelDatePassed(): void + { + // Now is 2025-01-15, travel was 5 days ago: 2025-01-10 + $travelDate = new \DateTimeImmutable('2025-01-10 12:00:00'); + $bookingDate = new \DateTimeImmutable('2024-12-01 12:00:00'); + + $bookingDto = $this->createEditDto($travelDate, $bookingDate); + + $result = $this->condition->evaluate($bookingDto, 0, []); + + $this->assertTrue($result, 'Insurance should be readonly when travel date has passed'); + } + + public function testGetDependentFieldsReturnsEmptyArray(): void + { + $result = $this->condition->getDependentFields(); + + $this->assertIsArray($result); + $this->assertEmpty($result, 'Insurance mutability has no field dependencies'); + } + + public function testGetDescriptionReturnsString(): void + { + $result = $this->condition->getDescription(); + + $this->assertIsString($result); + $this->assertStringContainsString('30', $result); + $this->assertStringContainsString('3', $result); + } + + private function createEditDto(\DateTimeImmutable $travelDate, \DateTimeImmutable $bookingDate): BookingEditDto + { + $booking = new Booking(); + $booking->bookingDate = $bookingDate; + + $travel = new Travel(); + $travel->dateFrom = $travelDate; + + return new BookingEditDto($booking, $travel); + } +}