diff --git a/docs/booking-dto-unification-plan.md b/docs/booking-dto-unification-plan.md new file mode 100644 index 0000000..0c3cc32 --- /dev/null +++ b/docs/booking-dto-unification-plan.md @@ -0,0 +1,323 @@ +# Booking DTO Unification Plan + +## Problem Statement + +The current implementation uses two separate DTOs (`BookingCreateDto` and `BookingEditDto`) with fundamentally different data structures: + +- **Create mode**: Services stored in participant DTOs (e.g., `$participant->insurance`, `$participant->courses`) +- **Edit mode**: Services stored in the booking object (e.g., `$booking->insurances`, `$booking->additionalServices`) + +This divergence causes multiple issues: +1. Pricing and summary calculations fail in edit mode +2. Field handlers need complex mode-specific logic +3. Data processor needs separate handling for create vs edit +4. Code duplication and increased complexity +5. Bugs due to assumptions about data structure + +## Solution: Unified BookingDto + +Create a single `BookingDto` class that stores all service selections in participant DTOs for BOTH create and edit modes. Different modes are handled through different instantiation methods. + +## Implementation Plan + +### Phase 1: Create Unified BookingDto Class + +**File**: `src/Form/Model/BookingDto.php` + +**Changes**: +- Merge `BookingCreateDto` and `BookingEditDto` into single `BookingDto` class +- Keep all participant-based service storage (insurance, courses, skiPass, rentals, transportation, etc.) +- Add `mode` property (MODE_CREATE or MODE_EDIT) +- Add `booking` property (null in create mode, Booking object in edit mode for metadata only) +- Implement `BookingDtoInterface` interface + +**Constructor signatures**: +```php +// Create mode +public function __construct(Travel $travel, int $agencyId) + +// Edit mode (static factory) +public static function fromBooking(Booking $booking, Travel $travel): static +``` + +**Key method**: +```php +public function getMode(): string +{ + return null !== $this->booking ? self::MODE_EDIT : self::MODE_CREATE; +} +``` + +### Phase 2: Update BookingDto::fromBooking() for Edit Mode + +**File**: `src/Form/Model/BookingDto.php` + +**Responsibilities**: +1. Extract services from booking object and assign to participant DTOs +2. Map insurances: `$participant->insurance = $booking->getInsuranceForParticipant($index)` +3. Map services: `$participant->courses = $booking->getAdditionalServicesForParticipantByGroup($index, 'COURSES')` +4. Map transportation: `$participant->transportationOutbound = $booking->getTransportationForParticipant($index, 'OUTBOUND')` +5. Map pickup locations +6. Map room assignments +7. Set body dimensions from applicant for participant 0 +8. Set parking from form data (need to check if stored in booking) + +**Data extraction methods needed**: +- `Booking::getInsuranceForParticipant(int $index): ?Insurance` +- Existing methods for other services can be reused + +### Phase 3: Update ParticipantDto + +**File**: `src/Form/Model/ParticipantDto.php` + +**Changes**: +- Already has all necessary service properties +- Ensure `fromPersonalData()` copies body dimensions correctly +- No structural changes needed + +### Phase 4: Update Form Types + +**Files to update**: +- `src/Form/BookingType.php` → Rename to `BookingType` (generic) +- `src/Form/BookingEditType.php` → Delete (use unified BookingType) +- `src/Form/BookingParticipantType.php` → Already works with ParticipantDto, should work unchanged + +**Changes**: +- Update `BookingType` to use `BookingDto::class` as data_class +- Remove mode-specific form type distinction +- Pass `edit_mode` option to child forms for field state provider selection + +### Phase 5: Update Controllers + +#### CreateStep2Controller +**File**: `src/Controller/Booking/CreateStep2Controller.php` + +**Changes**: +- Change `BookingCreateDto` → `BookingDto` +- Constructor instantiation remains same +- All logic should work unchanged (services already in participant DTOs) + +#### EditController +**File**: `src/Controller/Booking/EditController.php` + +**Changes**: +- Change `BookingEditDto` → `BookingDto` +- Change `BookingEditDto::fromBooking()` → `BookingDto::fromBooking()` +- Form type: change `BookingEditType` → `BookingType` with `['edit_mode' => true]` option +- All pricing and summary calculations should now work (same DTO structure as create) + +### Phase 6: Update BookingDataProcessor + +**File**: `src/BusProNet/DataProcessor/BookingDataProcessor.php` + +**Major simplification**: + +#### createBookingRequestPayload() (Create flow) +- Already works with participant DTOs +- Change signature: `BookingDto` instead of `BookingCreateDto` +- No other changes needed + +#### createUpdateRequestPayload() (Edit flow) +**Current problems**: +- Tries to read services from `$bookingDto->booking` object +- Complex mapping and resetting logic +- `processParticipantServices()` needs to add services to booking data from travel data + +**New approach**: +- Services already in participant DTOs (populated by `fromBooking()`) +- Can reuse create flow logic almost entirely +- Only difference: include `idbuchung` and participant `idadresseperson` in payload + +**Unified approach**: +```php +public function createPayload(BookingDto $bookingDto, string $type): array +{ + // Apply bulk insurance if enabled + $this->applyBulkInsuranceIfActive($bookingDto); + + // Collect service mappings (works same for both modes) + $serviceMap = $this->collectServiceMappings($bookingDto); + $transportationMap = $this->collectTransportationMappings($bookingDto); + $roomMap = $this->collectRoomMappings($bookingDto); + $pickupMap = $this->collectPickupMappings($bookingDto); + $insuranceMap = $this->collectInsuranceMappings($bookingDto); + + // Build payload based on mode + if (BookingDtoInterface::MODE_EDIT === $bookingDto->getMode()) { + return $this->buildUpdatePayload($bookingDto, ...maps); + } else { + return $this->buildCreatePayload($bookingDto, $type, ...maps); + } +} +``` + +**Simplifications**: +- Remove `processParticipantServices()` complexity +- Remove `resetServiceMappings()` +- Remove `removeUnusedServices()` +- Direct mapping from participant DTOs to payload + +### Phase 7: Update Field Handlers + +**Files**: All handlers in `src/Form/Service/` + +**Changes needed**: +- Handlers already work with participant DTOs +- No changes needed (they don't care about mode) +- Registry already handles both modes + +### Phase 8: Update Service Layer + +#### BookingService +**File**: `src/Service/BookingService.php` + +**Changes**: +- Update type hints: `BookingCreateDto|BookingEditDto` → `BookingDto` +- `getRoomSummaryAndParticipantCount()` should now work for both modes (same DTO structure) +- No logic changes needed + +#### PriceCalculator +**File**: `src/Service/PriceCalculator.php` + +**Changes**: +- Update type hints to use `BookingDto` +- All calculations work with participant DTOs, should work unchanged +- Mode detection: `$bookingDto->getMode()` instead of `instanceof` checks + +### Phase 9: Update Field State Providers + +**Files**: +- `src/Form/Service/CreateFieldStateProvider.php` +- `src/Form/Service/EditFieldStateProvider.php` + +**Changes**: +- Already use `BookingDtoInterface`, no changes needed +- Continue to be selected based on `edit_mode` form option + +### Phase 10: Update Templates + +**Files**: +- `templates/booking/create_step_2.html.twig` +- `templates/booking/edit.html.twig` + +**Changes**: +- Variable naming: `bookingCreateDto` → `bookingDto`, `bookingEditDto` → `bookingDto` +- All logic should work unchanged (both render participant forms) + +### Phase 11: Critical New Method in Booking Model + +**File**: `src/BusProNet/Model/Booking.php` + +**Add method**: +```php +public function getInsuranceForParticipant(int $index): ?Insurance +{ + foreach ($this->insurances as $insurance) { + if (in_array($index, $insurance->mapping)) { + return $insurance; + } + } + return null; +} +``` + +Similar methods may be needed for other services if not already present. + +## Migration Strategy + +### Step 1: Create new BookingDto (keep old DTOs) +- Create `src/Form/Model/BookingDto.php` +- Implement both constructor and `fromBooking()` +- Keep `BookingCreateDto` and `BookingEditDto` temporarily + +### Step 2: Update edit flow to use new DTO +- Update `EditController` to use `BookingDto` +- Update `BookingDataProcessor::createUpdateRequestPayload()` to accept `BookingDto` +- Test edit flow thoroughly + +### Step 3: Update create flow to use new DTO +- Update `CreateStep2Controller` to use `BookingDto` +- Test create flow thoroughly + +### Step 4: Cleanup +- Delete `BookingCreateDto.php` +- Delete `BookingEditDto.php` +- Delete `BookingEditType.php` +- Update all remaining type hints + +## Testing Checklist + +### Edit Flow +- [ ] Load existing booking with all service types +- [ ] Form displays all current selections correctly +- [ ] Summary sidebar shows all services and pricing +- [ ] Body dimensions show for applicant +- [ ] Change insurance (individual and bulk) +- [ ] Change services (courses, skiPass, rentals, board) +- [ ] Change transportation +- [ ] Submit changes successfully +- [ ] API receives correct payload with insurances +- [ ] After redirect, summary shows correctly + +### Create Flow +- [ ] Select rooms +- [ ] Add participants +- [ ] Select services for participants +- [ ] Select insurances (individual and bulk) +- [ ] Pricing calculates correctly +- [ ] Summary shows all selections +- [ ] Submit creates booking successfully + +### Field State System +- [ ] Conditional fields show/hide correctly in both modes +- [ ] Readonly fields work in edit mode +- [ ] Age-dependent fields work in both modes +- [ ] Bulk insurance checkbox works + +### Data Integrity +- [ ] No service data loss during mode transitions +- [ ] Insurance IDs match correctly +- [ ] Participant indices correct in both modes +- [ ] Room assignments preserved + +## Benefits of Unified DTO + +1. **Single source of truth**: All service selections in one place (participant DTOs) +2. **Simplified calculations**: Pricing, summary, and totals work identically for both modes +3. **Reduced complexity**: No mode-specific logic in services and calculators +4. **Easier testing**: One DTO structure to test +5. **Better maintainability**: Changes to service structure only need updating in one place +6. **Consistent field handlers**: Handlers work with same data structure regardless of mode +7. **Cleaner templates**: Same rendering logic for both modes + +## Risks and Mitigation + +### Risk: Breaking existing create flow +**Mitigation**: Migrate edit flow first, test thoroughly, then migrate create flow + +### Risk: Data loss during form processing +**Mitigation**: Extensive logging during migration, compare payloads before/after + +### Risk: Field handler incompatibility +**Mitigation**: Field handlers already work with ParticipantDto, minimal changes needed + +### Risk: Performance impact from data extraction +**Mitigation**: `fromBooking()` runs once per request, acceptable overhead + +## Timeline Estimate + +- **Phase 1-3** (DTO creation): 2 hours +- **Phase 4-5** (Forms & Controllers): 2 hours +- **Phase 6** (DataProcessor refactor): 3 hours +- **Phase 7-10** (Service layer & templates): 2 hours +- **Phase 11** (Booking model methods): 1 hour +- **Testing & Fixes**: 3 hours + +**Total**: ~13 hours + +## Notes + +- Current code has accumulated technical debt from multiple iterations +- Clean refactor will improve long-term maintainability +- Most existing logic can be reused (field handlers, conditions, validators) +- Main work is in `fromBooking()` extraction logic and DataProcessor simplification \ No newline at end of file diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index dc640d8..3938d20 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -16,6 +16,7 @@ use App\BusProNet\Model\Travel; use App\BusProNet\Traits\ApiClientTrait; use App\BusProNet\XmlParser\ApiResponseParser; use App\Form\Model\BookingCreateDto; +use App\Form\Model\BookingDto; use App\Form\Model\BookingEditDto; use App\Form\Model\RegistrationDto; use League\Flysystem\FilesystemException; @@ -48,6 +49,7 @@ class ApiClient private readonly ApiResponseParser $responseParser, private readonly FilesystemOperator $xmlDump, private readonly LoggerInterface $logger, + private readonly BookingDataProcessor $bookingDataProcessor, array $options, ) { $this->config = $this->resolveOptions($options); @@ -192,9 +194,9 @@ class ApiClient /** * @throws ApiClientException */ - public function updateBooking(BookingEditDto $formData, bool $debug = false): Notification|BookingUpdate + public function updateBooking(BookingDto $formData, bool $debug = false): Notification|BookingUpdate { - $payload = (new BookingDataProcessor())->createUpdateRequestPayload($formData); + $payload = $this->bookingDataProcessor->createUpdateRequestPayload($formData); $data = [ 'user' => $this->config['bpn_username'], @@ -222,7 +224,7 @@ class ApiClient */ public function createBookingInquiry(BookingCreateDto $bookingDto, bool $debug = false): Notification|BookingResponse { - $payload = (new BookingDataProcessor())->createBookingRequestPayload($bookingDto, 'Anfrage'); + $payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, 'Anfrage'); $data = [ 'user' => $this->config['bpn_username'], @@ -249,7 +251,7 @@ class ApiClient */ public function createBooking(BookingCreateDto $bookingDto, bool $debug = false): Notification|BookingResponse { - $payload = (new BookingDataProcessor())->createBookingRequestPayload($bookingDto, 'Buchung'); + $payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, 'Buchung'); $data = [ 'user' => $this->config['bpn_username'], diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index c528859..a879d74 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -5,9 +5,18 @@ declare(strict_types=1); namespace App\BusProNet\DataProcessor; use App\BusProNet\Constants; +use App\BusProNet\Model\Address; +use App\BusProNet\Model\Booking; use App\BusProNet\Model\Communication; +use App\BusProNet\Model\PersonalData; +use App\BusProNet\Model\Travel; +use App\BusProNet\Utility\DirectionMapper; +use App\Form\Model\BankAccountDto; use App\Form\Model\BookingCreateDto; +use App\Form\Model\BookingDto; use App\Form\Model\BookingEditDto; +use App\Form\Model\ParticipantDto; +use App\Service\InsuranceMatchingService; /** * Processes booking form data and converts it into BusProNet API payload format. @@ -20,6 +29,89 @@ use App\Form\Model\BookingEditDto; */ class BookingDataProcessor { + public function __construct( + private readonly InsuranceMatchingService $insuranceMatchingService, + ) { + } + + /** + * Creates a BookingDto from an existing Booking entity (for edit mode). + * + * Extracts all service selections from the booking entity and assigns them + * to participant DTOs, creating a unified data structure identical to create mode. + */ + public function createBookingDtoFromBooking(Booking $booking, Travel $travel): BookingDto + { + $dto = new BookingDto($travel, $booking->hotelId); + + $dto->booking = $booking; + $dto->agencyId = $booking->agencyId; + + // Map payment ID from API to form payment method + $dto->paymentMethod = match ($booking->paymentId) { + (string) Constants::PAYMENT_TYPE_ID_DEBIT => Constants::PAYMENT_METHOD_DEBIT, + (string) Constants::PAYMENT_TYPE_ID_TRANSFER => Constants::PAYMENT_METHOD_TRANSFER, + default => Constants::PAYMENT_METHOD_TRANSFER, + }; + + if (null !== $booking->bankAccount) { + $dto->bankAccount = BankAccountDto::fromBankAccount($booking->bankAccount); + } + + foreach ($booking->participants as $index => $participant) { + /** @var PersonalData $participant */ + $participantData = ParticipantDto::fromPersonalData($participant); + $participantData->index = $index; + + // Extract service selections from booking and assign to participant DTO + $participantData->courses = $booking + ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES); + + $participantData->skiPass = $booking->getSkiPassForParticipant($index); + + $participantData->additionalServices = $booking + ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_ADDITIONAL); + + $participantData->board = $booking + ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_BOARD); + + $participantData->rentals = $booking + ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_RENTALS); + + // Rental insurance (single service, not array) + $participantData->rentalInsurance = $booking->getRentalInsuranceForParticipant($index); + $participantData->rentalInsuranceSelected = null !== $participantData->rentalInsurance; + + // Transportation services + $participantData->transportationOutbound = $booking + ->getTransportationServiceForParticipantAndDirection($index, DirectionMapper::OUTBOUND_BOOKING); + + $participantData->transportationInbound = $booking + ->getTransportationServiceForParticipantAndDirection($index, DirectionMapper::INBOUND_BOOKING); + + // Pickup location + $participantData->pickup = $booking->getPickupForParticipant($index); + + // Parking (stored in form data, not in booking entity - needs special handling) + // For now, leave as false - may need to extract from transportation services + $participantData->parking = false; + + // License plate (not stored in booking entity) + $participantData->licensePlate = null; + + // Insurance + $participantData->insurance = $booking->getInsuranceForParticipant($index); + + // Room assignment + $room = $booking->getRoomForParticipant($index); + $participantData->assignedRoomId = $room?->id; + + $dto->participants[$index] = $participantData; + } + + return $dto; + } + /** * Creates an update request payload for the BusProNet API from booking form data. * @@ -34,18 +126,29 @@ class BookingDataProcessor * - Adding new services from travel data when participants select them * - Removing services with no participant mappings * - Updating participant personal data from form input - * - Synchronizing applicant data with first participant details * - Building the final API payload structure * - * @param BookingEditDto|null $formData The booking edit form data containing updated participant and service selections + * IMPORTANT: The applicant's address must be preserved from the original booking data stored in the DTO, + * as it can be lost during form binding when ParticipantDto stores address references. + * + * @param BookingDto|null $formData The booking edit form data containing updated participant and service selections * * @return array The structured payload array ready for BusProNet API submission */ - public function createUpdateRequestPayload(?BookingEditDto $formData): array + public function createUpdateRequestPayload(?BookingDto $formData): array { + // Apply bulk insurance if enabled (modifies DTO in place) + $this->applyBulkInsuranceIfActive($formData); + $bookingData = $formData->booking; $travelData = $formData->travel; + // CRITICAL: The applicant address may have been lost during form binding because ParticipantDto::fromPersonalData() + // stores a reference to the address object, and Symfony's form binding can modify it in place. + // Since we can't easily restore it here without the original booking, we rely on the EditController + // to preserve the original booking's applicant data by fetching fresh data before calling updateBooking(). + // The applicant data should NEVER be modified in edit mode. + $this->resetServiceMappings($bookingData); foreach ($formData->participants as $participant) { @@ -54,11 +157,10 @@ class BookingDataProcessor $this->removeUnusedServices($bookingData); $this->updateParticipantPersonalData($formData->participants, $bookingData); - $this->syncApplicantData($bookingData); $payload = $this->buildBasePayload($bookingData); $this->addBankAccountToPayload($payload, $bookingData); - $this->buildParticipantPayload($payload, $bookingData); + $this->buildParticipantPayload($payload, $bookingData, $formData->participants); $this->buildServicesPayload($payload, $bookingData); $this->buildPickupPayload($payload, $bookingData); @@ -71,9 +173,9 @@ class BookingDataProcessor * This ensures that service assignments are rebuilt from scratch based on current form selections. * Includes resetting room mappings to allow room reassignments during edit. * - * @param object $bookingData The booking data object containing services to reset + * @param Booking $bookingData The booking data object containing services to reset */ - private function resetServiceMappings(object $bookingData): void + private function resetServiceMappings(Booking $bookingData): void { $servicesToReset = [ ...$bookingData->additionalServices, @@ -81,6 +183,7 @@ class BookingDataProcessor ...$bookingData->pickupsOutbound, ...$bookingData->pickupsInbound, ...$bookingData->rooms, + ...$bookingData->insurances, ]; foreach ($servicesToReset as $service) { @@ -94,11 +197,11 @@ class BookingDataProcessor * This orchestrator method handles the complete service assignment workflow for one participant, * including additional services, transportation services, pickup locations, and room assignments. * - * @param object $participant The participant data from the form - * @param object $bookingData The booking data object to update - * @param object $travelData The travel data containing available services + * @param ParticipantDto $participant The participant data from the form + * @param Booking $bookingData The booking data object to update + * @param Travel $travelData The travel data containing available services */ - private function processParticipantServices(object $participant, object $bookingData, object $travelData): void + private function processParticipantServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void { if (true === $participant->isCanceled()) { return; @@ -108,21 +211,22 @@ class BookingDataProcessor $this->processTransportationServices($participant, $bookingData, $travelData); $this->processPickupLocations($participant, $bookingData); $this->processRoomAssignment($participant, $bookingData); + $this->processInsurance($participant, $bookingData, $travelData); } /** - * Processes additional services for a participant. + * Collects additional services from a participant for mapping. * - * Maps additional services (courses, ski passes, board options, rentals) to the participant. - * Adds new services to the booking if they don't already exist and sets individual pricing. + * Extracts all additional services (courses, board, rentals, ski pass, rental insurance, + * additional services) from a participant into a flat array of Service objects. * - * @param object $participant The participant data from the form - * @param object $bookingData The booking data object to update - * @param object $travelData The travel data containing available services + * @param ParticipantDto $participant The participant data + * + * @return array Array of services selected by this participant */ - private function processAdditionalServices(object $participant, object $bookingData, object $travelData): void + private function collectParticipantAdditionalServices(ParticipantDto $participant): array { - $servicesToMap = [ + $services = [ ...$participant->courses, ...$participant->additionalServices, ...$participant->board, @@ -131,9 +235,31 @@ class BookingDataProcessor // Add ski pass if selected (single service, not an array) if (null !== $participant->skiPass) { - $servicesToMap[] = $participant->skiPass; + $services[] = $participant->skiPass; } + // Add rental insurance if selected (single service, not an array) + if (null !== $participant->rentalInsurance) { + $services[] = $participant->rentalInsurance; + } + + return $services; + } + + /** + * Processes additional services for a participant. + * + * Maps additional services (courses, ski passes, board options, rentals) to the participant. + * Adds new services to the booking if they don't already exist and sets individual pricing. + * + * @param ParticipantDto $participant The participant data from the form + * @param Booking $bookingData The booking data object to update + * @param Travel $travelData The travel data containing available services + */ + private function processAdditionalServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void + { + $servicesToMap = $this->collectParticipantAdditionalServices($participant); + foreach ($servicesToMap as $service) { if (false === isset($bookingData->additionalServices[$service->id])) { $serviceToAdd = $travelData->additionalServices[$service->id] ?? null; @@ -152,11 +278,11 @@ class BookingDataProcessor * Maps transportation services (both directions: to and from destination) to the participant. * Adds new transportation services to the booking if they don't already exist. * - * @param object $participant The participant data from the form - * @param object $bookingData The booking data object to update - * @param object $travelData The travel data containing available services + * @param ParticipantDto $participant The participant data from the form + * @param Booking $bookingData The booking data object to update + * @param Travel $travelData The travel data containing available services */ - private function processTransportationServices(object $participant, object $bookingData, object $travelData): void + private function processTransportationServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void { foreach ([$participant->transportationOutbound, $participant->transportationInbound] as $service) { if (false === isset($bookingData->transportationServices[$service->id])) { @@ -176,10 +302,10 @@ class BookingDataProcessor * Only processes pickup locations for bus transportation services and maps the participant * to their selected pickup location. * - * @param object $participant The participant data from the form - * @param object $bookingData The booking data object to update + * @param ParticipantDto $participant The participant data from the form + * @param Booking $bookingData The booking data object to update */ - private function processPickupLocations(object $participant, object $bookingData): void + private function processPickupLocations(ParticipantDto $participant, Booking $bookingData): void { // Check if either transportation direction is BUS and pickup is selected $hasOutboundBus = null !== $participant->transportationOutbound && 'BUS' === $participant->transportationOutbound->subType; @@ -200,10 +326,10 @@ class BookingDataProcessor * booking edits while maintaining the constraint that participants can only be * assigned to room types that have already been booked. * - * @param object $participant The participant data from the form - * @param object $bookingData The booking data object to update + * @param ParticipantDto $participant The participant data from the form + * @param Booking $bookingData The booking data object to update */ - private function processRoomAssignment(object $participant, object $bookingData): void + private function processRoomAssignment(ParticipantDto $participant, Booking $bookingData): void { if (null === $participant->assignedRoomId) { return; @@ -218,15 +344,48 @@ class BookingDataProcessor } } + /** + * Processes travel insurance for a participant. + * + * Maps insurance to the participant. Adds new insurances to the booking if they don't exist. + * Insurance can be either individual or package-based, with automatic price-tier adjustment. + * + * @param ParticipantDto $participant The participant data from the form + * @param Booking $bookingData The booking data object to update + * @param Travel $travelData The travel data containing available insurances + */ + private function processInsurance(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void + { + if (null === $participant->insurance) { + return; + } + + $insurance = $participant->insurance; + + if (false === isset($bookingData->insurances[$insurance->id])) { + $insuranceToAdd = $travelData->insurances[$insurance->id] ?? null; + if (null !== $insuranceToAdd) { + $bookingData->insurances[$insurance->id] = $insuranceToAdd; + $bookingData->insurances[$insurance->id]->individualPrice[$participant->index] = $insuranceToAdd->price; + } + } + + // Only add mapping if insurance exists in booking data + // This can legitimately be false if the insurance is no longer available in current travel data + if (isset($bookingData->insurances[$insurance->id])) { + $bookingData->insurances[$insurance->id]->mapping[] = $participant->index; + } + } + /** * Removes services and pickups with no participant mappings. * * Cleans up unused services to prevent empty services from being sent to the API. - * This includes additional services, transportation services, and pickup locations. + * This includes additional services, transportation services, pickup locations, and insurances. * - * @param object $bookingData The booking data object to clean up + * @param Booking $bookingData The booking data object to clean up */ - private function removeUnusedServices(object $bookingData): void + private function removeUnusedServices(Booking $bookingData): void { foreach ($bookingData->additionalServices as $service) { if (0 === count($service->mapping)) { @@ -251,6 +410,12 @@ class BookingDataProcessor unset($bookingData->pickupsInbound[$pickup->id]); } } + + foreach ($bookingData->insurances as $insurance) { + if (0 === count($insurance->mapping)) { + unset($bookingData->insurances[$insurance->id]); + } + } } /** @@ -259,10 +424,13 @@ class BookingDataProcessor * Only processes participants with status 'F' (active/confirmed participants). * Updates all personal data fields and communication information. * - * @param array $participants The participants array from the form - * @param object $bookingData The booking data object to update + * IMPORTANT: The applicant's address must never be modified. This method updates + * participant addresses independently to ensure applicant data remains intact. + * + * @param array $participants The participants array from the form + * @param Booking $bookingData The booking data object to update */ - private function updateParticipantPersonalData(array $participants, object $bookingData): void + private function updateParticipantPersonalData(array $participants, Booking $bookingData): void { foreach ($participants as $participant) { if ('F' !== $participant->status) { @@ -278,6 +446,19 @@ class BookingDataProcessor $bookingData->participants[$participant->index]->weight = $participant->weight; $bookingData->participants[$participant->index]->shoeSize = $participant->shoeSize; + // Update address if provided + // Create new Address instance to avoid modifying any shared object references + if (null !== $participant->address) { + $newAddress = new Address(); + $newAddress->street = $participant->address->street; + $newAddress->postCode = $participant->address->postCode; + $newAddress->city = $participant->address->city; + $newAddress->district = $participant->address->district; + $newAddress->country = $participant->address->country; + + $bookingData->participants[$participant->index]->address = $newAddress; + } + if ($participant->email || $participant->mobile) { if (null === $bookingData->participants[$participant->index]->communication) { $bookingData->participants[$participant->index]->communication = new Communication(); @@ -288,32 +469,16 @@ class BookingDataProcessor } } - /** - * Synchronizes applicant data with first participant's physical characteristics. - * - * The applicant (booking holder) inherits physical data from the first participant. - * - * @param object $bookingData The booking data object to update - */ - private function syncApplicantData(object $bookingData): void - { - if (false !== $firstParticipant = reset($bookingData->participants)) { - $bookingData->applicant->height = $firstParticipant->height; - $bookingData->applicant->weight = $firstParticipant->weight; - $bookingData->applicant->shoeSize = $firstParticipant->shoeSize; - } - } - /** * Builds the base API payload structure with booking information. * * Creates the main structure that will be populated with detailed data sections. * - * @param object $bookingData The booking data object + * @param Booking $bookingData The booking data object * * @return array The base payload structure */ - private function buildBasePayload(object $bookingData): array + private function buildBasePayload(Booking $bookingData): array { return [ 'idbuchung' => $bookingData->id, @@ -347,10 +512,10 @@ class BookingDataProcessor * * Bank account information is required for direct debit payments. * - * @param array $payload The payload array to modify - * @param object $bookingData The booking data object + * @param array $payload The payload array to modify + * @param Booking $bookingData The booking data object */ - private function addBankAccountToPayload(array &$payload, object $bookingData): void + private function addBankAccountToPayload(array &$payload, Booking $bookingData): void { if (null !== $bookingData->bankAccount) { $payload['zahlung']['bankverbindung'] = [ @@ -365,19 +530,42 @@ class BookingDataProcessor /** * Builds the participant list section of the payload. * - * Includes status and personal data for each participant. + * Includes status, personal data, and wishes (room remarks, license plate) for each participant. * - * @param array $payload The payload array to modify - * @param object $bookingData The booking data object + * @param array $payload The payload array to modify + * @param Booking $bookingData The booking data object + * @param array $participantDtos The participant DTOs from the form (for wishes data) */ - private function buildParticipantPayload(array &$payload, object $bookingData): void + private function buildParticipantPayload(array &$payload, Booking $bookingData, array $participantDtos): void { foreach ($bookingData->participants as $index => $participant) { - $payload['teilnehmerliste']['teilnehmer'][] = [ + $participantPayload = [ '@id' => $index + 1, 'status' => $bookingData->participantsStatus[$index], ...$participant->toPayload(), ]; + + // Add wishes (room remarks, license plate) from form DTO + if (isset($participantDtos[$index])) { + $dto = $participantDtos[$index]; + if (null !== $dto->remarksRoom || null !== $dto->licensePlate) { + $wishes = []; + + if (null !== $dto->remarksRoom && '' !== trim($dto->remarksRoom)) { + $wishes['unterbringungswunsch'] = $dto->remarksRoom; + } + + if (null !== $dto->licensePlate && '' !== trim($dto->licensePlate)) { + $wishes['beförderungswunsch'] = $dto->licensePlate; + } + + if (false === empty($wishes)) { + $participantPayload['wünsche'] = $wishes; + } + } + } + + $payload['teilnehmerliste']['teilnehmer'][] = $participantPayload; } } @@ -387,10 +575,10 @@ class BookingDataProcessor * Includes additional services, transportation services, and accommodation details * with participant mappings and quantities. * - * @param array $payload The payload array to modify - * @param object $bookingData The booking data object + * @param array $payload The payload array to modify + * @param Booking $bookingData The booking data object */ - private function buildServicesPayload(array &$payload, object $bookingData): void + private function buildServicesPayload(array &$payload, Booking $bookingData): void { foreach ($bookingData->additionalServices as $service) { $payload['zusatzleistungen']['zusatzleistung'][] = [ @@ -419,6 +607,20 @@ class BookingDataProcessor '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $room->mapping)), ]; } + + // Add insurances with participant mappings + if (false === empty($bookingData->insurances)) { + $payload['versicherungen']['versicherung'] = []; + foreach ($bookingData->insurances as $insurance) { + if (count($insurance->mapping) > 0) { + $payload['versicherungen']['versicherung'][] = [ + '@idversicherung' => $insurance->id, + '@anzahl' => count($insurance->mapping), + '@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $insurance->mapping)), + ]; + } + } + } } /** @@ -426,10 +628,10 @@ class BookingDataProcessor * * Only included in payload if there are actual pickup assignments for bus transportation. * - * @param array $payload The payload array to modify - * @param object $bookingData The booking data object + * @param array $payload The payload array to modify + * @param Booking $bookingData The booking data object */ - private function buildPickupPayload(array &$payload, object $bookingData): void + private function buildPickupPayload(array &$payload, Booking $bookingData): void { if (0 < count($bookingData->pickupsOutbound)) { $payload['zustiege']['zustieg'] = []; @@ -451,13 +653,16 @@ class BookingDataProcessor * payment information. The booking type determines whether this is an inquiry validation * ('Anfrage') or a final booking commit ('Buchung'). * - * @param BookingCreateDto $bookingDto The booking creation form data + * @param BookingDto $bookingDto The booking creation form data * @param string $bookingType Either 'Anfrage' (inquiry) or 'Buchung' (final booking) * * @return array The structured payload array for BusProNet API submission */ - public function createBookingRequestPayload(BookingCreateDto $bookingDto, string $bookingType): array + public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array { + // Apply bulk insurance if enabled (modifies DTO in place) + $this->applyBulkInsuranceIfActive($bookingDto); + $firstParticipant = $bookingDto->participants[0]; $payload = [ @@ -802,4 +1007,59 @@ class BookingDataProcessor return $insuranceMap; } + + /** + * Applies bulk insurance assignment if the applicant has enabled it. + * + * When bulk insurance is active (applicant's bulkInsuranceBooking = true), this method + * assigns the applicant's insurance TYPE to all dependent participants with automatic + * price tier adjustment based on each participant's total cost. + * + * IMPORTANT: In edit mode, bulk insurance only applies to participants who: + * 1. Currently have NO insurance assigned (insurance === null) + * 2. OR whose insurance was already assigned via previous bulk operation + * + * This prevents overriding individually selected insurances that are locked. + * + * @param BookingDto $bookingDto The booking DTO (create or edit flow) + */ + private function applyBulkInsuranceIfActive(BookingDto $bookingDto): void + { + $applicant = $bookingDto->getParticipant(0); + + // Check if bulk insurance is enabled and applicant has selected an insurance + if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) { + return; + } + + // Get all available insurances from travel data + $availableInsurances = array_values($bookingDto->travel->insurances); + + // Use InsuranceMatchingService for proper type-based assignment with price tier matching + $assignments = $this->insuranceMatchingService->batchAssignInsuranceToParticipants( + $availableInsurances, + $applicant->insurance, + $bookingDto + ); + + // Apply assignments to dependent participants (skip applicant at index 0) + foreach ($assignments as $index => $insurance) { + if (0 === $index) { + continue; // Skip applicant + } + + $participant = $bookingDto->getParticipant($index); + if (null === $participant) { + continue; + } + + // In edit mode: only apply bulk insurance if participant has no insurance + // This respects the rule that once assigned, insurance cannot be changed + if ($bookingDto instanceof BookingEditDto && null !== $participant->insurance) { + continue; // Skip participants with existing insurance assignment + } + + $participant->insurance = $insurance; + } + } } diff --git a/src/BusProNet/Model/Booking.php b/src/BusProNet/Model/Booking.php index 3cf25cc..3f908e0 100644 --- a/src/BusProNet/Model/Booking.php +++ b/src/BusProNet/Model/Booking.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\BusProNet\Model; +use App\BusProNet\Constants; + /** * Represents a booking with participants, services, and pricing information. * @@ -192,6 +194,22 @@ class Booking return null; } + /** + * Retrieves rental insurance for a specific participant. + */ + public function getRentalInsuranceForParticipant(int $participantIndex): ?Service + { + $rentalInsurances = $this->getAdditionalServicesByGroup(Constants::TOKEN_RENTAL_INSURANCE); + + foreach ($rentalInsurances as $service) { + if (in_array($participantIndex, $service->mapping)) { + return $service; + } + } + + return null; + } + /** * Retrieves room assignment for a specific participant. * diff --git a/src/BusProNet/Model/PersonalData.php b/src/BusProNet/Model/PersonalData.php index 4e0e740..ab067c7 100644 --- a/src/BusProNet/Model/PersonalData.php +++ b/src/BusProNet/Model/PersonalData.php @@ -39,6 +39,10 @@ class PersonalData public ?\DateTimeImmutable $dateOfBirth = null; public ?string $remarks = null; + // Wishes (unterbringungswunsch, beförderungswunsch) + public ?string $remarksRoom = null; + public ?string $licensePlate = null; + #[Assert\Valid(groups: ['personal_data'])] public Address $address; diff --git a/src/BusProNet/XmlParser/BookingParser.php b/src/BusProNet/XmlParser/BookingParser.php index f2f38be..c595000 100644 --- a/src/BusProNet/XmlParser/BookingParser.php +++ b/src/BusProNet/XmlParser/BookingParser.php @@ -174,6 +174,13 @@ class BookingParser extends AbstractParser $personalData->communication->email = 'teilnehmer@ep-reisen.de'; } + // Parse wishes (room remarks and license plate) + $wishesNode = $node->filterXPath('//wünsche'); + if (0 < $wishesNode->count()) { + $personalData->remarksRoom = $this->getStringOrNullValue($wishesNode->filterXPath('//unterbringungswunsch')); + $personalData->licensePlate = $this->getStringOrNullValue($wishesNode->filterXPath('//beförderungswunsch')); + } + return $personalData; } } diff --git a/src/BusProNet/XmlParser/PersonalDataParser.php b/src/BusProNet/XmlParser/PersonalDataParser.php index 2ed887d..aa3f002 100644 --- a/src/BusProNet/XmlParser/PersonalDataParser.php +++ b/src/BusProNet/XmlParser/PersonalDataParser.php @@ -6,7 +6,6 @@ use App\BusProNet\Model\Address; use App\BusProNet\Model\Communication; use App\BusProNet\Model\PersonalData; use Symfony\Component\DomCrawler\Crawler; -use voku\helper\AntiXSS; class PersonalDataParser extends AbstractParser { @@ -58,8 +57,7 @@ class PersonalDataParser extends AbstractParser $remarksNode = $addressDataNode->filterXPath('//bemerkung'); if (0 < $remarksNode->count()) { - $antiXss = new AntiXSS(); - $personalData->remarks = $antiXss->xss_clean($remarksNode->text()); + $personalData->remarks = $remarksNode->text(); } return $personalData; diff --git a/src/Controller/Booking/EditController.php b/src/Controller/Booking/EditController.php index 733db72..9463a93 100644 --- a/src/Controller/Booking/EditController.php +++ b/src/Controller/Booking/EditController.php @@ -3,6 +3,7 @@ namespace App\Controller\Booking; use App\BusProNet\ApiClient; +use App\BusProNet\DataProcessor\BookingDataProcessor; use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Model\Notification; use App\BusProNet\XmlLoader\PickupLoader; @@ -10,7 +11,7 @@ use App\Controller\Traits\BookingDataTrait; use App\Controller\Traits\HtmxControllerTrait; use App\Entity\User; use App\Form\BookingEditType; -use App\Form\Model\BookingEditDto; +use App\Form\Model\BookingDto; use App\Security\Crypt; use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; @@ -32,6 +33,7 @@ class EditController extends AbstractController public function __construct( private readonly ApiClient $apiClient, + private readonly BookingDataProcessor $bookingDataProcessor, private readonly TravelDataService $travelDataService, private readonly BookingService $bookingService, private readonly BookingPriceCalculatorService $priceCalculator, @@ -87,7 +89,7 @@ class EditController extends AbstractController $this->travelDataService->patchMutability($travelData, $mutableData); // Create DTO for form - $formData = BookingEditDto::fromBooking($bookingData, $travelData); + $formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData); // Calculate pricing data for template $summary = $this->bookingService->getRoomSummaryAndParticipantCount($formData); @@ -151,9 +153,7 @@ class EditController extends AbstractController return $this->render('booking/edit.html.twig', [ 'bookingData' => $bookingData, 'bookingEditDto' => $formData, - 'travelData' => $travelData, 'mutableData' => $mutableData, - 'availabilities' => $availabilities, 'form' => $form->createView(), 'pricingData' => $summary['pricing'], 'participantCount' => $summary['participantCount'], @@ -203,8 +203,8 @@ class EditController extends AbstractController $this->travelDataService->patchAvailabilities($travelData, $availabilities); $this->travelDataService->patchMutability($travelData, $mutableData); - // Create DTO for form - $formData = BookingEditDto::fromBooking($bookingData, $travelData); + // Create fresh DTO from booking data + $formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData); // Process form data without validation to capture current state $form = $this->createForm(BookingEditType::class, $formData, [ @@ -242,6 +242,7 @@ class EditController extends AbstractController 'assignmentCounts' => $roomAssignmentCounts, 'participantPrices' => $participantPrices, 'groupedSelectedRooms' => $groupedSelectedRooms, + 'mutableData' => $mutableData, ] ); @@ -260,11 +261,11 @@ class EditController extends AbstractController * * @return array Array of notification messages */ - private function collectParticipantNotifications(BookingEditDto $bookingEditDto): array + private function collectParticipantNotifications(BookingDto $bookingDto): array { $notifications = []; - foreach ($bookingEditDto->participants as $participant) { + foreach ($bookingDto->participants as $participant) { if ([] !== $participant->notifications) { foreach ($participant->notifications as $notification) { $notifications[] = $notification; diff --git a/src/Controller/Traits/BookingDataTrait.php b/src/Controller/Traits/BookingDataTrait.php index 2476e51..7ea8614 100644 --- a/src/Controller/Traits/BookingDataTrait.php +++ b/src/Controller/Traits/BookingDataTrait.php @@ -11,7 +11,6 @@ trait BookingDataTrait { public function fetchBookingData(string $email, string $password, int $id): Booking|Notification|null { - // Fetch booking data via API and cache result for a short ttl to check permissions $cacheKey = sprintf('bpn_booking_%d', $id); try { $bookingData = $this->cache->get($cacheKey, function (ItemInterface $item) use ($email, $password, $id) { diff --git a/src/Form/BookingEditType.php b/src/Form/BookingEditType.php index a1d5a0c..0afdc86 100644 --- a/src/Form/BookingEditType.php +++ b/src/Form/BookingEditType.php @@ -2,7 +2,7 @@ namespace App\Form; -use App\Form\Model\BookingEditDto; +use App\Form\Model\BookingDto; use App\Form\Service\ParticipantFieldHandlerRegistry; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; @@ -27,7 +27,7 @@ class BookingEditType extends AbstractType public function onPreSetData(FormEvent $event): void { - /** @var BookingEditDto $data */ + /** @var BookingDto $data */ $data = $event->getData(); $form = $event->getForm(); @@ -46,7 +46,7 @@ class BookingEditType extends AbstractType $form = $event->getForm(); $submittedData = $event->getData(); - /** @var BookingEditDto $bookingDto */ + /** @var BookingDto $bookingDto */ $bookingDto = $form->getData(); // Process field handlers and synchronize submitted data with cleaned DTO state @@ -71,7 +71,7 @@ class BookingEditType extends AbstractType public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ - 'data_class' => BookingEditDto::class, + 'data_class' => BookingDto::class, ]); } } diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index f0b191a..94bdb51 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -3,7 +3,7 @@ namespace App\Form; use App\BusProNet\Form\CountryType; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Service\Contract\FieldOptionsProviderInterface; use App\Form\Service\Contract\FieldStateProviderInterface; @@ -14,6 +14,7 @@ use Symfony\Component\Form\Extension\Core\Type\BirthdayType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; @@ -108,7 +109,7 @@ class BookingParticipantType extends AbstractType /** * Adds base fields to the form with field states applied. */ - private function addBaseFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex): void + private function addBaseFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex): void { // Get field states for base fields $allFieldStates = $this->fieldStateProvider->getAllFieldStates($bookingDto, $participantIndex); @@ -151,12 +152,12 @@ class BookingParticipantType extends AbstractType ], $getFieldState('email'))) ->add('mobile', TextType::class, $this->mergeFieldState([ 'label' => 'Telefon (mobil)', - 'required' => 0 === $participantIndex, + 'required' => false, 'sanitize_html' => true, ], $getFieldState('mobile'))) ->add('address', AddressType::class, $this->mergeFieldState([ 'label' => 'Adresse', - 'required' => 0 === $participantIndex, + 'required' => false, ], $getFieldState('address'))); // Add body dimensions with state handling - use shouldIncludeField method @@ -172,11 +173,11 @@ class BookingParticipantType extends AbstractType * field states change based on submitted data. * * @param FormInterface $form The form to modify - * @param BookingDtoInterface $bookingDto The booking data for context + * @param BookingDto $bookingDto The booking data for context * @param int $participantIndex The participant index * @param array $formData Submitted form data for state calculation */ - private function removeExcludedFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): void + private function removeExcludedFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array $formData = []): void { foreach (ParticipantDto::DYNAMIC_FIELDS as $fieldName) { if ($form->has($fieldName) && !$this->fieldStateProvider->shouldIncludeField($fieldName, $bookingDto, $participantIndex, $formData)) { @@ -188,7 +189,7 @@ class BookingParticipantType extends AbstractType /** * Rebuilds all fields with updated states based on submitted data. */ - private function rebuildFieldsWithStates(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $submittedData): void + private function rebuildFieldsWithStates(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array $submittedData): void { // First, remove fields that should be excluded entirely $this->removeExcludedFields($form, $bookingDto, $participantIndex, $submittedData); @@ -220,7 +221,7 @@ class BookingParticipantType extends AbstractType /** * Adds all configured dynamic fields to the form with state conditions applied. */ - private function addDynamicFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex): void + private function addDynamicFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex): void { $dynamicFields = [ 'assignedRoomId' => ChoiceType::class, @@ -236,10 +237,14 @@ class BookingParticipantType extends AbstractType 'pickup' => ChoiceType::class, 'parking' => CheckboxType::class, 'licensePlate' => TextType::class, - 'bulkInsuranceBooking' => CheckboxType::class, - 'insurance' => InsuranceChoiceType::class, ]; + // Insurance fields only available in create mode (API limitation) + if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { + $dynamicFields['bulkInsuranceBooking'] = CheckboxType::class; + $dynamicFields['insurance'] = InsuranceChoiceType::class; + } + foreach ($dynamicFields as $fieldName => $fieldType) { if ($this->fieldOptionsProvider->hasFieldOptions($fieldName)) { // Check if field should be included in the form at all diff --git a/src/Form/Model/BankAccountDto.php b/src/Form/Model/BankAccountDto.php index fa46110..3437cbe 100644 --- a/src/Form/Model/BankAccountDto.php +++ b/src/Form/Model/BankAccountDto.php @@ -33,6 +33,17 @@ class BankAccountDto #[Assert\IsTrue(message: 'Bitte akzeptieren Sie das SEPA-Mandat.')] public bool $sepaMandateAccepted = false; + public static function fromBankAccount(\App\BusProNet\Model\BankAccount $bankAccount): static + { + $instance = new static(); + $instance->iban = $bankAccount->iban; + $instance->accountHolder = $bankAccount->holder; + $instance->bankName = $bankAccount->bankName; + $instance->sepaMandateAccepted = true; + + return $instance; + } + /** * Returns IBAN formatted with spaces for display (e.g., DE12 3456 7890 1234 5678 90). */ diff --git a/src/Form/Model/BookingCreateDto.php b/src/Form/Model/BookingCreateDto.php index 789276f..870ac84 100644 --- a/src/Form/Model/BookingCreateDto.php +++ b/src/Form/Model/BookingCreateDto.php @@ -37,6 +37,11 @@ class BookingCreateDto implements BookingDtoInterface { } + public function getMode(): string + { + return BookingDtoInterface::MODE_CREATE; + } + /** * @return array */ diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php new file mode 100644 index 0000000..be1c58c --- /dev/null +++ b/src/Form/Model/BookingDto.php @@ -0,0 +1,179 @@ + + */ + #[Assert\Valid] + public array $roomSelections = []; + + /** + * @var array + */ + #[Assert\Valid] + public array $participants = []; + + #[Assert\Choice( + choices: [Constants::PAYMENT_METHOD_TRANSFER, Constants::PAYMENT_METHOD_DEBIT], + message: 'Bitte wählen Sie eine gültige Zahlungsart.' + )] + public ?string $paymentMethod = Constants::PAYMENT_METHOD_TRANSFER; + + public ?BankAccountDto $bankAccount = null; + + public ?int $agencyId = null; + + /** + * Reference to booking entity (only populated in edit mode). + */ + public ?Booking $booking = null; + + public function __construct(public Travel $travel, public int $hotelId) + { + } + + public function getMode(): string + { + return null !== $this->booking ? self::MODE_EDIT : self::MODE_CREATE; + } + + /** + * @return array + */ + public function getSelectedRooms(): array + { + // In edit mode, rooms are fixed - return empty array + if (self::MODE_EDIT === $this->getMode()) { + return []; + } + + return array_filter($this->roomSelections, function (RoomSelectionDto $roomSelection) { + return 0 < $roomSelection->quantity; + }); + } + + public function getParticipants(): array + { + return $this->participants; + } + + public function hasParticipant(int $index): bool + { + return isset($this->participants[$index]); + } + + public function getParticipant(int $index): ?ParticipantDto + { + return $this->participants[$index] ?? null; + } + + /** + * Determines if this is a family booking based on participant age distribution. + * + * A family booking is defined as: + * - 1 or 2 participants aged 18 or older (adults) + * - At least 1 participant younger than 18 (children) + */ + public function isFamilyBooking(): bool + { + $adults = 0; + $children = 0; + + $travelStartDate = $this->travel->dateFrom; + + foreach ($this->participants as $participant) { + $age = $participant->getAge($travelStartDate); + + if (null === $age) { + continue; + } + + if ($age >= 18) { + ++$adults; + } else { + ++$children; + } + } + + return ($adults >= 1 && $adults <= 2) && ($children >= 1); + } + + public function isCanceled(): bool + { + return null !== $this->booking && 'S' === $this->booking->status; + } + + public function isOption(): bool + { + return null !== $this->booking && 'O' === $this->booking->status; + } + + #[Assert\Callback(callback: 'validateRoomSelection', groups: ['booking_create_step_1'])] + public function validateRoomSelection(ExecutionContextInterface $context): void + { + $selectedRooms = $this->getSelectedRooms(); + + if (0 === count($selectedRooms)) { + $context->buildViolation('Bitte mindestens ein Zimmer/Bett auswählen') + ->addViolation(); + } + } + + #[Assert\Callback] + public function validateBankAccount(ExecutionContextInterface $context): void + { + if (Constants::PAYMENT_METHOD_DEBIT !== $this->paymentMethod) { + return; + } + + if (null === $this->bankAccount) { + $context->buildViolation('Bitte geben Sie Ihre Bankverbindung an.') + ->atPath('bankAccount') + ->addViolation(); + + return; + } + + if (null === $this->bankAccount->iban || '' === trim($this->bankAccount->iban)) { + $context->buildViolation('Bitte geben Sie Ihre IBAN ein.') + ->atPath('bankAccount.iban') + ->addViolation(); + } + + if (null === $this->bankAccount->accountHolder || '' === trim($this->bankAccount->accountHolder)) { + $context->buildViolation('Bitte geben Sie den Kontoinhaber ein.') + ->atPath('bankAccount.accountHolder') + ->addViolation(); + } + + if (false === $this->bankAccount->sepaMandateAccepted) { + $context->buildViolation('Bitte akzeptieren Sie das SEPA-Mandat.') + ->atPath('bankAccount.sepaMandateAccepted') + ->addViolation(); + } + } +} diff --git a/src/Form/Model/BookingDtoInterface.php b/src/Form/Model/BookingDtoInterface.php index 4df70e2..27bd9a1 100644 --- a/src/Form/Model/BookingDtoInterface.php +++ b/src/Form/Model/BookingDtoInterface.php @@ -14,6 +14,16 @@ namespace App\Form\Model; */ interface BookingDtoInterface { + public const MODE_CREATE = 'create'; + public const MODE_EDIT = 'edit'; + + /** + * Gets the booking mode (create or edit). + * + * @return string One of MODE_CREATE or MODE_EDIT constants + */ + public function getMode(): string; + /** * Gets all participants in the booking. * diff --git a/src/Form/Model/BookingEditDto.php b/src/Form/Model/BookingEditDto.php index 88b658c..4d18554 100644 --- a/src/Form/Model/BookingEditDto.php +++ b/src/Form/Model/BookingEditDto.php @@ -21,6 +21,11 @@ class BookingEditDto implements BookingDtoInterface { } + public function getMode(): string + { + return BookingDtoInterface::MODE_EDIT; + } + public static function fromBooking(Booking $booking, Travel $travel): static { $instance = new static($booking, $travel); @@ -30,7 +35,9 @@ class BookingEditDto implements BookingDtoInterface foreach ($booking->participants as $index => $participant) { /** @var PersonalData $participant */ - $participantData = ParticipantDto::fromPersonalData($participant); + // For the first participant (applicant), use applicant data instead of participant data + $personalData = 0 === $index && null !== $booking->applicant ? $booking->applicant : $participant; + $participantData = ParticipantDto::fromPersonalData($personalData); $participantData->index = $index; $participantData->courses = $booking @@ -102,7 +109,10 @@ class BookingEditDto implements BookingDtoInterface /** * Gets all selected rooms for the booking (edit context). * - * @return array always returns an empty array for edit DTOs unless implemented + * In edit mode, rooms are fixed and not selectable - returns empty array. + * Participant count should be derived from actual participants, not room selections. + * + * @return array */ public function getSelectedRooms(): array { diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index c3a1175..eb42fc7 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -11,6 +11,7 @@ use App\Validator\Constraints as AppAssert; use Symfony\Component\Validator\Constraints as Assert; #[AppAssert\Participant(groups: ['booking_edit', 'booking_create_step_2'])] +#[AppAssert\ApplicantAddress(groups: ['booking_create_step_2'])] class ParticipantDto { /** @@ -132,6 +133,12 @@ class ParticipantDto $instance->weight = $personalData->weight; $instance->shoeSize = $personalData->shoeSize; + // Clone address to prevent shared object references that could cause mutations + $instance->address = null !== $personalData->address ? clone $personalData->address : null; + + $instance->remarksRoom = $personalData->remarksRoom; + $instance->licensePlate = $personalData->licensePlate; + return $instance; } diff --git a/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php b/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php index 606f2d5..718fe15 100644 --- a/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php +++ b/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Abstract; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldOptionsProviderInterface; /** @@ -37,13 +37,13 @@ abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInter * static options defined in the form type. * * @param string $fieldName The name of the field to configure - * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being configured * @param array $options Additional options to customize field behavior * * @return array Symfony form field options, or empty array if field not supported */ - public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $options = []): array + public function getFieldOptions(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $options = []): array { // Check if we have a provider for this field if (false === isset($this->fieldOptionProviders[$fieldName])) { diff --git a/src/Form/Service/Abstract/AbstractFieldStateProvider.php b/src/Form/Service/Abstract/AbstractFieldStateProvider.php index 2d69c1b..38b0549 100644 --- a/src/Form/Service/Abstract/AbstractFieldStateProvider.php +++ b/src/Form/Service/Abstract/AbstractFieldStateProvider.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Abstract; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; use App\Form\Service\Contract\FieldStateProviderInterface; use App\Form\Service\Trait\FormTraversalTrait; @@ -36,13 +36,13 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface * condition independently to allow early field exclusion. * * @param string $fieldName The name of the field to evaluate - * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * * @return bool True if the field should be included in the form, false if it should be excluded */ - public function shouldIncludeField(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): bool + public function shouldIncludeField(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $formData = []): bool { if (false === isset($this->fieldStateConditions[$fieldName]['hidden'])) { return true; // No hidden condition means field should be included @@ -64,13 +64,13 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface * excluded from the form entirely rather than hidden with CSS. * * @param string $fieldName The name of the field to evaluate - * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * * @return array Symfony form field options for state modifications */ - public function getFieldState(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array + public function getFieldState(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $formData = []): array { if (false === isset($this->fieldStateConditions[$fieldName])) { return []; @@ -139,13 +139,13 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface /** * Calculates field states for all configured fields at once. * - * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * * @return array> Field states indexed by field name */ - public function getAllFieldStates(BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array + public function getAllFieldStates(BookingDto $bookingDto, int $participantIndex, array $formData = []): array { $allStates = []; diff --git a/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php b/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php index 312b098..3318251 100644 --- a/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php +++ b/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Abstract; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\ParticipantFieldHandlerInterface; /** @@ -55,12 +55,12 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle * if the participant exists at the given index. Returns null if the * participant doesn't exist, preventing array access errors. * - * @param BookingDtoInterface $bookingDto The booking DTO containing participants (create or edit) + * @param BookingDto $bookingDto The booking DTO containing participants (create or edit) * @param int $participantIndex The index of the participant to retrieve * * @return object|null The participant object, or null if not found */ - protected function getParticipant(BookingDtoInterface $bookingDto, int $participantIndex): ?object + protected function getParticipant(BookingDto $bookingDto, int $participantIndex): ?object { $participants = $bookingDto->getParticipants(); @@ -129,12 +129,12 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle * processing results. * * @param array $submittedData The submitted participant form data - * @param BookingDtoInterface $bookingDto The booking DTO (potentially modified by processing) + * @param BookingDto $bookingDto The booking DTO (potentially modified by processing) * @param int $participantIndex The participant index being processed * * @return array> Empty array (no state modifications by default) */ - public function getFieldStateModifications(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): array + public function getFieldStateModifications(array $submittedData, BookingDto $bookingDto, int $participantIndex): array { return []; } diff --git a/src/Form/Service/Condition/AdditionalServicesMutabilityCondition.php b/src/Form/Service/Condition/AdditionalServicesMutabilityCondition.php index e486532..52645f9 100644 --- a/src/Form/Service/Condition/AdditionalServicesMutabilityCondition.php +++ b/src/Form/Service/Condition/AdditionalServicesMutabilityCondition.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -12,7 +12,7 @@ use App\Form\Service\Contract\FieldConditionInterface; */ class AdditionalServicesMutabilityCondition implements FieldConditionInterface { - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { return false === $bookingDto->travel->additionalServicesMutable; } diff --git a/src/Form/Service/Condition/AgeRangeCondition.php b/src/Form/Service/Condition/AgeRangeCondition.php index 7ba8a75..669b902 100644 --- a/src/Form/Service/Condition/AgeRangeCondition.php +++ b/src/Form/Service/Condition/AgeRangeCondition.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -54,13 +54,13 @@ class AgeRangeCondition implements FieldConditionInterface * checks if it falls within the configured age range. Returns false if * the participant has no date of birth set. * - * @param BookingDtoInterface $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data (unused for age conditions) * * @return bool True if the participant's age meets the criteria, false otherwise */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { $participant = $bookingDto->getParticipant($participantIndex); diff --git a/src/Form/Service/Condition/ApplicantCondition.php b/src/Form/Service/Condition/ApplicantCondition.php index 9656a0a..3a9a002 100644 --- a/src/Form/Service/Condition/ApplicantCondition.php +++ b/src/Form/Service/Condition/ApplicantCondition.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -21,13 +21,13 @@ class ApplicantCondition implements FieldConditionInterface /** * Evaluates if the participant is the applicant. * - * @param BookingDtoInterface $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data (unused) * * @return bool True if the participant is the applicant, false otherwise */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { // Default: applicant is the first participant (index 0) return 0 === $participantIndex; diff --git a/src/Form/Service/Condition/BookingEligibilityCondition.php b/src/Form/Service/Condition/BookingEligibilityCondition.php index df2e22f..1295b88 100644 --- a/src/Form/Service/Condition/BookingEligibilityCondition.php +++ b/src/Form/Service/Condition/BookingEligibilityCondition.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; use App\Service\ParticipantEligibilityService; @@ -36,13 +36,13 @@ class BookingEligibilityCondition implements FieldConditionInterface * Returns true when the participant is INELIGIBLE (should hide service fields). * Returns false when the participant is eligible (show normal form fields). * - * @param BookingDtoInterface $bookingDto The current booking data + * @param BookingDto $bookingDto The current booking data * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * * @return bool True if participant is ineligible (no skipasses available for their age) */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { // Invert the eligibility check since conditions typically evaluate to TRUE for "hide" // isParticipantEligible() returns TRUE when eligible, we need TRUE when INELIGIBLE diff --git a/src/Form/Service/Condition/BulkInsuranceBookingCondition.php b/src/Form/Service/Condition/BulkInsuranceBookingCondition.php index 5affa22..dbcac06 100644 --- a/src/Form/Service/Condition/BulkInsuranceBookingCondition.php +++ b/src/Form/Service/Condition/BulkInsuranceBookingCondition.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -29,13 +29,13 @@ class BulkInsuranceBookingCondition implements FieldConditionInterface * For dependent participants, returns true if the applicant has bulk insurance booking enabled, * regardless of whether an insurance is selected (applies to "no insurance" as well). * - * @param BookingDtoInterface $bookingDto The current booking data + * @param BookingDto $bookingDto The current booking data * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * * @return bool True if bulk insurance booking is active for this dependent participant */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { // Applicant is never affected by bulk insurance booking (they control it) if (0 === $participantIndex) { diff --git a/src/Form/Service/Condition/CompositeCondition.php b/src/Form/Service/Condition/CompositeCondition.php index f667546..f5baa20 100644 --- a/src/Form/Service/Condition/CompositeCondition.php +++ b/src/Form/Service/Condition/CompositeCondition.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -64,13 +64,13 @@ class CompositeCondition implements FieldConditionInterface * evaluation for optimal performance. The evaluation stops as soon as the * final result can be determined. * - * @param BookingDtoInterface $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * * @return bool True if the composite condition is met, false otherwise */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { return match ($this->operator) { self::OPERATOR_AND => $this->evaluateAnd($bookingDto, $participantIndex, $formData), @@ -168,7 +168,7 @@ class CompositeCondition implements FieldConditionInterface * Returns false as soon as any condition evaluates to false, * avoiding unnecessary evaluation of remaining conditions. */ - private function evaluateAnd(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + private function evaluateAnd(BookingDto $bookingDto, int $participantIndex, array $formData): bool { foreach ($this->conditions as $condition) { if (!$condition->evaluate($bookingDto, $participantIndex, $formData)) { @@ -185,7 +185,7 @@ class CompositeCondition implements FieldConditionInterface * Returns true as soon as any condition evaluates to true, * avoiding unnecessary evaluation of remaining conditions. */ - private function evaluateOr(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + private function evaluateOr(BookingDto $bookingDto, int $participantIndex, array $formData): bool { foreach ($this->conditions as $condition) { if ($condition->evaluate($bookingDto, $participantIndex, $formData)) { @@ -199,7 +199,7 @@ class CompositeCondition implements FieldConditionInterface /** * Evaluates NOT logic by inverting the result of the single condition. */ - private function evaluateNot(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + private function evaluateNot(BookingDto $bookingDto, int $participantIndex, array $formData): bool { return !$this->conditions[0]->evaluate($bookingDto, $participantIndex, $formData); } diff --git a/src/Form/Service/Condition/DateOfBirthProvidedCondition.php b/src/Form/Service/Condition/DateOfBirthProvidedCondition.php index 0ba0271..ed48a39 100644 --- a/src/Form/Service/Condition/DateOfBirthProvidedCondition.php +++ b/src/Form/Service/Condition/DateOfBirthProvidedCondition.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -27,17 +27,24 @@ class DateOfBirthProvidedCondition implements FieldConditionInterface * Checks if the participant exists and has a non-null dateOfBirth property. * This is a prerequisite for showing age-dependent form fields and services. * - * @param BookingDtoInterface $bookingDto The current booking data + * @param BookingDto $bookingDto The current booking data * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data (unused for this condition) * * @return bool True if the participant has provided their date of birth, false otherwise */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { $participant = $bookingDto->getParticipant($participantIndex); + $result = null !== $participant && null !== $participant->dateOfBirth; - return null !== $participant && null !== $participant->dateOfBirth; + error_log(sprintf('[DOB Condition] Participant %d: dateOfBirth=%s, result=%s', + $participantIndex, + $participant?->dateOfBirth?->format('Y-m-d') ?? 'NULL', + $result ? 'TRUE' : 'FALSE' + )); + + return $result; } /** diff --git a/src/Form/Service/Condition/FieldValueCondition.php b/src/Form/Service/Condition/FieldValueCondition.php index 570c36e..e45648c 100644 --- a/src/Form/Service/Condition/FieldValueCondition.php +++ b/src/Form/Service/Condition/FieldValueCondition.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -60,13 +60,13 @@ class FieldValueCondition implements FieldConditionInterface * against the expected value using the configured operator. Supports * both participant-level fields and booking-level fields. * - * @param BookingDtoInterface $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * * @return bool True if the field value meets the condition criteria, false otherwise */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { $fieldValue = $this->getFieldValue($formData, $participantIndex, $bookingDto); @@ -175,7 +175,7 @@ class FieldValueCondition implements FieldConditionInterface /** * Retrieves field value from form data or participant data. */ - private function getFieldValue(array $formData, int $participantIndex, BookingDtoInterface $bookingDto): mixed + private function getFieldValue(array $formData, int $participantIndex, BookingDto $bookingDto): mixed { // First check participant-specific form data if (isset($formData['participants'][$participantIndex][$this->fieldName])) { diff --git a/src/Form/Service/Condition/InsuranceMutabilityCondition.php b/src/Form/Service/Condition/InsuranceMutabilityCondition.php index dc537d9..aafedcf 100644 --- a/src/Form/Service/Condition/InsuranceMutabilityCondition.php +++ b/src/Form/Service/Condition/InsuranceMutabilityCondition.php @@ -4,72 +4,36 @@ declare(strict_types=1); namespace App\Form\Service\Condition; -use App\Form\Model\BookingDtoInterface; -use App\Form\Model\BookingEditDto; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** - * Condition that determines if insurance fields are editable based on booking and travel dates. + * Condition that determines if insurance fields are editable. * - * Implements time-based mutability constraints for insurance booking in the edit flow: - * - Standard case: Insurance editable up to 30 days before travel date - * - Late booking case: If booking made < 30 days before travel, insurance editable up to 3 days after booking date + * Insurance cannot be modified via the BusProNet API after booking creation, + * so insurance fields are always readonly in edit mode and editable in create mode. */ class InsuranceMutabilityCondition implements FieldConditionInterface { - private const DAYS_BEFORE_TRAVEL_THRESHOLD = 30; - private const DAYS_AFTER_BOOKING_THRESHOLD = 3; - /** * Evaluates whether the insurance field should be readonly. * * Returns true if the field should be readonly (locked), false if editable. * * Logic: - * 1. Always editable in create flow - * 2. In edit flow, check standard case: editable if >= 30 days before travel - * 3. In edit flow, check late booking case: editable if within 3 days of booking date - * 4. Otherwise: readonly + * - Create mode: Always editable (return false) + * - Edit mode: Always readonly (return true) - API limitation * - * @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) + * @param BookingDto $bookingDto The current booking data + * @param int $participantIndex The participant index (unused) + * @param array $formData Current form data (unused) * * @return bool True if field should be readonly, false if editable */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $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; + // Insurance cannot be updated via API - always readonly in edit mode + return BookingDto::MODE_EDIT === $bookingDto->getMode(); } /** @@ -92,10 +56,6 @@ class InsuranceMutabilityCondition implements FieldConditionInterface */ 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 - ); + return 'Insurance is not editable in edit mode (API limitation)'; } } diff --git a/src/Form/Service/Condition/MutabilityCondition.php b/src/Form/Service/Condition/MutabilityCondition.php index 8bfb05e..1a65d52 100644 --- a/src/Form/Service/Condition/MutabilityCondition.php +++ b/src/Form/Service/Condition/MutabilityCondition.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -13,7 +13,7 @@ use App\Form\Service\Contract\FieldConditionInterface; * This is used to set fields as readonly or disabled if the booking or participant * is not allowed to be modified (e.g., after a certain workflow step or status). * - * The logic assumes the BookingDtoInterface or its participant DTOs expose a + * The logic assumes the BookingDto or its participant DTOs expose a * 'personalDataMutable' property or method. Adjust as needed for your domain. */ class MutabilityCondition implements FieldConditionInterface @@ -21,13 +21,13 @@ class MutabilityCondition implements FieldConditionInterface /** * Evaluates if the participant's personal data is mutable. * - * @param BookingDtoInterface $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data (unused) * * @return bool True if the participant's data is mutable, false otherwise */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { $participant = $bookingDto->getParticipant($participantIndex); if (null === $participant) { diff --git a/src/Form/Service/Condition/PickupsMutabilityCondition.php b/src/Form/Service/Condition/PickupsMutabilityCondition.php index e9b9a5c..8a82cdc 100644 --- a/src/Form/Service/Condition/PickupsMutabilityCondition.php +++ b/src/Form/Service/Condition/PickupsMutabilityCondition.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -12,7 +12,7 @@ use App\Form\Service\Contract\FieldConditionInterface; */ class PickupsMutabilityCondition implements FieldConditionInterface { - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { return false === $bookingDto->travel->pickupsMutable; } diff --git a/src/Form/Service/Condition/RentalSelectionCondition.php b/src/Form/Service/Condition/RentalSelectionCondition.php index b54b836..976cbfd 100644 --- a/src/Form/Service/Condition/RentalSelectionCondition.php +++ b/src/Form/Service/Condition/RentalSelectionCondition.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; use App\BusProNet\Model\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -28,13 +28,13 @@ class RentalSelectionCondition implements FieldConditionInterface * which would indicate rental services have been selected and body * dimensions should be required for proper equipment sizing. * - * @param BookingDtoInterface $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * * @return bool True if rental services are selected, false otherwise */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { // First check submitted form data for rental selections if (isset($formData['participants'][$participantIndex]['rentals'])) { diff --git a/src/Form/Service/Condition/RoomSelectionCondition.php b/src/Form/Service/Condition/RoomSelectionCondition.php index e3a4bf2..d6f0638 100644 --- a/src/Form/Service/Condition/RoomSelectionCondition.php +++ b/src/Form/Service/Condition/RoomSelectionCondition.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; use App\BusProNet\Model\Room; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -35,13 +35,13 @@ class RoomSelectionCondition implements FieldConditionInterface * Checks both submitted form data and participant DTO data to determine * if the selected room matches any of the configured room codes. * - * @param BookingDtoInterface $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * * @return bool True if room with matching code is selected, false otherwise */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { // First check submitted form data for room selection if (isset($formData['participants'][$participantIndex]['assignedRoomId'])) { @@ -95,11 +95,11 @@ class RoomSelectionCondition implements FieldConditionInterface * Finds a room by ID in the available travel rooms. * * @param int $roomId The room ID to find - * @param BookingDtoInterface $bookingDto The booking DTO containing travel data + * @param BookingDto $bookingDto The booking DTO containing travel data * * @return Room|null The found room or null if not found */ - private function findRoomById(int $roomId, BookingDtoInterface $bookingDto): ?Room + private function findRoomById(int $roomId, BookingDto $bookingDto): ?Room { foreach ($bookingDto->travel->rooms as $room) { if ($room->id === $roomId) { diff --git a/src/Form/Service/Condition/ServiceSubTypeCondition.php b/src/Form/Service/Condition/ServiceSubTypeCondition.php index 2f8a6e4..baf6d55 100644 --- a/src/Form/Service/Condition/ServiceSubTypeCondition.php +++ b/src/Form/Service/Condition/ServiceSubTypeCondition.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; use App\BusProNet\Model\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -52,13 +52,13 @@ class ServiceSubTypeCondition implements FieldConditionInterface * property against the expected value(s). Handles both form data and * participant DTO data sources. * - * @param BookingDtoInterface $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * * @return bool True if the service sub-type meets the condition criteria, false otherwise */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { $service = $this->getService($formData, $participantIndex, $bookingDto); @@ -161,7 +161,7 @@ class ServiceSubTypeCondition implements FieldConditionInterface /** * Retrieves service from form data or participant data. */ - private function getService(array $formData, int $participantIndex, BookingDtoInterface $bookingDto): ?Service + private function getService(array $formData, int $participantIndex, BookingDto $bookingDto): ?Service { // First check participant-specific form data if (isset($formData['participants'][$participantIndex][$this->serviceFieldName])) { diff --git a/src/Form/Service/Condition/SkiPassSelectionCondition.php b/src/Form/Service/Condition/SkiPassSelectionCondition.php index 6fa7526..fbf9970 100644 --- a/src/Form/Service/Condition/SkiPassSelectionCondition.php +++ b/src/Form/Service/Condition/SkiPassSelectionCondition.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; use App\BusProNet\Model\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -28,13 +28,13 @@ class SkiPassSelectionCondition implements FieldConditionInterface * which would indicate a skipass has been selected and rental services * should be made available with duration filtering applied. * - * @param BookingDtoInterface $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * * @return bool True if a skipass is selected, false otherwise */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { // First check submitted form data for skipass selection if (isset($formData['participants'][$participantIndex]['skiPass'])) { diff --git a/src/Form/Service/Condition/TransportationServicesMutabilityCondition.php b/src/Form/Service/Condition/TransportationServicesMutabilityCondition.php index 85e86c2..9ab83a4 100644 --- a/src/Form/Service/Condition/TransportationServicesMutabilityCondition.php +++ b/src/Form/Service/Condition/TransportationServicesMutabilityCondition.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Condition; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Contract\FieldConditionInterface; /** @@ -12,7 +12,7 @@ use App\Form\Service\Contract\FieldConditionInterface; */ class TransportationServicesMutabilityCondition implements FieldConditionInterface { - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool { return false === $bookingDto->travel->transportationServicesMutable; } diff --git a/src/Form/Service/Contract/FieldConditionInterface.php b/src/Form/Service/Contract/FieldConditionInterface.php index 5e4f4c2..d7458c7 100644 --- a/src/Form/Service/Contract/FieldConditionInterface.php +++ b/src/Form/Service/Contract/FieldConditionInterface.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Contract; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; /** * Interface for evaluating field state conditions. @@ -29,13 +29,13 @@ interface FieldConditionInterface * state of the booking, participant data, and submitted form values. * The result is used to determine field state (enabled/disabled/readonly). * - * @param BookingDtoInterface $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data (may include partial submissions) * * @return bool True if the condition is met, false otherwise */ - public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool; + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool; /** * Returns field names that trigger re-evaluation of this condition. diff --git a/src/Form/Service/Contract/FieldOptionsProviderInterface.php b/src/Form/Service/Contract/FieldOptionsProviderInterface.php index d2a6b28..5ba78c9 100644 --- a/src/Form/Service/Contract/FieldOptionsProviderInterface.php +++ b/src/Form/Service/Contract/FieldOptionsProviderInterface.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Contract; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; /** * Interface for providing dynamic field options based on context. @@ -42,13 +42,13 @@ interface FieldOptionsProviderInterface * static options defined in the form type. * * @param string $fieldName The name of the field to configure - * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being configured * @param array $options Additional options to customize field behavior * * @return array Symfony form field options, or empty array if field not supported */ - public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $options = []): array; + public function getFieldOptions(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $options = []): array; /** * Checks whether a field has option provider support. diff --git a/src/Form/Service/Contract/FieldStateProviderInterface.php b/src/Form/Service/Contract/FieldStateProviderInterface.php index 10f5b4e..b005dc8 100644 --- a/src/Form/Service/Contract/FieldStateProviderInterface.php +++ b/src/Form/Service/Contract/FieldStateProviderInterface.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Contract; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; /** * Interface for providing dynamic field state based on conditions. @@ -38,13 +38,13 @@ interface FieldStateProviderInterface * condition independently to allow early field exclusion during form building. * * @param string $fieldName The name of the field to evaluate - * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * * @return bool True if the field should be included in the form, false if it should be excluded */ - public function shouldIncludeField(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): bool; + public function shouldIncludeField(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $formData = []): bool; /** * Calculates the dynamic state for a specified field. @@ -65,13 +65,13 @@ interface FieldStateProviderInterface * - 'attr' => ['class' => 'conditional-field'] - Add CSS classes * * @param string $fieldName The name of the field to evaluate - * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data (may include partial submissions) * * @return array Symfony form field options for state modifications, empty if no changes needed */ - public function getFieldState(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array; + public function getFieldState(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $formData = []): array; /** * Checks whether a field has state conditions configured. @@ -110,11 +110,11 @@ interface FieldStateProviderInterface * when multiple field states need to be determined simultaneously. It's * particularly useful during form building and bulk state updates. * - * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * * @return array> Field states indexed by field name */ - public function getAllFieldStates(BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array; + public function getAllFieldStates(BookingDto $bookingDto, int $participantIndex, array $formData = []): array; } diff --git a/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php b/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php index 8fdc81b..adb8b61 100644 --- a/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php +++ b/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Contract; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; /** * Interface for handling dynamic participant form field processing and state modification. @@ -32,10 +32,10 @@ interface ParticipantFieldHandlerInterface * Processes the participant field data from submitted form data and updates the DTO. * * @param array $submittedData The submitted participant form data - * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) + * @param BookingDto $bookingDto The booking DTO to update (create or edit) * @param int $participantIndex The participant index being processed */ - public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void; + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void; /** * Determines if this handler should process the field based on submitted participant data. @@ -50,12 +50,12 @@ interface ParticipantFieldHandlerInterface * to enable/disable/hide fields based on the handler's processing results. * * @param array $submittedData The submitted participant form data - * @param BookingDtoInterface $bookingDto The booking DTO (potentially modified by processing) + * @param BookingDto $bookingDto The booking DTO (potentially modified by processing) * @param int $participantIndex The participant index being processed * * @return array> Field state modifications indexed by field name */ - public function getFieldStateModifications(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): array; + public function getFieldStateModifications(array $submittedData, BookingDto $bookingDto, int $participantIndex): array; /** * Returns field names whose state is affected by this handler's processing. diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index e2ef97a..bfd0563 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -6,6 +6,7 @@ namespace App\Form\Service; use App\BusProNet\Utility\DirectionMapper; use App\Form\Service\Abstract\AbstractFieldStateProvider; +use App\Form\Service\Condition\ApplicantCondition; use App\Form\Service\Condition\BookingEligibilityCondition; use App\Form\Service\Condition\BulkInsuranceBookingCondition; use App\Form\Service\Condition\CompositeCondition; @@ -34,10 +35,11 @@ use App\Service\ParticipantEligibilityService; class CreateFieldStateProvider extends AbstractFieldStateProvider { public function __construct( - private readonly ParticipantEligibilityService $participantEligibilityService + private readonly ParticipantEligibilityService $participantEligibilityService, ) { parent::__construct(); } + /** * Registers field state conditions for the create workflow. * @@ -124,22 +126,7 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider $this->fieldStateConditions['bulkInsuranceBooking'] = [ 'hidden' => CompositeCondition::or( CompositeCondition::not($dateOfBirthProvidedCondition), // Hide until date of birth provided - new class() implements \App\Form\Service\Contract\FieldConditionInterface { - public function evaluate(\App\Form\Model\BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool - { - return $participantIndex > 0; // Hide for all participants except applicant - } - - public function getDependentFields(): array - { - return []; - } - - public function getDescription(): string - { - return 'Participant is not the applicant'; - } - } + CompositeCondition::not(new ApplicantCondition()) // Hide for non-applicants ), ]; diff --git a/src/Form/Service/EditFieldStateProvider.php b/src/Form/Service/EditFieldStateProvider.php index 150125e..9f6f665 100644 --- a/src/Form/Service/EditFieldStateProvider.php +++ b/src/Form/Service/EditFieldStateProvider.php @@ -11,6 +11,7 @@ use App\Form\Service\Condition\ApplicantCondition; use App\Form\Service\Condition\CompositeCondition; use App\Form\Service\Condition\DateOfBirthProvidedCondition; use App\Form\Service\Condition\FieldValueCondition; +use App\Form\Service\Condition\InsuranceMutabilityCondition; use App\Form\Service\Condition\MutabilityCondition; use App\Form\Service\Condition\PickupsMutabilityCondition; use App\Form\Service\Condition\RentalSelectionCondition; @@ -43,7 +44,8 @@ class EditFieldStateProvider extends AbstractFieldStateProvider $transportationServicesMutabilityCondition = new TransportationServicesMutabilityCondition(); $pickupsMutabilityCondition = new PickupsMutabilityCondition(); - // Make all personal data fields readonly if not mutable OR if applicant + // Make all personal data fields readonly if participant not mutable + // Note: First participant is now treated as independent from applicant and can be edited $personalDataFields = [ 'firstName', 'lastName', @@ -55,13 +57,15 @@ class EditFieldStateProvider extends AbstractFieldStateProvider ]; foreach ($personalDataFields as $field) { $this->fieldStateConditions[$field] = [ - 'readonly' => CompositeCondition::or( - new ApplicantCondition(), - new MutabilityCondition() - ), + 'readonly' => CompositeCondition::not(new MutabilityCondition()), ]; } + // Address fields - readonly if participant not mutable + $this->fieldStateConditions['address'] = [ + 'readonly' => CompositeCondition::not(new MutabilityCondition()), + ]; + // Conditional visibility for service fields (same as create flow) $rentalCondition = new RentalSelectionCondition(); $skiPassCondition = new SkiPassSelectionCondition(); @@ -72,33 +76,40 @@ class EditFieldStateProvider extends AbstractFieldStateProvider 'hidden' => CompositeCondition::not($rentalCondition), ]; - // Age-dependent service fields - hidden until birth date provided + // Age-dependent service fields - hidden until birth date provided (except for applicant who always has DOB) // Also readonly if services not mutable + // For applicant in edit mode: DOB is always available (patched from booking), so never hide + $hideUntilDobCondition = CompositeCondition::and( + CompositeCondition::not($dateOfBirthProvidedCondition), + CompositeCondition::not(new ApplicantCondition()) // Don't hide for applicant + ); + $this->fieldStateConditions['courses'] = [ - 'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition), + 'hidden' => $hideUntilDobCondition, 'readonly' => $additionalServicesMutabilityCondition, ]; $this->fieldStateConditions['additionalServices'] = [ - 'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition), + 'hidden' => $hideUntilDobCondition, 'readonly' => $additionalServicesMutabilityCondition, ]; $this->fieldStateConditions['board'] = [ - 'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition), + 'hidden' => $hideUntilDobCondition, 'readonly' => $additionalServicesMutabilityCondition, ]; - // Skipass - hidden until birth date, readonly if services not mutable + // Skipass - hidden until birth date (except applicant), readonly if services not mutable $this->fieldStateConditions['skiPass'] = [ - 'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition), + 'hidden' => $hideUntilDobCondition, 'readonly' => $additionalServicesMutabilityCondition, ]; // Rentals - shown only when skipass selected, readonly if services not mutable + // For applicant: only check skipass, not DOB $this->fieldStateConditions['rentals'] = [ 'hidden' => CompositeCondition::or( - CompositeCondition::not($dateOfBirthProvidedCondition), + $hideUntilDobCondition, CompositeCondition::not($skiPassCondition) ), 'readonly' => $additionalServicesMutabilityCondition, @@ -110,14 +121,14 @@ class EditFieldStateProvider extends AbstractFieldStateProvider 'readonly' => $additionalServicesMutabilityCondition, ]; - // Transportation fields - hidden until birth date, readonly if transportation not mutable + // Transportation fields - hidden until birth date (except applicant), readonly if transportation not mutable $this->fieldStateConditions['transportationOutbound'] = [ - 'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition), + 'hidden' => $hideUntilDobCondition, 'readonly' => $transportationServicesMutabilityCondition, ]; $this->fieldStateConditions['transportationInbound'] = [ - 'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition), + 'hidden' => $hideUntilDobCondition, 'readonly' => $transportationServicesMutabilityCondition, ]; diff --git a/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php b/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php index 9e49355..585e8c0 100644 --- a/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php +++ b/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php @@ -6,7 +6,7 @@ namespace App\Form\Service; use App\BusProNet\Constants; use App\BusProNet\Model\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -84,10 +84,10 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField * 5. Updates participant with filtered valid selections * * @param array $submittedData The submitted participant form data - * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) + * @param BookingDto $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 + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { // Safely get the participant object, returning early if not found $participant = $this->getParticipant($bookingDto, $participantIndex); @@ -122,7 +122,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField * * @param array $selectedServices List of currently selected services * @param array $availableServices List of all available additional services - * @param BookingDtoInterface $bookingDto The booking DTO for context + * @param BookingDto $bookingDto The booking DTO for context * @param int $participantIndex The participant index for age evaluation * * @return array Filtered array of valid service selections @@ -130,7 +130,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField private function filterValidServiceSelections( array $selectedServices, array $availableServices, - BookingDtoInterface $bookingDto, + BookingDto $bookingDto, int $participantIndex, ): array { $validSelections = []; @@ -156,7 +156,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField * * @param mixed $selectedService The selected service to validate * @param array $availableServices Array of available services - * @param BookingDtoInterface $bookingDto The booking DTO for context + * @param BookingDto $bookingDto The booking DTO for context * @param int $participantIndex The participant index for age evaluation * * @return bool True if the service is valid for the participant, false otherwise @@ -164,7 +164,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField private function isServiceValidForParticipant( mixed $selectedService, array $availableServices, - BookingDtoInterface $bookingDto, + BookingDto $bookingDto, int $participantIndex, ): bool { // Find the service in available services diff --git a/src/Form/Service/ParticipantAssignedRoomFieldHandler.php b/src/Form/Service/ParticipantAssignedRoomFieldHandler.php index 3f68b4b..76d38d2 100644 --- a/src/Form/Service/ParticipantAssignedRoomFieldHandler.php +++ b/src/Form/Service/ParticipantAssignedRoomFieldHandler.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -53,10 +53,10 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle * 4. Updates the participant's assignedRoomId property * * @param array $submittedData The submitted participant form data - * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) + * @param BookingDto $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 + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { // Safely get the participant object, returning early if not found $participant = $this->getParticipant($bookingDto, $participantIndex); diff --git a/src/Form/Service/ParticipantBoardFieldHandler.php b/src/Form/Service/ParticipantBoardFieldHandler.php index 13f8af0..0fecb7d 100644 --- a/src/Form/Service/ParticipantBoardFieldHandler.php +++ b/src/Form/Service/ParticipantBoardFieldHandler.php @@ -6,7 +6,7 @@ namespace App\Form\Service; use App\BusProNet\Constants; use App\BusProNet\Model\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -49,7 +49,7 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler return true; // Always process to handle deselection cases } - public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { $participant = $this->getParticipant($bookingDto, $participantIndex); if (null === $participant) { @@ -72,7 +72,7 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler private function filterValidServiceSelections( array $selectedServices, array $availableServices, - BookingDtoInterface $bookingDto, + BookingDto $bookingDto, int $participantIndex, ): array { $validSelections = []; @@ -93,7 +93,7 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler private function isServiceValidForParticipant( mixed $selectedService, array $availableServices, - BookingDtoInterface $bookingDto, + BookingDto $bookingDto, int $participantIndex, ): bool { $service = $this->findServiceInAvailableServices($selectedService, $availableServices); diff --git a/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php b/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php index 843e045..d3ac2be 100644 --- a/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php @@ -4,30 +4,23 @@ declare(strict_types=1); namespace App\Form\Service; -use App\Form\Model\BookingCreateDto; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; -use App\Service\InsuranceMatchingService; /** - * Handles bulk insurance booking for the applicant (first participant). + * Handles bulk insurance booking checkbox for the applicant (first participant). * - * When the applicant enables bulk insurance booking, their selected insurance type - * (subType + familyInsurance) is automatically assigned to all participants with - * automatic price tier adjustment based on each participant's individual travel price. + * This handler only manages the checkbox state - it does NOT assign insurance to + * dependent participants. The actual bulk insurance assignment happens in the + * BookingDataProcessor during API submission, keeping the form layer clean and + * avoiding cross-participant modifications in field handlers. * - * This handler processes the bulkInsuranceBooking checkbox state and triggers - * insurance assignment to dependent participants when enabled. - * - * Dependencies: insurance (applicant must have insurance selected before enabling bulk) + * The checkbox state is used by: + * - Templates: To display "wie Anmelder" for dependent participants + * - Processors: To apply applicant's insurance to all participants before API submission */ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandler { - public function __construct( - private readonly InsuranceMatchingService $insuranceMatchingService, - ) { - } - public function getFieldName(): string { return 'bulkInsuranceBooking'; @@ -35,8 +28,8 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl public function getDependencies(): array { - // Depends on insurance field to ensure insurance is selected before bulk assignment - return ['insurance']; + // No dependencies - just manages checkbox state + return []; } /** @@ -58,120 +51,22 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl /** * Processes the bulk insurance booking checkbox for the applicant. * - * When bulk insurance is enabled and applicant has insurance selected, - * assigns the same insurance type to all participants based on their - * individual pricing and eligibility criteria. + * This handler ONLY stores the checkbox state. The actual insurance assignment + * to dependent participants is handled by BookingDataProcessor during API submission. * * @param array $submittedData The submitted participant form data - * @param BookingDtoInterface $bookingDto The booking DTO to update + * @param BookingDto $bookingDto The booking DTO to update * @param int $participantIndex The index of the participant (must be 0) */ - public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { $participant = $this->getParticipant($bookingDto, $participantIndex); - if (null === $participant || !$bookingDto instanceof BookingCreateDto) { + if (null === $participant) { return; } - // Get checkbox value from submitted data + // Get checkbox value from submitted data and store it $bulkInsuranceBooking = $this->getFieldValue($submittedData, $this->getFieldName()); - $isBulkEnabled = (bool) $bulkInsuranceBooking; - - // Store checkbox state - $participant->bulkInsuranceBooking = $isBulkEnabled; - - // If bulk insurance is enabled and applicant has insurance, assign to all participants - if (true === $isBulkEnabled && null !== $participant->insurance) { - $this->applyBulkInsuranceToAllParticipants($bookingDto, $participant->insurance); - } - - // If bulk insurance was CHANGED from enabled to disabled, clear dependent participants' insurances - // Don't clear if it was never enabled (to allow independent insurance selection) - if (false === $isBulkEnabled && $this->wasBulkInsurancePreviouslyEnabled($bookingDto)) { - $this->clearDependentParticipantsInsurance($bookingDto); - } - } - - /** - * Applies the applicant's insurance type to all participants with automatic price tier adjustment. - * - * Uses InsuranceMatchingService to find the appropriate price tier for each participant - * based on their individual travel price and eligibility criteria. - * - * @param BookingCreateDto $bookingDto The booking DTO with all participants - * @param object $applicantInsurance The insurance selected by the applicant - */ - private function applyBulkInsuranceToAllParticipants(BookingCreateDto $bookingDto, object $applicantInsurance): void - { - $availableInsurances = $bookingDto->travel->insurances ?? []; - - // Get insurance assignments for all participants - $assignments = $this->insuranceMatchingService->batchAssignInsuranceToParticipants( - $availableInsurances, - $applicantInsurance, - $bookingDto - ); - - // Apply assignments to participants (skip applicant index 0, they keep their selection) - foreach ($assignments as $index => $insurance) { - if (0 === $index) { - continue; // Skip applicant - } - - $participant = $bookingDto->getParticipant($index); - if (null !== $participant) { - $participant->insurance = $insurance; - } - } - } - - /** - * Clears insurance selections for dependent participants when bulk booking is disabled. - * - * @param BookingCreateDto $bookingDto The booking DTO with all participants - */ - private function clearDependentParticipantsInsurance(BookingCreateDto $bookingDto): void - { - foreach ($bookingDto->getParticipants() as $index => $participant) { - if (0 === $index) { - continue; // Skip applicant - } - - $participant->insurance = null; - } - } - - /** - * Checks if bulk insurance was previously enabled by checking if dependent participants - * have the same insurance type as the applicant. - * - * This prevents clearing independent insurance selections when the checkbox is simply unchecked - * without ever having been enabled. - * - * @param BookingCreateDto $bookingDto The booking DTO with all participants - * - * @return bool True if bulk was previously active (dependent participants have matching insurance) - */ - private function wasBulkInsurancePreviouslyEnabled(BookingCreateDto $bookingDto): bool - { - $applicant = $bookingDto->participants[0] ?? null; - if (null === $applicant || null === $applicant->insurance) { - return false; - } - - // Check if any dependent participant has insurance that matches the applicant - // If so, bulk was likely previously enabled - foreach ($bookingDto->participants as $index => $participant) { - if (0 === $index) { - continue; // Skip applicant - } - - if (null !== $participant->insurance) { - // If any dependent has insurance, assume bulk was previously enabled - return true; - } - } - - return false; + $participant->bulkInsuranceBooking = (bool) $bulkInsuranceBooking; } } diff --git a/src/Form/Service/ParticipantCoursesFieldHandler.php b/src/Form/Service/ParticipantCoursesFieldHandler.php index 8fa6b9f..f0fd034 100644 --- a/src/Form/Service/ParticipantCoursesFieldHandler.php +++ b/src/Form/Service/ParticipantCoursesFieldHandler.php @@ -6,7 +6,7 @@ namespace App\Form\Service; use App\BusProNet\Constants; use App\BusProNet\Model\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -77,10 +77,10 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler * no longer appropriate for the participant's age are automatically removed. * * @param array $submittedData The submitted participant form data - * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) + * @param BookingDto $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 + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { // Safely get the participant object, returning early if not found $participant = $this->getParticipant($bookingDto, $participantIndex); @@ -111,7 +111,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler * * @param array $selectedServices List of currently selected courses * @param array $availableServices List of all available courses - * @param BookingDtoInterface $bookingDto The booking DTO for context + * @param BookingDto $bookingDto The booking DTO for context * @param int $participantIndex The participant index for age evaluation * * @return array Filtered array of valid course selections @@ -119,7 +119,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler private function filterValidServiceSelections( array $selectedServices, array $availableServices, - BookingDtoInterface $bookingDto, + BookingDto $bookingDto, int $participantIndex, ): array { $validSelections = []; @@ -142,7 +142,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler * * @param mixed $selectedService The selected course to validate * @param array $availableServices Array of available courses - * @param BookingDtoInterface $bookingDto The booking DTO for context + * @param BookingDto $bookingDto The booking DTO for context * @param int $participantIndex The participant index for age evaluation * * @return bool True if the course is valid for the participant, false otherwise @@ -150,7 +150,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler private function isServiceValidForParticipant( mixed $selectedService, array $availableServices, - BookingDtoInterface $bookingDto, + BookingDto $bookingDto, int $participantIndex, ): bool { // Find the service in available services diff --git a/src/Form/Service/ParticipantDateOfBirthFieldHandler.php b/src/Form/Service/ParticipantDateOfBirthFieldHandler.php index fe196bd..521f99f 100644 --- a/src/Form/Service/ParticipantDateOfBirthFieldHandler.php +++ b/src/Form/Service/ParticipantDateOfBirthFieldHandler.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -53,10 +53,10 @@ class ParticipantDateOfBirthFieldHandler extends AbstractParticipantFieldHandler * 4. Updates the participant's dateOfBirth property * * @param array $submittedData The submitted participant form data - * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) + * @param BookingDto $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 + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { // Safely get the participant object, returning early if not found $participant = $this->getParticipant($bookingDto, $participantIndex); diff --git a/src/Form/Service/ParticipantFieldHandlerRegistry.php b/src/Form/Service/ParticipantFieldHandlerRegistry.php index a26b136..c80d33f 100644 --- a/src/Form/Service/ParticipantFieldHandlerRegistry.php +++ b/src/Form/Service/ParticipantFieldHandlerRegistry.php @@ -7,7 +7,7 @@ namespace App\Form\Service; use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Service\Contract\ParticipantFieldHandlerInterface; @@ -75,12 +75,14 @@ class ParticipantFieldHandlerRegistry * automatically cleared. * * @param array $submittedData The submitted form data containing participants array - * @param BookingDtoInterface $bookingDto The booking DTO to update with processed field values + * @param BookingDto $bookingDto The booking DTO to update with processed field values * * @return array The synchronized submitted data reflecting DTO changes */ - public function processFieldsAndSync(array $submittedData, BookingDtoInterface $bookingDto): array + public function processFieldsAndSync(array $submittedData, BookingDto $bookingDto): array { + error_log(sprintf('[ProcessFieldsAndSync] Mode: %s', $bookingDto->getMode())); + // Process all field handlers to clean the DTO $this->processFields($submittedData, $bookingDto); @@ -105,9 +107,9 @@ class ParticipantFieldHandlerRegistry * family detection which needs all participants' ages to be processed first). * * @param array $submittedData The submitted form data containing participants array - * @param BookingDtoInterface $bookingDto The booking DTO to update with processed field values (create or edit) + * @param BookingDto $bookingDto The booking DTO to update with processed field values (create or edit) */ - public function processFields(array $submittedData, BookingDtoInterface $bookingDto): void + public function processFields(array $submittedData, BookingDto $bookingDto): void { // Early return if no participant data exists in submission if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) { @@ -245,15 +247,18 @@ class ParticipantFieldHandlerRegistry * that the form continues processing with cleaned data rather than the original * submitted data that may contain invalid selections. * + * In edit mode, this also adds the applicant's personal data to the submitted data + * for participant 0, ensuring those fields are bound to the DTO by handleRequest. + * * The synchronization is generic and works with any field handlers by examining * the current DTO state and updating the corresponding submitted data fields. * * @param array $submittedData The original submitted form data - * @param BookingDtoInterface $bookingDto The DTO with cleaned data from field handlers + * @param BookingDto $bookingDto The DTO with cleaned data from field handlers * * @return array Updated submitted data reflecting DTO state */ - private function syncSubmittedDataWithDto(array $submittedData, BookingDtoInterface $bookingDto): array + private function syncSubmittedDataWithDto(array $submittedData, BookingDto $bookingDto): array { // Ensure participants array exists in submitted data if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) { @@ -272,7 +277,7 @@ class ParticipantFieldHandlerRegistry } // Update participant data to match cleaned DTO state - $submittedData['participants'][$index] = $this->syncParticipantData($participantData, $participant); + $submittedData['participants'][$index] = $this->syncParticipantData($participantData, $participant, $index, $bookingDto); } return $submittedData; @@ -285,13 +290,20 @@ class ParticipantFieldHandlerRegistry * any changes made by field handlers. It automatically detects which fields have * been processed by checking against registered handlers. * + * In edit mode for participant 0 (applicant), this also adds the personal data fields + * that were patched from the applicant, ensuring they're bound to the DTO. + * * @param array $participantData The submitted participant data * @param ParticipantDto $participant The cleaned participant DTO + * @param int $index The participant index + * @param BookingDto $bookingDto The booking DTO for mode detection * * @return array Updated participant data with synchronized field values */ - private function syncParticipantData(array $participantData, ParticipantDto $participant): array + private function syncParticipantData(array $participantData, ParticipantDto $participant, int $index, BookingDto $bookingDto): array { + error_log(sprintf('[Sync] Participant %d fields in submission: %s', $participant->index ?? -1, implode(', ', array_keys($participantData)))); + // Sync fields for all registered handlers foreach ($this->handlers as $fieldName => $handler) { // Only sync fields that were in the original submission @@ -299,6 +311,7 @@ class ParticipantFieldHandlerRegistry if (property_exists($participant, $fieldName) && array_key_exists($fieldName, $participantData)) { $dtoValue = $participant->{$fieldName}; $participantData[$fieldName] = $this->convertDtoValueToSubmittedFormat($dtoValue); + error_log(sprintf('[Sync] Participant %d: synced %s', $participant->index ?? -1, $fieldName)); } } @@ -362,6 +375,17 @@ class ParticipantFieldHandlerRegistry return $value->id; } + // Handle Address objects -> convert to array of properties + if ($value instanceof \App\BusProNet\Model\Address) { + return [ + 'street' => $value->street, + 'postCode' => $value->postCode, + 'city' => $value->city, + 'district' => $value->district, + 'country' => $value->country, + ]; + } + // Handle DateTimeInterface -> convert to string format if ($value instanceof \DateTimeInterface) { return $value->format('Y-m-d'); diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 9d35efe..99e1cab 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -9,7 +9,7 @@ use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Service; use App\BusProNet\Utility\DirectionMapper; use App\Form\Model\BookingCreateDto; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Model\BookingEditDto; use App\Form\Service\Abstract\AbstractFieldOptionsProvider; use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory; @@ -82,11 +82,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider protected function registerFieldOptionProviders(): void { // Room assignment field provider (available for both create and edit workflows) - $this->fieldOptionProviders['assignedRoomId'] = function (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) { + $this->fieldOptionProviders['assignedRoomId'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []) { $choiceLoader = null; $disabled = false; - if ($bookingDto instanceof BookingCreateDto) { + if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { // Create context: use selected rooms from step 1 $choiceLoader = $this->roomChoiceLoaderFactory->createForCreate( $bookingDto->participants, @@ -95,7 +95,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ); // Disable when only one room type selected (auto-assigned) $disabled = 1 === count($bookingDto->getSelectedRooms()); - } elseif ($bookingDto instanceof BookingEditDto) { + } elseif (BookingDto::MODE_EDIT === $bookingDto->getMode()) { // Edit context: use already-booked rooms from booking $choiceLoader = $this->roomChoiceLoaderFactory->createForEdit( $bookingDto->participants, @@ -121,7 +121,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider }; // Courses field provider - provides age-appropriate courses from travel data - $this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + $this->fieldOptionProviders['courses'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Kurse', 'multiple' => true, 'expanded' => true, @@ -156,7 +156,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ]; // Additional services field provider - provides age-appropriate additional services with mandatory pre-selection - $this->fieldOptionProviders['additionalServices'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + $this->fieldOptionProviders['additionalServices'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Zusatzleistungen', 'multiple' => true, 'expanded' => true, @@ -199,7 +199,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ]; // Board field provider - provides age-appropriate board options from travel data - $this->fieldOptionProviders['board'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + $this->fieldOptionProviders['board'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Verpflegung', 'multiple' => true, 'expanded' => true, @@ -229,7 +229,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ]; // Rentals field provider - provides age-appropriate rental options filtered by selected skipass duration - $this->fieldOptionProviders['rentals'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + $this->fieldOptionProviders['rentals'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Leihmaterial', 'multiple' => true, 'expanded' => true, @@ -268,7 +268,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ]; // Rental insurance field provider - provides rental insurance options when rental services are selected - $this->fieldOptionProviders['rentalInsurance'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + $this->fieldOptionProviders['rentalInsurance'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => $this->getRentalInsuranceCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true, true)), 'required' => false, 'property_path' => 'rentalInsuranceSelected', @@ -276,7 +276,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ]; // License plate field provider - provides text input for vehicle license plate when parking is selected - $this->fieldOptionProviders['licensePlate'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + $this->fieldOptionProviders['licensePlate'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Kennzeichen', 'required' => false, 'attr' => [ @@ -288,7 +288,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ]; // Skipass field provider - provides age-appropriate skipass options from travel data filtered by date range - $this->fieldOptionProviders['skiPass'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + $this->fieldOptionProviders['skiPass'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Skipass', 'multiple' => false, 'expanded' => true, @@ -323,7 +323,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ]; // Room remarks field provider - provides textarea for room-specific remarks (only for 'mbz' rooms) - $this->fieldOptionProviders['remarksRoom'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + $this->fieldOptionProviders['remarksRoom'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Wünsche oder Anmerkungen zum Zimmer', 'required' => false, 'sanitize_html' => true, @@ -335,7 +335,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // Transportation field providers - handles outbound/inbound transportation and pickup selection // Outbound Transportation - $this->fieldOptionProviders['transportationOutbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + $this->fieldOptionProviders['transportationOutbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Hinfahrt', 'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL), 'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service), @@ -366,7 +366,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ]; // Inbound Transportation - $this->fieldOptionProviders['transportationInbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + $this->fieldOptionProviders['transportationInbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Rückfahrt', 'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL), 'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service), @@ -398,7 +398,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // Pickup (conditional - only shown when either transportation direction is bus) // Uses outbound pickups list, applies to both directions - $this->fieldOptionProviders['pickup'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + $this->fieldOptionProviders['pickup'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Zu- und Ausstieg', 'choices' => $bookingDto->travel->pickupsOutbound, 'choice_label' => fn (?Pickup $pickup) => $this->formatPickupLabelWithPrice($pickup), @@ -411,13 +411,14 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // Parking (conditional - only shown when outbound transportation is PKW) // Simple checkbox since there's only ever one parking type - $this->fieldOptionProviders['parking'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + $this->fieldOptionProviders['parking'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => $this->getParkingCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true)), 'required' => false, ]; // Bulk insurance booking checkbox (applicant only - controls insurance assignment for all participants) - $this->fieldOptionProviders['bulkInsuranceBooking'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + // Only registered in create mode - insurance cannot be modified in edit mode due to API limitation + $this->fieldOptionProviders['bulkInsuranceBooking'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Für alle Teilnehmer buchen', 'required' => false, 'attr' => [ @@ -428,7 +429,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ]; // Insurance field provider - provides age and eligibility filtered insurances for participants - $this->fieldOptionProviders['insurance'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + // Only registered in create mode - insurance cannot be modified in edit mode due to API limitation + $this->fieldOptionProviders['insurance'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Reiseversicherung', 'multiple' => false, 'expanded' => true, @@ -461,12 +463,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider * is only available for the exact duration of the skipass. * * @param array $rentals Array of rental Service objects to filter - * @param BookingDtoInterface $bookingDto The booking DTO containing participant data + * @param BookingDto $bookingDto The booking DTO containing participant data * @param int $participantIndex Index of the participant to evaluate * * @return array Filtered array of rentals matching skipass duration */ - private function filterRentalsBySkiPassDuration(array $rentals, BookingDtoInterface $bookingDto, int $participantIndex): array + private function filterRentalsBySkiPassDuration(array $rentals, BookingDto $bookingDto, int $participantIndex): array { $participant = $bookingDto->getParticipant($participantIndex); if (null === $participant || null === $participant->skiPass) { @@ -586,12 +588,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider * Checks if a service should be rendered as read-only due to unavailability. * * @param Service $service The service to check - * @param BookingDtoInterface $bookingDto The booking DTO containing participant data + * @param BookingDto $bookingDto The booking DTO containing participant data * @param int $participantIndex Index of the participant currently selecting services * * @return bool True if the service should be read-only due to unavailability */ - private function isServiceUnavailableForParticipant(Service $service, BookingDtoInterface $bookingDto, int $participantIndex): bool + private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool { if (!$bookingDto instanceof BookingCreateDto) { // For non-create workflows, don't apply availability restrictions @@ -609,12 +611,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider * returns empty array to be handled by field visibility conditions. * * @param array $services Array of Service objects to filter - * @param BookingDtoInterface $bookingDto The booking DTO containing participant data + * @param BookingDto $bookingDto The booking DTO containing participant data * @param int $participantIndex Index of the participant to evaluate * * @return array Filtered array of available services */ - private function filterServicesByAgeConstraints(array $services, BookingDtoInterface $bookingDto, int $participantIndex): array + private function filterServicesByAgeConstraints(array $services, BookingDto $bookingDto, int $participantIndex): array { $ageEvaluator = new ServiceAgeEvaluator(); @@ -664,12 +666,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider /** * Gets eligible insurances for a participant based on eligibility criteria. * - * @param BookingDtoInterface $bookingDto The booking DTO containing travel and participant data + * @param BookingDto $bookingDto The booking DTO containing travel and participant data * @param int $participantIndex The index of the participant to get eligible insurances for * * @return array Array of eligible insurance objects filtered by age, family status, and other constraints */ - private function getEligibleInsurances(BookingDtoInterface $bookingDto, int $participantIndex): array + private function getEligibleInsurances(BookingDto $bookingDto, int $participantIndex): array { $participant = $bookingDto->getParticipant($participantIndex); if (null === $participant) { @@ -682,11 +684,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // They are only available as part of packages $availableInsurances = array_filter($availableInsurances, fn ($insurance) => !$insurance->complementary); - // Only apply insurance filtering for BookingCreateDto (creation workflow) - if (!$bookingDto instanceof BookingCreateDto) { - return $availableInsurances; - } - // Use insurance matching service to filter based on eligibility criteria return $this->insuranceMatchingService->getEligibleInsurances( $availableInsurances, diff --git a/src/Form/Service/ParticipantInsuranceFieldHandler.php b/src/Form/Service/ParticipantInsuranceFieldHandler.php index f1a3ab5..4c09cc5 100644 --- a/src/Form/Service/ParticipantInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantInsuranceFieldHandler.php @@ -5,8 +5,7 @@ declare(strict_types=1); namespace App\Form\Service; use App\BusProNet\Model\Insurance; -use App\Form\Model\BookingCreateDto; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; use App\Service\InsuranceMatchingService; @@ -112,13 +111,13 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler * participant data and automatically reassigns to the correct price tier if needed. * * @param array $submittedData The submitted participant form data - * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) + * @param BookingDto $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 + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { $participant = $bookingDto->getParticipant($participantIndex); - if (null === $participant || !$bookingDto instanceof BookingCreateDto) { + if (null === $participant) { return; } @@ -206,12 +205,12 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler * 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 BookingDto $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 + public function getFieldStateModifications(array $submittedData, BookingDto $bookingDto, int $participantIndex): array { return []; } diff --git a/src/Form/Service/ParticipantLicensePlateFieldHandler.php b/src/Form/Service/ParticipantLicensePlateFieldHandler.php index 503186d..8acae80 100644 --- a/src/Form/Service/ParticipantLicensePlateFieldHandler.php +++ b/src/Form/Service/ParticipantLicensePlateFieldHandler.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -69,10 +69,10 @@ class ParticipantLicensePlateFieldHandler extends AbstractParticipantFieldHandle * the license plate is automatically cleared to maintain data consistency. * * @param array $submittedData The submitted participant form data - * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) + * @param BookingDto $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 + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { // Safely get the participant object, returning early if not found $participant = $this->getParticipant($bookingDto, $participantIndex); diff --git a/src/Form/Service/ParticipantParkingFieldHandler.php b/src/Form/Service/ParticipantParkingFieldHandler.php index e1fe7a0..84c2b01 100644 --- a/src/Form/Service/ParticipantParkingFieldHandler.php +++ b/src/Form/Service/ParticipantParkingFieldHandler.php @@ -7,7 +7,7 @@ namespace App\Form\Service; use App\BusProNet\Constants; use App\BusProNet\Model\Service; use App\BusProNet\Utility\DirectionMapper; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -46,7 +46,7 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler return true; // Always process for state changes } - public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { $participant = $this->getParticipant($bookingDto, $participantIndex); if (null === $participant) { @@ -95,11 +95,11 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler * Gets the first (and typically only) parking service for pricing calculation. * Returns null if no parking services are available. * - * @param BookingDtoInterface $bookingDto The booking DTO containing travel data + * @param BookingDto $bookingDto The booking DTO containing travel data * * @return Service|null The parking service object, or null if not found */ - private function findParkingService(BookingDtoInterface $bookingDto): ?Service + private function findParkingService(BookingDto $bookingDto): ?Service { $parkingServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true); diff --git a/src/Form/Service/ParticipantPickupFieldHandler.php b/src/Form/Service/ParticipantPickupFieldHandler.php index 0c315ff..e31a5b1 100644 --- a/src/Form/Service/ParticipantPickupFieldHandler.php +++ b/src/Form/Service/ParticipantPickupFieldHandler.php @@ -6,7 +6,7 @@ namespace App\Form\Service; use App\BusProNet\Model\Pickup; use App\BusProNet\Utility\DirectionMapper; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -35,7 +35,7 @@ class ParticipantPickupFieldHandler extends AbstractParticipantFieldHandler return true; // Always process to handle clearing pickup } - public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { $participant = $this->getParticipant($bookingDto, $participantIndex); if (null === $participant) { diff --git a/src/Form/Service/ParticipantRemarksRoomFieldHandler.php b/src/Form/Service/ParticipantRemarksRoomFieldHandler.php index 929ad18..a45beda 100644 --- a/src/Form/Service/ParticipantRemarksRoomFieldHandler.php +++ b/src/Form/Service/ParticipantRemarksRoomFieldHandler.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -41,10 +41,10 @@ class ParticipantRemarksRoomFieldHandler extends AbstractParticipantFieldHandler * the normalization of empty strings to null values. * * @param array $submittedData The submitted participant form data - * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) + * @param BookingDto $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 + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { // Safely get the participant object, returning early if not found $participant = $this->getParticipant($bookingDto, $participantIndex); diff --git a/src/Form/Service/ParticipantRentalInsuranceFieldHandler.php b/src/Form/Service/ParticipantRentalInsuranceFieldHandler.php index d8972c8..47e8641 100644 --- a/src/Form/Service/ParticipantRentalInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantRentalInsuranceFieldHandler.php @@ -6,7 +6,7 @@ namespace App\Form\Service; use App\BusProNet\Constants; use App\BusProNet\Model\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -74,10 +74,10 @@ class ParticipantRentalInsuranceFieldHandler extends AbstractParticipantFieldHan * is no longer appropriate for the participant's age, it is automatically cleared. * * @param array $submittedData The submitted participant form data - * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) + * @param BookingDto $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 + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { // Safely get the participant object, returning early if not found $participant = $this->getParticipant($bookingDto, $participantIndex); @@ -118,11 +118,11 @@ class ParticipantRentalInsuranceFieldHandler extends AbstractParticipantFieldHan * Gets the first (and typically only) rental insurance service. * Returns null if no rental insurance services are available. * - * @param BookingDtoInterface $bookingDto The booking DTO containing travel data + * @param BookingDto $bookingDto The booking DTO containing travel data * * @return Service|null The rental insurance service object, or null if not found */ - private function findRentalInsuranceService(BookingDtoInterface $bookingDto): ?Service + private function findRentalInsuranceService(BookingDto $bookingDto): ?Service { $rentalInsuranceServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true, true); diff --git a/src/Form/Service/ParticipantRentalsFieldHandler.php b/src/Form/Service/ParticipantRentalsFieldHandler.php index 730fdac..a51ba75 100644 --- a/src/Form/Service/ParticipantRentalsFieldHandler.php +++ b/src/Form/Service/ParticipantRentalsFieldHandler.php @@ -6,7 +6,7 @@ namespace App\Form\Service; use App\BusProNet\Constants; use App\BusProNet\Model\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -57,7 +57,7 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler return true; // Always process to handle deselection cases } - public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { $participant = $this->getParticipant($bookingDto, $participantIndex); if (null === $participant) { @@ -108,7 +108,7 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler private function filterValidServiceSelections( array $selectedServices, array $availableServices, - BookingDtoInterface $bookingDto, + BookingDto $bookingDto, int $participantIndex, ): array { $validSelections = []; @@ -129,7 +129,7 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler private function isServiceValidForParticipant( mixed $selectedService, array $availableServices, - BookingDtoInterface $bookingDto, + BookingDto $bookingDto, int $participantIndex, ): bool { $service = $this->findServiceInAvailableServices($selectedService, $availableServices); diff --git a/src/Form/Service/ParticipantSkiPassFieldHandler.php b/src/Form/Service/ParticipantSkiPassFieldHandler.php index 682247f..1a020e3 100644 --- a/src/Form/Service/ParticipantSkiPassFieldHandler.php +++ b/src/Form/Service/ParticipantSkiPassFieldHandler.php @@ -6,7 +6,7 @@ namespace App\Form\Service; use App\BusProNet\Constants; use App\BusProNet\Model\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -79,10 +79,10 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler * age or exceeds the travel date range, it is automatically cleared. * * @param array $submittedData The submitted participant form data - * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) + * @param BookingDto $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 + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { // Safely get the participant object, returning early if not found $participant = $this->getParticipant($bookingDto, $participantIndex); @@ -117,7 +117,7 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler * * @param mixed $selectedService The selected skipass to validate * @param array $availableServices Array of available skipasses - * @param BookingDtoInterface $bookingDto The booking DTO for context + * @param BookingDto $bookingDto The booking DTO for context * @param int $participantIndex The participant index for age evaluation * * @return bool True if the skipass is valid for the participant, false otherwise @@ -125,7 +125,7 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler private function isServiceValidForParticipant( mixed $selectedService, array $availableServices, - BookingDtoInterface $bookingDto, + BookingDto $bookingDto, int $participantIndex, ): bool { // Find the service in available services diff --git a/src/Form/Service/ParticipantTransportationInboundFieldHandler.php b/src/Form/Service/ParticipantTransportationInboundFieldHandler.php index 5cdcdfa..0241ae3 100644 --- a/src/Form/Service/ParticipantTransportationInboundFieldHandler.php +++ b/src/Form/Service/ParticipantTransportationInboundFieldHandler.php @@ -6,7 +6,7 @@ namespace App\Form\Service; use App\BusProNet\Model\Service; use App\BusProNet\Utility\DirectionMapper; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -41,7 +41,7 @@ class ParticipantTransportationInboundFieldHandler extends AbstractParticipantFi return true; // Always process to handle deselection cases } - public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { $participant = $this->getParticipant($bookingDto, $participantIndex); if (null === $participant) { diff --git a/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php b/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php index 21c1833..796c3eb 100644 --- a/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php +++ b/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php @@ -6,7 +6,7 @@ namespace App\Form\Service; use App\BusProNet\Model\Service; use App\BusProNet\Utility\DirectionMapper; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; /** @@ -41,7 +41,7 @@ class ParticipantTransportationOutboundFieldHandler extends AbstractParticipantF return true; // Always process to handle deselection cases } - public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { $participant = $this->getParticipant($bookingDto, $participantIndex); if (null === $participant) { diff --git a/src/Form/Service/ServiceAgeEvaluator.php b/src/Form/Service/ServiceAgeEvaluator.php index 8087168..2e48fc7 100644 --- a/src/Form/Service/ServiceAgeEvaluator.php +++ b/src/Form/Service/ServiceAgeEvaluator.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Form\Service; use App\BusProNet\Model\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; /** * Evaluates service availability based on participant age constraints. @@ -40,12 +40,12 @@ class ServiceAgeEvaluator * age at the time of travel, not their current age. * * @param Service $service The service to evaluate - * @param BookingDtoInterface $bookingDto The booking containing participant data + * @param BookingDto $bookingDto The booking containing participant data * @param int $participantIndex The index of the participant to evaluate * * @return bool True if the service is available for the participant */ - public function isServiceAvailableForParticipant(Service $service, BookingDtoInterface $bookingDto, int $participantIndex): bool + public function isServiceAvailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool { $participant = $bookingDto->getParticipant($participantIndex); diff --git a/src/Form/Service/Trait/FormTraversalTrait.php b/src/Form/Service/Trait/FormTraversalTrait.php index 327f2e9..2b40497 100644 --- a/src/Form/Service/Trait/FormTraversalTrait.php +++ b/src/Form/Service/Trait/FormTraversalTrait.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form\Service\Trait; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use Symfony\Component\Form\FormInterface; /** @@ -17,17 +17,17 @@ use Symfony\Component\Form\FormInterface; trait FormTraversalTrait { /** - * Gets the BookingDtoInterface from the root of the form tree. + * Gets the BookingDto from the root of the form tree. * * This helper method traverses up the form tree to find the root form - * and extracts the BookingDtoInterface data. This is shared logic used + * and extracts the BookingDto data. This is shared logic used * by both create and edit participant forms. * * @param FormInterface $form The form to start traversing from * - * @return BookingDtoInterface|null The booking DTO or null if not found + * @return BookingDto|null The booking DTO or null if not found */ - public function getBookingDtoFromForm(FormInterface $form): ?BookingDtoInterface + public function getBookingDtoFromForm(FormInterface $form): ?BookingDto { // Traverse up the form tree to get the root form's data $rootForm = $form; @@ -37,6 +37,6 @@ trait FormTraversalTrait $data = $rootForm->getData(); - return $data instanceof BookingDtoInterface ? $data : null; + return $data instanceof BookingDto ? $data : null; } } diff --git a/src/Model/InsuranceEligibilityCriteria.php b/src/Model/InsuranceEligibilityCriteria.php index 4f336c9..aabb382 100644 --- a/src/Model/InsuranceEligibilityCriteria.php +++ b/src/Model/InsuranceEligibilityCriteria.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Model; -use App\Form\Model\BookingCreateDto; +use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; /** @@ -22,7 +22,7 @@ readonly class InsuranceEligibilityCriteria public \DateTimeImmutable $bookingDate, public float $travelPrice, public int $travelDurationDays, - public BookingCreateDto $booking, + public BookingDto $booking, ) { } } diff --git a/src/Service/BookingPriceCalculatorService.php b/src/Service/BookingPriceCalculatorService.php index 3df563f..bb4a436 100644 --- a/src/Service/BookingPriceCalculatorService.php +++ b/src/Service/BookingPriceCalculatorService.php @@ -9,7 +9,7 @@ use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Room; use App\BusProNet\Model\Service; use App\Form\Model\BookingCreateDto; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; /** @@ -22,17 +22,18 @@ use App\Form\Model\ParticipantDto; class BookingPriceCalculatorService { public function __construct( - private readonly ParticipantEligibilityService $participantEligibilityService + private readonly ParticipantEligibilityService $participantEligibilityService, ) { } + /** * Calculates comprehensive pricing breakdown for a booking. * - * @param BookingDtoInterface $bookingDto The booking data to calculate pricing for + * @param BookingDto $bookingDto The booking data to calculate pricing for * * @return array{rooms: array, services: array, grandTotal: float} Complete pricing breakdown */ - public function getPricingBreakdown(BookingDtoInterface $bookingDto): array + public function getPricingBreakdown(BookingDto $bookingDto): array { $roomPricing = $this->calculateRoomPricing($bookingDto); $servicePricing = $this->calculateServicePricing($bookingDto); @@ -48,11 +49,11 @@ class BookingPriceCalculatorService /** * Calculates pricing for all selected rooms. * - * @param BookingDtoInterface $bookingDto The booking data containing room selections + * @param BookingDto $bookingDto The booking data containing room selections * * @return array Array of room pricing data with labels, quantities, and totals */ - public function calculateRoomPricing(BookingDtoInterface $bookingDto): array + public function calculateRoomPricing(BookingDto $bookingDto): array { $roomPricing = []; @@ -95,11 +96,11 @@ class BookingPriceCalculatorService * * Only includes services from eligible participants (those with available skipasses for their age). * - * @param BookingDtoInterface $bookingDto The booking data containing participants and their service selections + * @param BookingDto $bookingDto The booking data containing participants and their service selections * * @return array Array of service groups with each group containing services of the same subtype */ - public function calculateServicePricing(BookingDtoInterface $bookingDto): array + public function calculateServicePricing(BookingDto $bookingDto): array { $participants = $bookingDto->getParticipants(); @@ -132,11 +133,11 @@ class BookingPriceCalculatorService /** * Calculates the grand total for the entire booking. * - * @param BookingDtoInterface $bookingDto The booking data to calculate total for + * @param BookingDto $bookingDto The booking data to calculate total for * * @return float The grand total price */ - public function calculateGrandTotal(BookingDtoInterface $bookingDto): float + public function calculateGrandTotal(BookingDto $bookingDto): float { $roomTotal = $this->calculateRoomTotal($bookingDto); $serviceTotal = $this->calculateServiceTotal($bookingDto); @@ -147,7 +148,7 @@ class BookingPriceCalculatorService /** * Calculates total price for all rooms. */ - public function calculateRoomTotal(BookingDtoInterface $bookingDto): float + public function calculateRoomTotal(BookingDto $bookingDto): float { $roomPricing = $this->calculateRoomPricing($bookingDto); @@ -157,7 +158,7 @@ class BookingPriceCalculatorService /** * Calculates total price for all services. */ - public function calculateServiceTotal(BookingDtoInterface $bookingDto): float + public function calculateServiceTotal(BookingDto $bookingDto): float { $servicePricing = $this->calculateServicePricing($bookingDto); @@ -194,12 +195,12 @@ class BookingPriceCalculatorService * This method calculates the complete price breakdown for a single participant, * including their room allocation (full room price) and all selected services. * - * @param BookingDtoInterface $bookingDto The booking data containing all participants + * @param BookingDto $bookingDto The booking data containing all participants * @param int $participantIndex The index of the participant to calculate for * * @return float The total price for the specified participant */ - public function calculateIndividualParticipantPrice(BookingDtoInterface $bookingDto, int $participantIndex): float + public function calculateIndividualParticipantPrice(BookingDto $bookingDto, int $participantIndex): float { if (false === $bookingDto instanceof BookingCreateDto) { return 0.0; @@ -221,8 +222,8 @@ class BookingPriceCalculatorService } } - // Add service prices for this participant - $totalPrice += $this->calculateParticipantServiceTotal($participant); + // Add service prices for this participant (with booking context for bulk insurance) + $totalPrice += $this->calculateParticipantServiceTotal($participant, true, $bookingDto); return $totalPrice; } @@ -230,11 +231,11 @@ class BookingPriceCalculatorService /** * Calculates individual prices for all participants in a booking. * - * @param BookingDtoInterface $bookingDto The booking data containing all participants + * @param BookingDto $bookingDto The booking data containing all participants * * @return array Array indexed by participant index containing individual prices */ - public function calculateAllParticipantIndividualPrices(BookingDtoInterface $bookingDto): array + public function calculateAllParticipantIndividualPrices(BookingDto $bookingDto): array { if (false === $bookingDto instanceof BookingCreateDto) { return []; @@ -256,12 +257,12 @@ class BookingPriceCalculatorService * This method is used for insurance eligibility filtering to avoid circular dependency * where insurance selection affects travel price which affects insurance eligibility. * - * @param BookingDtoInterface $bookingDto The booking data containing all participants + * @param BookingDto $bookingDto The booking data containing all participants * @param int $participantIndex The index of the participant to calculate for * * @return float The total price for the specified participant excluding insurance */ - public function calculateIndividualParticipantPriceExcludingInsurance(BookingDtoInterface $bookingDto, int $participantIndex): float + public function calculateIndividualParticipantPriceExcludingInsurance(BookingDto $bookingDto, int $participantIndex): float { if (false === $bookingDto instanceof BookingCreateDto) { return 0.0; @@ -300,11 +301,11 @@ class BookingPriceCalculatorService * * Note: Transportation discounts will be handled generically by groupServicesBySubtype as "Beförderung - Rabatt" * - * @param BookingDtoInterface $bookingDto The booking data containing participants + * @param BookingDto $bookingDto The booking data containing participants * * @return array Array of transportation line items (Zustieg, Parkplatz) */ - private function aggregateTransportationServices(BookingDtoInterface $bookingDto): array + private function aggregateTransportationServices(BookingDto $bookingDto): array { $participants = $bookingDto->getParticipants(); @@ -527,12 +528,13 @@ class BookingPriceCalculatorService /** * Calculates the total service cost for a single participant. * - * @param ParticipantDto $participant The participant to calculate services for - * @param bool $includeInsurance Whether to include insurance pricing (default: true) + * @param ParticipantDto $participant The participant to calculate services for + * @param bool $includeInsurance Whether to include insurance pricing (default: true) + * @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance resolution * * @return float The total service cost for this participant */ - private function calculateParticipantServiceTotal(ParticipantDto $participant, bool $includeInsurance = true): float + private function calculateParticipantServiceTotal(ParticipantDto $participant, bool $includeInsurance = true, ?BookingDto $bookingDto = null): float { $serviceTotal = 0.0; @@ -545,8 +547,10 @@ class BookingPriceCalculatorService $serviceTotal += $participant->rentalInsurance->price; } - if ($includeInsurance && null !== $participant->insurance && null !== $participant->insurance->price) { - $serviceTotal += $participant->insurance->price; + // Get effective insurance (considering bulk insurance for dependent participants) + $effectiveInsurance = $this->getEffectiveInsurance($participant, $bookingDto); + if ($includeInsurance && null !== $effectiveInsurance && null !== $effectiveInsurance->price) { + $serviceTotal += $effectiveInsurance->price; } // Transportation services @@ -630,4 +634,40 @@ class BookingPriceCalculatorService $serviceAggregation[$serviceKey]['participantCount'] += $quantity; $serviceAggregation[$serviceKey]['totalPrice'] += $insurance->price * $quantity; } + + /** + * Gets the effective insurance for a participant, considering bulk insurance assignment. + * + * When bulk insurance is active and the participant is a dependent (index > 0), + * returns the applicant's insurance. Otherwise returns the participant's own insurance. + * + * This method is used for pricing calculations to show correct prices when bulk + * insurance is enabled, even though the actual assignment happens in the processor. + * + * @param ParticipantDto $participant The participant to get insurance for + * @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance check + * + * @return Insurance|null The effective insurance for pricing purposes + */ + private function getEffectiveInsurance(ParticipantDto $participant, ?BookingDto $bookingDto): ?Insurance + { + // If no booking context, use participant's own insurance + if (null === $bookingDto) { + return $participant->insurance; + } + + // Applicant always uses their own insurance + if (0 === $participant->index) { + return $participant->insurance; + } + + // Check if bulk insurance is active + $applicant = $bookingDto->getParticipant(0); + if (null === $applicant || false === $applicant->bulkInsuranceBooking) { + return $participant->insurance; + } + + // Bulk insurance is active - use applicant's insurance for dependent participants + return $applicant->insurance; + } } diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index a72b0d4..0f2641f 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -7,6 +7,7 @@ use App\BusProNet\Model\Travel; use App\Exception\BookingSessionNotFoundException; use App\Exception\NoRoomsAvailableException; use App\Form\Model\BookingCreateDto; +use App\Form\Model\BookingDto; use App\Form\Model\RoomSelectionDto; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -15,6 +16,7 @@ class BookingService { public const BOOKING_CREATE_KEY = 'booking_create'; public const BOOKING_CREATE_BASELINE_KEY = 'booking_create_baseline_snapshot'; + public const BOOKING_EDIT_KEY = 'booking_edit'; public function __construct( private readonly TravelDataService $travelDataService, @@ -81,6 +83,46 @@ class BookingService throw new BookingSessionNotFoundException(); } + /** + * Saves the booking DTO to the session. + * + * @param Request $request The HTTP request with session + * @param object $bookingDto The booking DTO to persist + * @param string $mode The booking mode (create/edit) + */ + public function saveBookingDto(Request $request, object $bookingDto, string $mode): void + { + $key = BookingDto::MODE_CREATE === $mode ? self::BOOKING_CREATE_KEY : self::BOOKING_EDIT_KEY; + $request->getSession()->set($key, $bookingDto); + } + + /** + * Retrieves the booking DTO from the session. + * + * @param Request $request The HTTP request containing session data + * @param string $mode The booking mode (create/edit) + * + * @return object|null The booking DTO from session or null if not found + */ + public function getBookingDto(Request $request, string $mode): ?object + { + $key = BookingDto::MODE_CREATE === $mode ? self::BOOKING_CREATE_KEY : self::BOOKING_EDIT_KEY; + + return $request->getSession()->get($key); + } + + /** + * Clears the booking DTO from the session. + * + * @param Request $request The HTTP request with session + * @param string $mode The booking mode (create/edit) + */ + public function clearBookingDto(Request $request, string $mode): void + { + $key = BookingDto::MODE_CREATE === $mode ? self::BOOKING_CREATE_KEY : self::BOOKING_EDIT_KEY; + $request->getSession()->remove($key); + } + /** * Saves the booking creation DTO to the session. * @@ -92,7 +134,7 @@ class BookingService */ public function saveBookingCreateDto(Request $request, BookingCreateDto $bookingCreateDto): void { - $request->getSession()->set(self::BOOKING_CREATE_KEY, $bookingCreateDto); + $this->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); } /** @@ -209,10 +251,10 @@ class BookingService * * @return array an array where the key is the room ID and the value is the count of assigned participants */ - public function getRoomAssignmentCounts(BookingCreateDto $bookingCreateDto): array + public function getRoomAssignmentCounts(BookingDto $bookingDto): array { $counts = []; - foreach ($bookingCreateDto->participants as $participant) { + foreach ($bookingDto->getParticipants() as $participant) { if (null !== $participant->assignedRoomId) { $counts[$participant->assignedRoomId] = ($counts[$participant->assignedRoomId] ?? 0) + 1; } @@ -226,11 +268,18 @@ class BookingService * * @return array{selectedRooms: array, participantCount: int, pricing: array} */ - public function getRoomSummaryAndParticipantCount(BookingCreateDto $bookingCreateDto): array + public function getRoomSummaryAndParticipantCount(BookingDto $bookingDto): array { - $selectedRooms = $bookingCreateDto->getSelectedRooms(); - $participantCount = $this->getParticipantsCount($selectedRooms, $bookingCreateDto->travel); - $pricing = $this->priceCalculator->getPricingBreakdown($bookingCreateDto); + $selectedRooms = $bookingDto->getSelectedRooms(); + + // In edit mode, count actual participants; in create mode, calculate from room selections + if (BookingDto::MODE_EDIT === $bookingDto->getMode()) { + $participantCount = count($bookingDto->getParticipants()); + } else { + $participantCount = $this->getParticipantsCount($selectedRooms, $bookingDto->travel); + } + + $pricing = $this->priceCalculator->getPricingBreakdown($bookingDto); return [ 'selectedRooms' => $selectedRooms, diff --git a/src/Service/InsuranceMatchingService.php b/src/Service/InsuranceMatchingService.php index 0f39068..3b4a2ff 100644 --- a/src/Service/InsuranceMatchingService.php +++ b/src/Service/InsuranceMatchingService.php @@ -7,6 +7,7 @@ namespace App\Service; use App\BusProNet\Model\Insurance; use App\BusProNet\Traits\SortByPriceTrait; use App\Form\Model\BookingCreateDto; +use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Model\InsuranceEligibilityCriteria; use Carbon\Carbon; @@ -21,6 +22,7 @@ use Carbon\Carbon; class InsuranceMatchingService { use SortByPriceTrait; + public function __construct( private readonly BookingPriceCalculatorService $priceCalculatorService, ) { @@ -29,13 +31,13 @@ class InsuranceMatchingService /** * Filters insurances based on participant and booking criteria. * - * @param array $insurances Available insurances to filter - * @param ParticipantDto $participant The participant to match insurances for - * @param BookingCreateDto $booking The booking context for additional criteria + * @param array $insurances Available insurances to filter + * @param ParticipantDto $participant The participant to match insurances for + * @param BookingDto $booking The booking context for additional criteria * * @return array Filtered array of eligible insurances */ - public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingCreateDto $booking): array + public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingDto $booking): array { $criteria = $this->createEligibilityCriteria($participant, $booking); @@ -58,14 +60,14 @@ class InsuranceMatchingService * current insurance is no longer eligible. It finds the same insurance type * (subType + familyInsurance) with the correct price tier. * - * @param array $availableInsurances All available insurances - * @param Insurance $currentInsurance The currently selected insurance - * @param ParticipantDto $participant The participant to reassign for - * @param BookingCreateDto $booking The booking context + * @param array $availableInsurances All available insurances + * @param Insurance $currentInsurance The currently selected insurance + * @param ParticipantDto $participant The participant to reassign for + * @param BookingDto $booking The booking context * * @return Insurance|null The reassigned insurance or null if no suitable match found */ - public function reassignInsuranceForPriceChange(array $availableInsurances, Insurance $currentInsurance, ParticipantDto $participant, BookingCreateDto $booking): ?Insurance + public function reassignInsuranceForPriceChange(array $availableInsurances, Insurance $currentInsurance, ParticipantDto $participant, BookingDto $booking): ?Insurance { // Group insurances of the same type $sameTypeInsurances = $this->filterInsurancesByType($availableInsurances, $currentInsurance); @@ -88,15 +90,15 @@ class InsuranceMatchingService * (subType + familyInsurance) to all participants, but selects the appropriate price tier * based on each participant's individual travel price. * - * @param array $availableInsurances All available insurances - * @param Insurance $selectedInsurance The insurance selected by the applicant - * @param BookingCreateDto $booking The booking with all participants + * @param array $availableInsurances All available insurances + * @param Insurance $selectedInsurance The insurance selected by the applicant + * @param BookingDto $booking The booking with all participants * * @return array Array indexed by participant index with assigned insurances * * @internal Reserved for future feature implementation */ - public function batchAssignInsuranceToParticipants(array $availableInsurances, Insurance $selectedInsurance, BookingCreateDto $booking): array + public function batchAssignInsuranceToParticipants(array $availableInsurances, Insurance $selectedInsurance, BookingDto $booking): array { $assignments = []; @@ -118,7 +120,7 @@ class InsuranceMatchingService * This method performs early validation before creating the criteria object * to avoid unnecessary object instantiation when criteria cannot be satisfied. */ - private function createEligibilityCriteria(ParticipantDto $participant, BookingCreateDto $booking): ?InsuranceEligibilityCriteria + private function createEligibilityCriteria(ParticipantDto $participant, BookingDto $booking): ?InsuranceEligibilityCriteria { // Early return if travel dates are missing - cannot evaluate any criteria $travelStartDate = $booking->travel->dateFrom; @@ -183,8 +185,13 @@ class InsuranceMatchingService * Family insurances should only be available for family bookings, * and individual insurances should only be available for non-family bookings. */ - private function checkFamilyInsuranceConstraints(Insurance $insurance, BookingCreateDto $booking): bool + private function checkFamilyInsuranceConstraints(Insurance $insurance, BookingDto $booking): bool { + // Family booking detection only available in BookingCreateDto + if (!$booking instanceof BookingCreateDto) { + return true; // Skip family constraints for edit mode + } + $isFamilyBooking = $booking->isFamilyBooking(); // If it's a family insurance, it should only be available for family bookings @@ -308,12 +315,12 @@ class InsuranceMatchingService * It excludes insurance prices to prevent circular dependency where insurance selection * affects travel price which then affects insurance eligibility. * - * @param BookingCreateDto $booking The booking to calculate price for - * @param int $participantIndex The participant index to calculate for + * @param BookingDto $booking The booking to calculate price for + * @param int $participantIndex The participant index to calculate for * * @return float The total travel price for the participant excluding insurance */ - private function calculateTravelPrice(BookingCreateDto $booking, int $participantIndex): float + private function calculateTravelPrice(BookingDto $booking, int $participantIndex): float { // Use the price calculator to get the participant's individual price excluding insurance return $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($booking, $participantIndex); @@ -343,7 +350,7 @@ class InsuranceMatchingService * Example: "Reise-Rücktritt + Selbstbehaltübernahme" at €10, €14, €26 are the same type, * but different from "Reiseschutz Platin Auto/Bahn/Bus (Europa) + Selbstbehaltübernahme". * - * @param array $insurances All available insurances to filter + * @param array $insurances All available insurances to filter * @param Insurance $referenceInsurance The insurance to match against * * @return array Filtered insurances of the same type diff --git a/src/Service/ParticipantEligibilityService.php b/src/Service/ParticipantEligibilityService.php index 0e8fa08..a839096 100644 --- a/src/Service/ParticipantEligibilityService.php +++ b/src/Service/ParticipantEligibilityService.php @@ -6,7 +6,7 @@ namespace App\Service; use App\BusProNet\Constants; use App\BusProNet\Model\Service; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use Carbon\CarbonImmutable; use Spatie\Blink\Blink; @@ -31,12 +31,12 @@ class ParticipantEligibilityService * * Results are cached per request to avoid redundant calculations. * - * @param BookingDtoInterface $bookingDto The current booking data + * @param BookingDto $bookingDto The current booking data * @param int $participantIndex The index of the participant being evaluated * * @return bool True if participant is eligible (has available skipasses) */ - public function isParticipantEligible(BookingDtoInterface $bookingDto, int $participantIndex): bool + public function isParticipantEligible(BookingDto $bookingDto, int $participantIndex): bool { $participant = $bookingDto->getParticipant($participantIndex); @@ -66,7 +66,7 @@ class ParticipantEligibilityService /** * Checks if a skipass service is available for the given participant based on age constraints. */ - private function isSkiPassAvailableForParticipant(Service $service, BookingDtoInterface $bookingDto, int $participantIndex): bool + private function isSkiPassAvailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool { $participant = $bookingDto->getParticipant($participantIndex); diff --git a/src/Service/TravelDataService.php b/src/Service/TravelDataService.php index dc74014..b49afaa 100644 --- a/src/Service/TravelDataService.php +++ b/src/Service/TravelDataService.php @@ -121,7 +121,7 @@ class TravelDataService 'error' => $e->getMessage(), ]); throw $e; - } catch (HotelNotFoundException | HotelNotInTravelException $e) { + } catch (HotelNotFoundException|HotelNotInTravelException $e) { $this->logger->debug('Hotel not found in XML', [ 'dateId' => $dateId, 'hotelId' => $hotelId, @@ -433,7 +433,44 @@ class TravelDataService * * @return BaseData|null The mutability data or null if not available or error occurred */ - public function getMutabilityData(int $dateId): ?BaseData + /** + * Gets mutability data for a travel date. + * + * @param int $dateId The travel date ID for API call + * @param bool $cached Whether to use cached data (default: true, TTL: 12 hours) + * + * @return BaseData|null The mutability data or null if not available or error occurred + */ + public function getMutabilityData(int $dateId, bool $cached = true): ?BaseData + { + if ($cached) { + $cacheKey = sprintf('mutability_%d', $dateId); + + try { + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($dateId) { + // 12 hours TTL - mutability dates have date-only granularity + $item->expiresAfter(43200); + + return $this->fetchMutabilityData($dateId); + }); + } catch (InvalidArgumentException $e) { + $this->logger->error('Cache error in getMutabilityData', [ + 'dateId' => $dateId, + 'error' => $e->getMessage(), + ]); + + // Fallback to direct API call + return $this->fetchMutabilityData($dateId); + } + } + + return $this->fetchMutabilityData($dateId); + } + + /** + * Fetches mutability data directly from the API without caching. + */ + private function fetchMutabilityData(int $dateId): ?BaseData { try { $mutableData = $this->apiClient->getMutableData($dateId); @@ -482,16 +519,44 @@ class TravelDataService } /** - * Fetch availability data from API. + * Gets availability data for a travel date. * - * Retrieves availability information from the API for a specific travel date. - * Handles API errors and notification responses gracefully. - * - * @param int $dateId The travel date ID for API call + * @param int $dateId The travel date ID for API call + * @param bool $cached Whether to use cached data (default: false, TTL when cached: 60 seconds) + * @param int $ttl Cache TTL in seconds when $cached is true (default: 60 seconds) * * @return BaseData|null The availability data or null if not available or error occurred */ - public function getAvailabilityData(int $dateId): ?BaseData + public function getAvailabilityData(int $dateId, bool $cached = false, int $ttl = 60): ?BaseData + { + if ($cached) { + $cacheKey = sprintf('availability_%d', $dateId); + + try { + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($dateId, $ttl) { + // Short TTL - availability is volatile and changes with bookings + $item->expiresAfter($ttl); + + return $this->fetchAvailabilityData($dateId); + }); + } catch (InvalidArgumentException $e) { + $this->logger->error('Cache error in getAvailabilityData', [ + 'dateId' => $dateId, + 'error' => $e->getMessage(), + ]); + + // Fallback to direct API call + return $this->fetchAvailabilityData($dateId); + } + } + + return $this->fetchAvailabilityData($dateId); + } + + /** + * Fetches availability data directly from the API without caching. + */ + private function fetchAvailabilityData(int $dateId): ?BaseData { try { $availabilities = $this->apiClient->getAvailabilities($dateId); @@ -524,9 +589,7 @@ class TravelDataService /** * Fetch availability data with short-term caching. * - * Retrieves availability information from the API with caching to reduce - * API calls during booking form interactions. Uses a short TTL to ensure - * reasonably fresh data while avoiding excessive API requests. + * @deprecated Use getAvailabilityData($dateId, cached: true) instead * * @param int $dateId The travel date ID for API call * @param int $ttl Cache TTL in seconds (default: 60 seconds) @@ -535,23 +598,7 @@ class TravelDataService */ public function getAvailabilityDataCached(int $dateId, int $ttl = 60): ?BaseData { - $cacheKey = sprintf('availability_%d', $dateId); - - try { - return $this->cache->get($cacheKey, function (ItemInterface $item) use ($dateId, $ttl) { - $item->expiresAfter($ttl); - - return $this->getAvailabilityData($dateId); - }); - } catch (InvalidArgumentException $e) { - $this->logger->error('Cache error in getAvailabilityDataCached', [ - 'dateId' => $dateId, - 'error' => $e->getMessage(), - ]); - - // Fallback to direct API call - return $this->getAvailabilityData($dateId); - } + return $this->getAvailabilityData($dateId, cached: true, ttl: $ttl); } /** @@ -606,7 +653,8 @@ class TravelDataService { try { $insurances = $this->insuranceLoader->loadAll(); - $travel->insurances = array_values($insurances); + // Keep insurances indexed by ID for efficient lookups + $travel->insurances = $insurances; // Hydrate package relationships after loading // Packages lose their containedInsurances during serialization, so rebuild them diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index be8b713..c98bdab 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -3,7 +3,7 @@ namespace App\Twig; use App\BusProNet\DataProvider\CountryDataProvider; -use App\Form\Model\BookingDtoInterface; +use App\Form\Model\BookingDto; use App\Service\ParticipantEligibilityService; use Twig\Environment; use Twig\Extension\RuntimeExtensionInterface; @@ -97,7 +97,7 @@ class AppRuntime implements RuntimeExtensionInterface return $this->countryDataProvider->get($nationality)?->nationality; } - public function isParticipantEligible(BookingDtoInterface $bookingDto, int $participantIndex): bool + public function isParticipantEligible(BookingDto $bookingDto, int $participantIndex): bool { return $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex); } diff --git a/src/Validator/Constraints/ApplicantAddress.php b/src/Validator/Constraints/ApplicantAddress.php new file mode 100644 index 0000000..b4de799 --- /dev/null +++ b/src/Validator/Constraints/ApplicantAddress.php @@ -0,0 +1,16 @@ +isApplicant()) { + return; + } + + // Ensure address object exists + if (null === $participant->address) { + return; + } + + // Validate required address fields + if (null === $participant->address->street || '' === trim($participant->address->street)) { + $this->context->buildViolation('Bitte angeben') + ->atPath('address.street') + ->addViolation() + ; + } + + if (null === $participant->address->postCode || '' === trim($participant->address->postCode)) { + $this->context->buildViolation('Bitte angeben') + ->atPath('address.postCode') + ->addViolation() + ; + } + + if (null === $participant->address->city || '' === trim($participant->address->city)) { + $this->context->buildViolation('Bitte angeben') + ->atPath('address.city') + ->addViolation() + ; + } + + if (null === $participant->address->country || '' === trim($participant->address->country)) { + $this->context->buildViolation('Bitte angeben') + ->atPath('address.country') + ->addViolation() + ; + } + + // Mobile/phone is mandatory for applicant in create mode + if (null === $participant->mobile || '' === trim($participant->mobile)) { + $this->context->buildViolation('Bitte angeben') + ->atPath('mobile') + ->addViolation() + ; + } + } +} \ No newline at end of file diff --git a/src/Validator/Constraints/ParticipantValidator.php b/src/Validator/Constraints/ParticipantValidator.php index 4224fa2..36e9c8b 100644 --- a/src/Validator/Constraints/ParticipantValidator.php +++ b/src/Validator/Constraints/ParticipantValidator.php @@ -16,7 +16,6 @@ class ParticipantValidator extends ConstraintValidator $this->assertBodyMeasurementsValid($participant); $this->assertTransportationSelected($participant); $this->assertPickupSelected($participant); - $this->assertApplicantAddressValid($participant); } public function assertBodyMeasurementsValid(ParticipantDto $participant): void @@ -68,49 +67,4 @@ class ParticipantValidator extends ConstraintValidator ; } } - - public function assertApplicantAddressValid(ParticipantDto $participant): void - { - // Address is only mandatory for the applicant (first participant) - if (false === $participant->isApplicant()) { - return; - } - - // Check each required address field for applicant - if (null === $participant->address->street || '' === trim($participant->address->street)) { - $this->context->buildViolation('Bitte angeben') - ->atPath('address.street') - ->addViolation() - ; - } - - if (null === $participant->address->postCode || '' === trim($participant->address->postCode)) { - $this->context->buildViolation('Bitte angeben') - ->atPath('address.postCode') - ->addViolation() - ; - } - - if (null === $participant->address->city || '' === trim($participant->address->city)) { - $this->context->buildViolation('Bitte angeben') - ->atPath('address.city') - ->addViolation() - ; - } - - if (null === $participant->address->country || '' === trim($participant->address->country)) { - $this->context->buildViolation('Bitte angeben') - ->atPath('address.country') - ->addViolation() - ; - } - - // Mobile/phone is mandatory for applicant - if (null === $participant->mobile || '' === trim($participant->mobile)) { - $this->context->buildViolation('Bitte angeben') - ->atPath('mobile') - ->addViolation() - ; - } - } } diff --git a/templates/booking/_summary.html.twig b/templates/booking/_summary.html.twig index faf1666..4ce9634 100644 --- a/templates/booking/_summary.html.twig +++ b/templates/booking/_summary.html.twig @@ -1,6 +1,48 @@

Buchungsübersicht

+ {# Mutability information (edit mode only) #} + {% if mutableData is defined %} +
+

Aktualisierung möglich bis

+
    +
  • + + + Zusatzleistungen: + {% if mutableData.items['additional_services'].mutable %} + {{ mutableData.items['additional_services'].mutableBefore|date('d.m.Y') }} + {% else %} + nicht mehr möglich + {% endif %} + +
  • +
  • + + + Beförderung: + {% if mutableData.items['transportation'].mutable %} + {{ mutableData.items['transportation'].mutableBefore|date('d.m.Y') }} + {% else %} + nicht mehr möglich + {% endif %} + +
  • +
  • + + + Zustiege: + {% if mutableData.items['pickup'].mutable %} + {{ mutableData.items['pickup'].mutableBefore|date('d.m.Y') }} + {% else %} + nicht mehr möglich + {% endif %} + +
  • +
+
+ {% endif %} + {# Travel Information #}

Reiseinformationen

diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index 5af0030..e9a94d9 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -71,7 +71,7 @@
- Teilnehmer:in {{ loop.index }} + {{ loop.index == 1 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ loop.index }} {% if isEligible and participantPrices is defined and participantPrices[loop.index0] is defined and participantPrices[loop.index0] > 0.0 %} €{{ participantPrices[loop.index0]|number_format(2, ',', '.') }} @@ -213,10 +213,12 @@
Reiseversicherung
- {% if participantData.insurance %} - {{ participantData.insurance.label }} - {% if participantData.insurance.price and participantData.insurance.price > 0 %} - (€{{ participantData.insurance.price|number_format(2, ',', '.') }}) + {% set applicantInsurance = form.vars.data.participants[0].insurance %} + {% if applicantInsurance %} + {{ applicantInsurance.label }} + {% set participantPrice = participantPrices[loop.index0] %} + {% if participantPrice.insurance and participantPrice.insurance > 0 %} + (€{{ participantPrice.insurance|number_format(2, ',', '.') }}) {% endif %} – wie Anmelder {% else %} diff --git a/templates/booking/edit.html.twig b/templates/booking/edit.html.twig index 88dbf0a..1b3c72c 100644 --- a/templates/booking/edit.html.twig +++ b/templates/booking/edit.html.twig @@ -53,104 +53,7 @@ {% block content %} {% include '_partials/_flashes.html.twig' %}
-

Buchung bearbeiten

- - {# Booking metadata section #} -
-
-
- Reisedaten - - - -
-
-
-

- Reise -

- {{ bookingData.travelName }} -
-
-

- Reisezeitraum -

- {{ travelData.dateFrom|date('d.m.Y') }} - {{ travelData.dateTo|date('d.m.Y') }} -
-
-

- Buchungsdatum -

- {{ bookingData.bookingDate|date('d.m.Y') }} -
-
-

- Vorgangsnummer -

- {{ bookingData.bookingNumber }} -
-
-

- Rechnungsnummer -

- {{ bookingData.invoiceNumber }} -
-
-

- Anmelder:in -

- {{ bookingData.applicant.firstName }} {{ bookingData.applicant.name }} -
-
-

- Anzahl Teilnehmer:innen -

- {{ bookingData.participants|length }} -
-
-

- Gesamtpreis -

- {{ bookingData.calculatedTotalPrice|format_currency('EUR') }} -
-
-

- Inklusivleistungen -

- {% if travelData.includedServices | length == 0 %} - - - {% else %} -
    - {% for selectionGroup in travelData.includedServices %} - {% for service in selectionGroup.selections %} -
  • - {{ service.label }} -
  • - {% endfor %} - {% endfor %} -
- {% endif %} -
-
-

- Aktualisierung möglich bis -

-
    -
  • - Zusatzleistungen: {% if mutableData.items['additional_services'].mutable %}{{ mutableData.items['additional_services'].mutableBefore|date('d.m.Y') }}{% else %}nicht mehr möglich{% endif %} -
  • -
  • - Beförderung: {% if mutableData.items['transportation'].mutable %}{{ mutableData.items['transportation'].mutableBefore|date('d.m.Y') }}{% else %}nicht mehr möglich{% endif %} -
  • -
  • - Zustiege: {% if mutableData.items['pickup'].mutable %}{{ mutableData.items['pickup'].mutableBefore|date('d.m.Y') }}{% else %}nicht mehr möglich{% endif %} -
  • -
-
-
-
-
+

Buchung bearbeiten

{# Grid layout with 2/3 form + 1/3 summary #}
@@ -172,7 +75,7 @@
- Teilnehmer:in {{ loop.index }} + {{ loop.index == 1 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ loop.index }} {% if isCanceled %} storniert {% endif %} @@ -207,7 +110,7 @@
{% endif %} {% else %} - {# Personal data section #} + {# Personal data section - readonly for applicant (edited via profile settings) #}
{{ form_row(participant.firstName) }} {{ form_row(participant.lastName) }} @@ -223,8 +126,29 @@
{{ form_row(participant.email) }} - {{ form_row(participant.mobile) }} + {% if loop.index0 == 0 and bookingData.applicant.communication and bookingData.applicant.communication.mobile %} + {# First participant: use applicant's mobile as placeholder #} + {{ form_row(participant.mobile, {'attr': {'placeholder': bookingData.applicant.communication.mobile}}) }} + {% else %} + {{ form_row(participant.mobile) }} + {% endif %}
+ {% if participant.address is defined %} +
+ {% if loop.index0 == 0 and bookingData.applicant.address %} + {# First participant: use applicant's address as placeholder #} + {{ form_row(participant.address.street, {'attr': {'placeholder': bookingData.applicant.address.street}}) }} + {{ form_row(participant.address.postCode, {'attr': {'placeholder': bookingData.applicant.address.postCode}}) }} + {{ form_row(participant.address.city, {'attr': {'placeholder': bookingData.applicant.address.city}}) }} + {{ form_row(participant.address.country, {'attr': {'placeholder': bookingData.applicant.address.country}}) }} + {% else %} + {{ form_row(participant.address.street) }} + {{ form_row(participant.address.postCode) }} + {{ form_row(participant.address.city) }} + {{ form_row(participant.address.country) }} + {% endif %} +
+ {% endif %} {% if participant.bodyDimensions is defined %}
{{ form_row(participant.bodyDimensions.height) }} @@ -317,6 +241,54 @@ } }) }} +
+ {# Insurance field OR assigned insurance display for dependent participants #} + {% set participantData = participant.vars.data %} + {% set showBulkInsurance = loop.index > 1 and form.vars.data.participants[0].bulkInsuranceBooking %} + + {% if showBulkInsurance %} +
+ Reiseversicherung +
+ {% set applicantInsurance = form.vars.data.participants[0].insurance %} + {% if applicantInsurance %} + wie Anmelder: {{ applicantInsurance.label }} + {% else %} + wie Anmelder + {% endif %} +
+
+ {% else %} +
+ Reiseversicherung + + {# Bulk insurance booking checkbox (applicant only) #} + {% if participant.bulkInsuranceBooking is defined %} + {{ form_row(participant.bulkInsuranceBooking, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}), + 'hx-swap': 'none' + } + }) }} + {% endif %} + + {% if participant.insurance is defined %} + {{ form_row(participant.insurance, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}), + 'hx-swap': 'none' + }, + 'label': false + }) }} + {% else %} +
Nicht wählbar
+ {% endif %} +
+ {% endif %} +
+

Hin-/Rückreise

@@ -401,7 +373,8 @@ 'bookingCreateDto': bookingEditDto, 'participantCount': participantCount, 'groupedSelectedRooms': groupedSelectedRooms, - 'assignmentCounts': assignmentCounts + 'assignmentCounts': assignmentCounts, + 'mutableData': mutableData|default(null) } %}
{% endblock %}