fieldOptionProviders['newField'] = function (BookingDto $bookingDto, int $participantIndex): array { * $choices = $this->getChoicesFor($bookingDto, $participantIndex); * * if (true === empty($choices)) { * return []; * } * * return [ * 'label' => 'New Field Label', * 'choices' => $choices, * ]; * }; * * Provider Pattern Benefits: * - Lazy evaluation (options only generated when needed) * - Context-aware configuration * - Easy to test individual field logic * - Supports complex interdependencies * - Unified empty-check pattern for all field types */ protected function registerFieldOptionProviders(): void { // Room assignment field provider (available for both create and edit workflows) // Returns empty array when no room choices available $this->fieldOptionProviders['assignedRoomId'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $choices = []; if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { // Create context: use selected rooms from step 1, filtered by participant age $choices = $this->filterRoomsByParticipantAge( $bookingDto->getSelectedRooms(), $bookingDto, $participantIndex ); } elseif (BookingDto::MODE_EDIT === $bookingDto->getMode()) { // Edit context: convert booked rooms to RoomSelectionDto for consistent handling $choices = $this->convertBookedRoomsToSelectionDtos($bookingDto->booking->rooms); } if (true === empty($choices)) { return []; } $singleChoice = 1 === count($choices); return [ 'label' => 'Zimmer', 'expanded' => true, 'multiple' => false, // No placeholder when only one room available - the only option is pre-selected 'placeholder' => $singleChoice ? false : 'Nicht zugeordnet', 'choices' => $choices, 'choice_value' => fn (RoomSelectionDto|int|null $room) => $room instanceof RoomSelectionDto ? $room->id : $room, 'choice_label' => fn (?RoomSelectionDto $room) => $room?->label, ]; }; // Courses field provider - provides age-appropriate courses from travel data // Returns empty array when no courses available $this->fieldOptionProviders['courses'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $choices = $this->filterServicesByAgeConstraints( $this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_COURSES), $bookingDto, $participantIndex ); if (true === empty($choices)) { return []; } return [ 'label' => 'Kurse', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $choices, 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $service?->label, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } $attributes = []; // Add service description as data attribute for frontend use if (null !== $service->description && '' !== trim($service->description)) { $attributes['data-description'] = $service->description; } // Make readonly if service is unavailable (intelligently handles edit mode) if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'courses')) { $attributes['readonly'] = true; $attributes['data-tooltip'] = 'ausgebucht'; } return $attributes; }, ]; }; // Additional services field provider - provides age-appropriate additional services with mandatory pre-selection // Returns empty array when no additional services available $this->fieldOptionProviders['additionalServices'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $choices = $this->filterServicesByAgeConstraints( $this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_ADDITIONAL), $bookingDto, $participantIndex ); if (true === empty($choices)) { return []; } return [ 'label' => 'Zusatzleistungen', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $choices, 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $service?->label, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } $attributes = []; // Make mandatory services readonly only when actually selected by participant. // Unselected mandatory services remain interactive so the user can select them // after age changes (e.g. agency skeleton bookings where DOB is corrected). if (true === $service->mandatory) { $participant = $bookingDto->getParticipant($participantIndex); $isSelected = null !== $participant && $this->hasServiceById($participant->additionalServices, $service->id); if (true === $isSelected) { $attributes['readonly'] = true; $attributes['data-tooltip'] = 'Diese Leistung ist nicht abwählbar'; } } // Add service description as data attribute for frontend use if (null !== $service->description && '' !== trim($service->description)) { $attributes['data-description'] = $service->description; } // Make readonly if service is unavailable (only if not already mandatory) if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'additionalServices')) { $attributes['readonly'] = true; $attributes['data-tooltip'] = 'ausgebucht'; } return $attributes; }, ]; }; // Board field provider - provides age-appropriate board options from travel data // Returns empty array when no board options available $this->fieldOptionProviders['board'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $choices = $this->filterServicesByAgeConstraints( $this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_BOARD), $bookingDto, $participantIndex ); if (true === empty($choices)) { return []; } return [ 'label' => 'Verpflegung', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $choices, 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $service?->label, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } $attributes = []; // Make mandatory services readonly only when actually selected by participant. if (true === $service->mandatory) { $participant = $bookingDto->getParticipant($participantIndex); $isSelected = null !== $participant && $this->hasServiceById($participant->board, $service->id); if (true === $isSelected) { $attributes['readonly'] = true; $attributes['data-tooltip'] = 'Diese Leistung ist nicht abwählbar'; } } // Add service description as data attribute for frontend use if (null !== $service->description && '' !== trim($service->description)) { $attributes['data-description'] = $service->description; } // Make readonly if service is unavailable (only if not already mandatory) if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'board')) { $attributes['readonly'] = true; $attributes['data-tooltip'] = 'ausgebucht'; } return $attributes; }, ]; }; // Veg (vegetarian/vegan) field provider - provides dietary preference options as dropdown // Returns empty array when no dietary options available $this->fieldOptionProviders['veg'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $choices = $this->filterServicesByAgeConstraints( $this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_VEG, true), $bookingDto, $participantIndex ); if (true === empty($choices)) { return []; } return [ 'label' => 'Verpflegungswunsch', 'multiple' => false, 'expanded' => false, 'required' => false, 'placeholder' => 'Verpflegungswunsch...', 'choices' => $choices, 'choice_value' => 'id', 'choice_label' => function (?Service $service): ?string { if (null === $service) { return null; } $priceLabel = (null === $service->price || 0.0 === $service->price) ? 'inkl.' : number_format($service->price, 2, ',', '.').' €'; return $service->label.' ('.$priceLabel.')'; }, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } $attributes = []; // Add service description as data attribute for frontend use if (null !== $service->description && '' !== trim($service->description)) { $attributes['data-description'] = $service->description; } // Check age restriction first (takes precedence over availability) $ageEvaluator = new ServiceAgeEvaluator(); if ($ageEvaluator->canEvaluate($service) && false === $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex)) { $attributes['readonly'] = true; $attributes['data-tooltip'] = $this->getAgeRestrictionTooltip($service); return $attributes; } // Make readonly if service is unavailable (intelligently handles edit mode) if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'veg')) { $attributes['readonly'] = true; $attributes['data-tooltip'] = 'ausgebucht'; } return $attributes; }, ]; }; // Rentals field provider - provides age-appropriate rental options filtered by selected skipass duration // Returns empty array when no rental options available $this->fieldOptionProviders['rentals'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $choices = $this->filterServicesByAgeConstraints( $this->filterRentalsBySkiPassDuration( $this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_RENTALS, true), $bookingDto, $participantIndex ), $bookingDto, $participantIndex ); if (true === empty($choices)) { return []; } return [ 'label' => 'Leihmaterial', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $choices, 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $service?->label, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } $attributes = []; // Make mandatory services readonly only when actually selected by participant. if (true === $service->mandatory) { $participant = $bookingDto->getParticipant($participantIndex); $isSelected = null !== $participant && $this->hasServiceById($participant->rentals, $service->id); if (true === $isSelected) { $attributes['readonly'] = true; $attributes['data-tooltip'] = 'Diese Leistung ist nicht abwählbar'; } } // Add service description as data attribute for frontend use if (null !== $service->description && '' !== trim($service->description)) { $attributes['data-description'] = $service->description; } // Make readonly if service is unavailable (only if not already mandatory) if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'rentals')) { $attributes['readonly'] = true; $attributes['data-tooltip'] = 'ausgebucht'; } return $attributes; }, ]; }; // Rental insurance field provider - provides rental insurance options when rental services are selected $this->fieldOptionProviders['rentalInsurance'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => $this->getRentalInsuranceCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true)), 'required' => false, 'property_path' => 'rentalInsuranceSelected', 'help' => $this->getRentalInsuranceDescription($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true)), ]; // License plate field provider - provides text input for vehicle license plate when parking is selected $this->fieldOptionProviders['licensePlate'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Kennzeichen', 'required' => false, 'attr' => [ 'placeholder' => 'z.B. AB-CD 123', 'maxlength' => 20, ], 'help' => 'Bitte gib das Kennzeichen deines Fahrzeugs an. Du kannst es aber auch später nachreichen.', 'help_attr' => [ 'class' => 'text-sm px-2 mt-2', ], 'sanitize_html' => true, ]; // Skipass field provider - provides age-appropriate skipass options from travel data filtered by date range // Returns empty array when no skipass options available $this->fieldOptionProviders['skiPass'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $choices = $this->filterSkiPassChoices( $this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_SKI_PASS, true), $bookingDto, $participantIndex ); if (true === empty($choices)) { return []; } return [ 'label' => 'Skipass', 'multiple' => false, 'expanded' => true, 'required' => true, 'choices' => $choices, 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $service?->label, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } $attributes = []; // Add service description as data attribute for frontend use if (null !== $service->description && '' !== trim($service->description)) { $attributes['data-description'] = $service->description; } // Check age restriction first (takes precedence over availability) $ageEvaluator = new ServiceAgeEvaluator(); if ($ageEvaluator->canEvaluate($service) && false === $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex)) { $attributes['readonly'] = true; $attributes['data-tooltip'] = $this->getAgeRestrictionTooltip($service); return $attributes; } // Make readonly if service is unavailable (intelligently handles edit mode) if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'skiPass')) { $attributes['readonly'] = true; $attributes['data-tooltip'] = 'ausgebucht'; } return $attributes; }, ]; }; // Room remarks field provider - provides textarea for room-specific remarks (only for 'mbz' rooms) $this->fieldOptionProviders['remarksRoom'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Unverbindliche Wünsche zum Zimmer', 'required' => false, 'sanitize_html' => true, 'attr' => [ 'rows' => 2, 'maxlength' => 200, ], ]; // Transportation field providers - handles outbound/inbound transportation and pickup selection // Outbound Transportation // Returns empty array when no transportation options available $this->fieldOptionProviders['transportationOutbound'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $choices = $this->filterTransportationChoices( $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL), $bookingDto, $participantIndex ); if (true === empty($choices)) { return []; } return [ 'label' => 'Hinfahrt', 'choices' => $choices, 'choice_label' => fn (Service $service) => $service?->label, 'choice_value' => 'id', 'expanded' => true, 'multiple' => false, 'required' => true, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } $attributes = []; // Add service description as data attribute for frontend use if (null !== $service->description && '' !== trim($service->description)) { $attributes['data-description'] = $service->description; } // Make readonly if service is unavailable (intelligently handles edit mode) if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationOutbound')) { $attributes['readonly'] = true; $attributes['data-tooltip'] = 'ausgebucht'; } return $attributes; }, ]; }; // Inbound Transportation // Returns empty array when no transportation options available $this->fieldOptionProviders['transportationInbound'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $choices = $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL); if (true === empty($choices)) { return []; } return [ 'label' => 'Rückfahrt', 'choices' => $choices, 'choice_label' => fn (Service $service) => $service?->label, 'choice_value' => 'id', 'expanded' => true, 'multiple' => false, 'required' => true, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } $attributes = []; // Add service description as data attribute for frontend use if (null !== $service->description && '' !== trim($service->description)) { $attributes['data-description'] = $service->description; } // Make readonly if service is unavailable (intelligently handles edit mode) if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationInbound')) { $attributes['readonly'] = true; $attributes['data-tooltip'] = 'ausgebucht'; } return $attributes; }, ]; }; // Pickup (conditional - only shown when either transportation direction is bus) // Uses outbound pickups list, applies to both directions // Returns empty array when no pickup options available $this->fieldOptionProviders['pickup'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $choices = $bookingDto->travel->pickups; if (true === empty($choices)) { return []; } return [ 'label' => 'Zustieg', 'choices' => $choices, 'choice_label' => fn (?Pickup $pickup) => $pickup?->getLabel(), 'choice_value' => 'id', 'expanded' => true, // Radio buttons in table layout like other services 'multiple' => false, 'required' => true, ]; }; // "Different drop-off" checkbox (BUS+BUS only) $this->fieldOptionProviders['differentDropOff'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'abweichender Ausstieg (bei gleichem Ausstieg gilt der Hinreisezuschlag)', 'required' => false, ]; // Drop-off location (inbound direction) // In BUS+BUS mode, gated by the differentDropOff checkbox state // In PKW+BUS mode, shown directly (no checkbox involved) $this->fieldOptionProviders['dropOff'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $choices = $bookingDto->travel->dropOffs; if (true === empty($choices)) { return []; } $participant = $bookingDto->getParticipant($participantIndex); // In BUS+BUS mode: only show drop-off choices when checkbox is checked $hasOutboundBus = null !== $participant?->transportationOutbound && DirectionMapper::SUBTYPE_BUS_API === $participant->transportationOutbound->subType; if ($hasOutboundBus && false === ($participant->differentDropOff ?? false)) { return []; } return [ 'label' => 'Ausstieg', 'choices' => $choices, 'choice_label' => fn (?Pickup $pickup) => $pickup?->getLabel(), 'choice_value' => 'id', 'expanded' => true, 'multiple' => false, 'required' => true, ]; }; // Parking (conditional - only shown when outbound transportation is PKW) // Simple checkbox since there's only ever one parking type // Returns empty array when no parking services exist to prevent field from rendering $this->fieldOptionProviders['parking'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $parkingServices = $this->getServicesWithBooked($bookingDto, $participantIndex, Constants::TOKEN_PARKING); if (true === empty($parkingServices)) { return []; } return [ 'label' => $this->getParkingCheckboxLabel($parkingServices), 'required' => false, ]; }; // Bulk insurance booking checkbox (applicant only - controls insurance assignment for all participants) // 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' => [ 'data-description' => sprintf( 'Der hier angezeigte Preis gilt nur für %s. Die Preise für die anderen Teilnehmer:innen werden automatisch aktualisiert.', $bookingDto->isInternalAgencyBooking() ? 'Teilnehmer:in 1' : 'den/die Anmelder:in' ), ], ]; // Insurance field provider - provides age and eligibility filtered insurances for participants // Only registered in create mode - insurance cannot be modified in edit mode due to API limitation // Returns empty array when no insurance options available $this->fieldOptionProviders['insurance'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $choices = $this->getEligibleInsurances($bookingDto, $participantIndex); if (true === empty($choices)) { return []; } return [ 'label' => 'Reiseversicherung', 'multiple' => false, 'expanded' => true, 'required' => false, 'placeholder' => false, // Disable default placeholder - synthetic "keine Versicherung gewünscht" option injected instead 'choices' => $choices, 'choice_value' => 'id', 'choice_label' => fn (?Insurance $insurance) => $insurance?->label, ]; }; // Purchase voucher field provider - redemption code for vouchers that apply to complete booking // Collected from all participants and aggregated into single collection in booking payload $this->fieldOptionProviders['purchaseVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Gutschein-Code', 'required' => false, 'attr' => [ 'placeholder' => 'z.B. 1F7V6PADRZ', 'maxlength' => 50, ], 'sanitize_html' => true, ]; // Promo voucher field provider - promo code applied per participant in booking payload // Included in participant XML node as $this->fieldOptionProviders['promoVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Rabatt-Code', 'required' => false, 'attr' => [ 'placeholder' => 'z.B. LW0705', 'maxlength' => 50, ], 'sanitize_html' => true, ]; // Future field providers would be added here, for example: // // $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [ // 'label' => 'Meal Preference', // 'choices' => [ // 'Standard' => 'standard', // 'Vegetarian' => 'vegetarian', // 'Vegan' => 'vegan', // ], // ]; } /** * Filters room selections by participant age constraints. * * Baby rooms (code 'Baby') are only available for participants aged 2 years or younger. * Other room types are available for all participants regardless of age. * * @param RoomSelectionDto[] $roomSelections Array of room selections from Step 1 * @param BookingDto $bookingDto The booking DTO containing participant and travel data * @param int $participantIndex Index of the participant to evaluate * * @return RoomSelectionDto[] Filtered array of room selections available for this participant */ private function filterRoomsByParticipantAge(array $roomSelections, BookingDto $bookingDto, int $participantIndex): array { $participant = $bookingDto->getParticipant($participantIndex); // If no participant or no date of birth, return all rooms (field visibility handles this case) if (null === $participant || null === $participant->dateOfBirth) { return $roomSelections; } // Calculate participant's age at travel start date $age = $participant->getAge($bookingDto->travel->dateFrom); if (null === $age) { return $roomSelections; } // Get available rooms from travel data to look up room codes $availableRooms = $bookingDto->travel->getAvailableRooms(); return array_filter($roomSelections, function (RoomSelectionDto $roomSelection) use ($availableRooms, $age) { $room = $availableRooms[$roomSelection->id] ?? null; // Exclude rooms not found in travel data to avoid validation issues if (null === $room) { return false; } // Baby rooms only available for participants at or under BABY_MAX_AGE if (Constants::BABY_ROOM_CODE === $room->code) { return $age <= Constants::BABY_MAX_AGE; } // All other rooms available for all ages return true; }); } /** * Converts booked Room objects to RoomSelectionDto for consistent form handling. * * In edit mode, we need to convert Room entities to RoomSelectionDto objects * so the form can use the same choice_value and choice_label configuration * as create mode, enabling consistent price display in the template. * * @param Room[] $bookedRooms Rooms from existing booking * * @return RoomSelectionDto[] Array of room selection DTOs */ private function convertBookedRoomsToSelectionDtos(array $bookedRooms): array { $selections = []; foreach ($bookedRooms as $room) { $selection = new RoomSelectionDto(); $selection->id = $room->id; $selection->label = $room->label; $selection->price = $room->price; $selections[] = $selection; } return $selections; } /** * Filters rental services by selected skipass duration. * * Only returns rentals that have exactly the same dateFrom and dateTo * as the participant's selected skipass. This ensures rental equipment * is only available for the exact duration of the skipass. * * @param array $rentals Array of rental Service objects to filter * @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, BookingDto $bookingDto, int $participantIndex): array { $participant = $bookingDto->getParticipant($participantIndex); if (null === $participant || null === $participant->skiPass) { return []; // No skipass selected = no rentals available } $selectedSkiPass = $participant->skiPass; // If skipass has no valid dates, return empty rentals if (null === $selectedSkiPass->dateFrom || null === $selectedSkiPass->dateTo) { return []; } return array_filter($rentals, function (Service $rental) use ($selectedSkiPass) { // Rental must have valid dates to be considered if (null === $rental->dateFrom || null === $rental->dateTo) { return false; } // Exact date matching: rental dates must match skipass dates exactly return $rental->dateFrom->format('Y-m-d') === $selectedSkiPass->dateFrom->format('Y-m-d') && $rental->dateTo->format('Y-m-d') === $selectedSkiPass->dateTo->format('Y-m-d'); }); } /** * Formats service label with pricing information. * * @param Service|null $service The service to format * * @return string The formatted label */ private function formatServiceLabelWithPrice(?Service $service): string { if (null === $service) { return ''; } if (null === $service->price) { return $service->label; } if (0.0 === $service->price) { return sprintf('%s (inkl.)', $service->label); } if ($service->price < 0) { // Negative prices are discounts return sprintf('%s (-%s€ Rabatt)', $service->label, number_format(abs($service->price), 2, ',', '.')); } return sprintf('%s (€%s)', $service->label, number_format($service->price, 2, ',', '.')); } /** * Gets the parking checkbox label with pricing information. * * Creates a checkbox label for the single parking service including pricing. * Since there's only ever one parking type, we take the first available service. * * @param array $parkingServices Array of available parking services * * @return string The formatted checkbox label with pricing */ private function getParkingCheckboxLabel(array $parkingServices): string { if (empty($parkingServices)) { return Constants::SERVICE_LABELS[Constants::TOKEN_PARKING]; } $parkingService = reset($parkingServices); // Get the first (and only) parking service return $this->formatServiceLabelWithPrice($parkingService); } /** * Checks if a service should be rendered as read-only due to unavailability. * * @param Service $service The service to check * @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, BookingDto $bookingDto, int $participantIndex): bool { if (BookingDto::MODE_EDIT === $bookingDto->getMode()) { // For non-create workflows, don't apply availability restrictions return false; } return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex); } /** * Determines if a service should be rendered as read-only. * * This method intelligently handles readonly state for services in both create and edit modes: * * - CREATE MODE: Uses existing availability calculator logic * - EDIT MODE: Services unavailable (available <= 0) are readonly ONLY if participant doesn't already have them * * This prevents fingerprint false positives in edit mode by allowing participants to keep * services they already have, even if those services are now fully booked. * * @param Service $service The service to check * @param BookingDto $bookingDto The booking DTO containing participant data * @param int $participantIndex Index of the participant currently selecting services * @param string $fieldName Name of the service field (e.g., 'courses', 'board', 'rentals') * * @return bool True if the service should be read-only */ private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex, string $fieldName): bool { // In CREATE mode, use existing availability logic if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex); } // In EDIT mode, apply intelligent readonly logic // If service is available (available > 0), it's never readonly if (null !== $service->available && $service->available > 0) { return false; } // Service is unavailable - check if participant already has it $participant = $bookingDto->getParticipant($participantIndex); if (null === $participant) { return true; // Readonly if no participant data } // Check if participant has this service based on field type $participantHasService = match ($fieldName) { 'courses' => $this->hasServiceById($participant->courses, $service->id), 'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id), 'board' => $this->hasServiceById($participant->board, $service->id), 'veg' => $participant->veg?->id === $service->id, 'rentals' => $this->hasServiceById($participant->rentals, $service->id), 'skiPass' => $participant->skiPass?->id === $service->id, 'transportationOutbound' => $participant->transportationOutbound?->id === $service->id, 'transportationInbound' => $participant->transportationInbound?->id === $service->id, default => false, }; // Make readonly only if participant doesn't have it return false === $participantHasService; } /** * Checks if a service array contains a service with the given ID. * * @param array $services Array of Service objects * @param int $serviceId Service ID to search for * * @return bool True if the service is found in the array */ private function hasServiceById(array $services, int $serviceId): bool { foreach ($services as $service) { if ($service->id === $serviceId) { return true; } } return false; } /** * Filters services based on participant's age constraints. * * Removes services that have age restrictions the participant doesn't meet. * If no age evaluator is configured or participant has no birth date, * returns empty array to be handled by field visibility conditions. * * @param array $services Array of Service objects to filter * @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, BookingDto $bookingDto, int $participantIndex): array { $ageEvaluator = new ServiceAgeEvaluator(); $participant = $bookingDto->getParticipant($participantIndex); // If no birthdate provided, return empty array (handled by field visibility conditions) if (null === $participant || null === $participant->dateOfBirth) { return []; } // Check if participant is a baby $age = $participant->getAge($bookingDto->travel->dateFrom); $isBaby = null !== $age && $age <= Constants::BABY_MAX_AGE; return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex, $ageEvaluator, $isBaby) { // Check if service has age constraints $hasAgeConstraints = $ageEvaluator->canEvaluate($service); // For babies: ONLY show services with explicit age ranges that include them // Services without age restrictions are hidden for babies if ($isBaby && false === $hasAgeConstraints) { return false; } // No age constraints and not a baby = available to all if (false === $hasAgeConstraints) { return true; } // Has age constraints = check if participant's age is within range return $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex); }); } /** * Filters ski pass choices for display. * * Unlike other services, ski passes are shown even when age-restricted * (marked as readonly instead of hidden) to avoid user confusion about * included ski passes not being visible. * * Baby filtering is preserved: babies only see services explicitly * including their age range. * * @param Service[] $services Array of ski pass Service objects to filter * @param BookingDto $bookingDto The booking DTO containing participant data * @param int $participantIndex Index of the participant to evaluate * * @return Service[] Filtered array of ski pass services */ private function filterSkiPassChoices(array $services, BookingDto $bookingDto, int $participantIndex): array { $participant = $bookingDto->getParticipant($participantIndex); // If no birthdate provided, return empty array (handled by field visibility conditions) if (null === $participant || null === $participant->dateOfBirth) { return []; } // Check if participant is a baby $age = $participant->getAge($bookingDto->travel->dateFrom); $isBaby = null !== $age && $age <= Constants::BABY_MAX_AGE; // For babies: keep existing filtering logic (only show services with explicit baby age ranges) if ($isBaby) { return $this->filterServicesByAgeConstraints($services, $bookingDto, $participantIndex); } // For non-babies: return all services (age-restricted ones will be marked readonly in choice_attr) return $services; } /** * Generates a German tooltip explaining age restrictions for a service. * * @param Service $service The service with age restrictions * * @return string German tooltip text explaining the restriction */ private function getAgeRestrictionTooltip(Service $service): string { $constraint = $this->translateAgeConstraint($service); return $this->translator->trans('service.age_constraint.prefix', ['%constraint%' => $constraint]); } /** * Translates a service's age constraints to a localized description. * * @param Service $service The service with age constraints * * @return string Translated constraint description */ private function translateAgeConstraint(Service $service): string { return match ($service->ageConstraintType) { 'absolute_age' => $this->translateAbsoluteAgeConstraint($service), 'birth_year' => $this->translateBirthYearConstraint($service), 'mixed' => $this->translateAbsoluteAgeConstraint($service) .$this->translator->trans('service.age_constraint.mixed.separator') .$this->translateBirthYearConstraint($service), default => '', }; } /** * Translates absolute age constraints (ageFrom/ageTo). */ private function translateAbsoluteAgeConstraint(Service $service): string { if (null !== $service->ageFrom && null !== $service->ageTo) { return $this->translator->trans('service.age_constraint.absolute_age.range', [ '%ageFrom%' => $service->ageFrom, '%ageTo%' => $service->ageTo, ]); } if (null !== $service->ageFrom) { return $this->translator->trans('service.age_constraint.absolute_age.min', [ '%ageFrom%' => $service->ageFrom, ]); } if (null !== $service->ageTo) { return $this->translator->trans('service.age_constraint.absolute_age.max', [ '%ageTo%' => $service->ageTo, ]); } return ''; } /** * Translates birth year constraints (birthYearFrom/birthYearTo). */ private function translateBirthYearConstraint(Service $service): string { if (null !== $service->birthYearFrom && null !== $service->birthYearTo) { if ($service->birthYearFrom === $service->birthYearTo) { return $this->translator->trans('service.age_constraint.birth_year.single', [ '%year%' => $service->birthYearFrom, ]); } return $this->translator->trans('service.age_constraint.birth_year.range', [ '%yearFrom%' => $service->birthYearFrom, '%yearTo%' => $service->birthYearTo, ]); } if (null !== $service->birthYearFrom) { return $this->translator->trans('service.age_constraint.birth_year.min', [ '%yearFrom%' => $service->birthYearFrom, ]); } if (null !== $service->birthYearTo) { return $this->translator->trans('service.age_constraint.birth_year.max', [ '%yearTo%' => $service->birthYearTo, ]); } return ''; } /** * Generates label for rental insurance checkbox including pricing information. */ private function getRentalInsuranceCheckboxLabel(array $rentalInsuranceServices): string { if (empty($rentalInsuranceServices)) { return Constants::SERVICE_LABELS[Constants::TOKEN_RENTAL_INSURANCE]; } $rentalInsuranceService = reset($rentalInsuranceServices); // Get the first (and only) rental insurance service return $this->formatServiceLabelWithPrice($rentalInsuranceService); } /** * Gets the rental insurance description for help text. */ private function getRentalInsuranceDescription(array $rentalInsuranceServices): ?string { if (empty($rentalInsuranceServices)) { return null; } $rentalInsuranceService = reset($rentalInsuranceServices); // Get the first (and only) rental insurance service return $rentalInsuranceService->description; } /** * Gets eligible insurances for a participant based on eligibility criteria. * * Injects a synthetic "keine Versicherung gewünscht" option at the top of the list * to force explicit user choice for legal compliance. * * @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(BookingDto $bookingDto, int $participantIndex): array { $participant = $bookingDto->getParticipant($participantIndex); if (null === $participant) { return []; } // Get selectable (non-complementary) insurances with caching $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); // Calculate travel price for eligibility filtering $travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex); // Filter based on eligibility criteria for this participant $eligibleInsurances = $this->insuranceService->getEligibleInsurances( $selectableInsurances, $participant, $bookingDto, $travelPrice ); // Inject synthetic "no insurance" option at the top of the list // This forces explicit user choice for legal compliance $noInsurance = new Insurance(); $noInsurance->id = Insurance::NO_INSURANCE_ID; $noInsurance->label = 'keine Versicherung gewünscht'; $noInsurance->price = 0.0; // Prepend to list (appears first in radio buttons) array_unshift($eligibleInsurances, $noInsurance); return $eligibleInsurances; } /** * Filters transportation choices to show only one PKW option based on per-booking availability. * * When both discounted and regular self-organized (PKW/CAR) options exist: * - If discounted option has remaining availability within this booking, show only discounted * - If discounted option is sold out within this booking, show only regular * - This creates dynamic fallback: as participants select/deselect, availability updates in real-time * * Business logic: Prefer showing discounted options while they have availability, but automatically * fall back to regular options when discounts are exhausted. This allows participants who initially * got the discount to change their mind (e.g., select bus instead), making the discount available * for others in the same booking session. * * @param array $services Transportation services from Travel model * @param BookingDto $bookingDto Current booking DTO with participant selections * @param int $participantIndex Current participant being processed * * @return array Filtered transportation choices with smart PKW option selection */ private function filterTransportationChoices(array $services, BookingDto $bookingDto, int $participantIndex): array { // Separate PKW/CAR from other services (BUS, etc.) $pkwServices = []; $otherServices = []; foreach ($services as $service) { if (in_array($service->subType, ['PKW', 'CAR'], true)) { $pkwServices[] = $service; } else { $otherServices[] = $service; } } // If only one or no PKW service, no filtering needed if (count($pkwServices) <= 1) { return [...$otherServices, ...$pkwServices]; } // Find discounted (negative price) and regular (zero/positive price) PKW options $discountedPkw = null; $regularPkw = null; foreach ($pkwServices as $pkw) { if (null !== $pkw->price && $pkw->price < 0) { $discountedPkw = $pkw; } else { $regularPkw = $pkw; } } // If we have both discounted and regular options, apply smart filtering if (null !== $discountedPkw && null !== $regularPkw) { $participant = $bookingDto->getParticipant($participantIndex); // In edit mode, always preserve the participant's current selection in the choices // This ensures the form can render the selected value even if filtering would hide it $currentOutboundId = $participant?->transportationOutbound?->id; $preserveDiscounted = $currentOutboundId === $discountedPkw->id; $preserveRegular = $currentOutboundId === $regularPkw->id; // Baby participants always get regular PKW (no discounts) $age = $participant?->getAge($bookingDto->travel->dateFrom); if (null !== $age && $age <= Constants::BABY_MAX_AGE) { if ($preserveDiscounted) { return [...$otherServices, $discountedPkw, $regularPkw]; } return [...$otherServices, $regularPkw]; } // Check if inbound is BUS $inboundIsBus = null !== $participant?->transportationInbound && DirectionMapper::SUBTYPE_BUS_API === $participant->transportationInbound->subType; // If inbound is BUS, always show regular PKW (not discounted) if ($inboundIsBus) { if ($preserveDiscounted) { return [...$otherServices, $discountedPkw, $regularPkw]; } return [...$otherServices, $regularPkw]; } // Check if discounted option is unavailable for this participant (per-booking availability) $discountedIsUnavailable = $this->serviceAvailabilityCalculator->isServiceUnavailable( $discountedPkw->id, $bookingDto, $participantIndex ); if ($discountedIsUnavailable) { // Discounted is sold out within this booking, show only regular // But preserve discounted if participant already has it selected if ($preserveDiscounted) { return [...$otherServices, $discountedPkw, $regularPkw]; } return [...$otherServices, $regularPkw]; } // Discounted has availability, show only discounted (hide regular) // But preserve regular if participant already has it selected if ($preserveRegular) { return [...$otherServices, $discountedPkw, $regularPkw]; } return [...$otherServices, $discountedPkw]; } // Fallback: return all services if we don't have the expected discount/regular pair return [...$otherServices, ...$pkwServices]; } /** * Retrieves available services from travel data, merged with booked services in edit mode. * * In edit mode, services that were previously booked for the participant are included * even if they're no longer available in travel data. This ensures form checkboxes * appear for booked services, allowing users to keep or deselect them. * * @param BookingDto $bookingDto The booking DTO containing travel and booking data * @param int $participantIndex The participant index to retrieve booked services for * @param mixed $serviceGroup The service group(s) to filter by (e.g., TOKEN_RENTALS) * @param bool $filterByTravelDateRange Whether to filter travel services by date range * * @return Service[] Array of available services keyed by service ID */ private function getServicesWithBooked( BookingDto $bookingDto, int $participantIndex, mixed $serviceGroup, bool $filterByTravelDateRange = false, ): array { $availableServices = $bookingDto->travel->getAdditionalServicesBySubTypes($serviceGroup, $filterByTravelDateRange); if (BookingDto::MODE_EDIT === $bookingDto->getMode() && null !== $bookingDto->booking) { $availableServices = $this->mergeBookedServices( $availableServices, $bookingDto->booking, $participantIndex, $serviceGroup ); } return $availableServices; } /** * Merges booked services into the available services array. * * @param Service[] $availableServices The services available from travel data * @param Booking $booking The booking containing previously booked services * @param int $participantIndex The participant index to retrieve booked services for * @param mixed $serviceGroup The service group(s) to filter by * * @return Service[] Merged array of services keyed by service ID */ private function mergeBookedServices( array $availableServices, Booking $booking, int $participantIndex, mixed $serviceGroup, ): array { $bookedServices = $booking->getAdditionalServicesForParticipantByGroup($participantIndex, $serviceGroup); foreach ($bookedServices as $bookedService) { if (false === isset($availableServices[$bookedService->id])) { $availableServices[$bookedService->id] = $bookedService; } } return $availableServices; } }