fieldOptionProviders['newField'] = fn($bookingDto, $participantIndex) => [ * 'label' => 'New Field Label', * 'choices' => $this->generateChoicesFor($bookingDto, $participantIndex), * ]; * * Provider Pattern Benefits: * - Lazy evaluation (options only generated when needed) * - Context-aware configuration * - Easy to test individual field logic * - Supports complex interdependencies */ protected function registerFieldOptionProviders(): void { // Room assignment field provider (available for both create and edit workflows) $this->fieldOptionProviders['assignedRoomId'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []) { $choiceLoader = null; $disabled = false; if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { // Create context: use selected rooms from step 1 $choiceLoader = $this->roomChoiceLoaderFactory->createForCreate( $bookingDto->getSelectedRooms() ); // Disable when only one room type selected (auto-assigned) $disabled = 1 === count($bookingDto->getSelectedRooms()); } elseif (BookingDto::MODE_EDIT === $bookingDto->getMode()) { // Edit context: use already-booked rooms from booking $choiceLoader = $this->roomChoiceLoaderFactory->createForEdit( $bookingDto->booking->rooms ); // Disable when only one room in booking (no reassignment needed) $disabled = 1 === count($bookingDto->booking->rooms); } return [ 'label' => 'Zimmer', 'placeholder' => 'Nicht zugeordnet', // Use factory to create context-aware choice loader that: // - Shows only available rooms for this participant // - Excludes rooms already assigned to other participants // - Respects room capacity and booking constraints 'choice_loader' => $choiceLoader, // Make field read-only when only one room type is available // Room is auto-assigned or cannot be changed, no user choice needed 'disabled' => $disabled, ]; }; // Courses field provider - provides age-appropriate courses from travel data $this->fieldOptionProviders['courses'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Kurse', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes( Constants::TOKEN_COURSES, BookingDto::MODE_CREATE === $bookingDto->getMode() // Only filter by availability in create mode ), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), '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 $this->fieldOptionProviders['additionalServices'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Zusatzleistungen', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes( Constants::TOKEN_ADDITIONAL, BookingDto::MODE_EDIT !== $bookingDto->getMode() // Only filter by availability in create mode ), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } $attributes = []; // Make readonly and checked for mandatory services (pre-selection handled by service layer) if (true === $service->mandatory) { $attributes['checked'] = true; $attributes['readonly'] = true; $attributes['class'] = 'text-pink'; $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 $this->fieldOptionProviders['board'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Verpflegung', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes( Constants::TOKEN_BOARD, BookingDto::MODE_CREATE === $bookingDto->getMode() // Only filter by availability in create mode ), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } $attributes = []; // Make readonly if service is unavailable (intelligently handles edit mode) if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'board')) { $attributes['readonly'] = true; $attributes['data-tooltip'] = 'ausgebucht'; } return $attributes; }, ]; // Rentals field provider - provides age-appropriate rental options filtered by selected skipass duration $this->fieldOptionProviders['rentals'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Leihmaterial', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $this->filterServicesByAgeConstraints( $this->filterRentalsBySkiPassDuration( $bookingDto->travel->getAdditionalServicesBySubTypes( Constants::TOKEN_RENTALS, BookingDto::MODE_CREATE === $bookingDto->getMode(), // Only filter by availability in create mode true // Filter by travel date range ), $bookingDto, $participantIndex ), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), '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, '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, true)), 'required' => false, 'property_path' => 'rentalInsuranceSelected', 'help' => $this->getRentalInsuranceDescription($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true, 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.', 'sanitize_html' => true, ]; // Skipass field provider - provides age-appropriate skipass options from travel data filtered by date range $this->fieldOptionProviders['skiPass'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Skipass', 'multiple' => false, 'expanded' => true, 'required' => true, 'choices' => $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes( Constants::TOKEN_SKI_PASS, BookingDto::MODE_CREATE === $bookingDto->getMode(), // Only filter by availability in create mode true // Filter by travel date range ), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), '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, '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' => 'Wünsche oder Anmerkungen zum Zimmer', 'required' => false, 'sanitize_html' => true, 'attr' => [ 'rows' => 2, ], ]; // Transportation field providers - handles outbound/inbound transportation and pickup selection // Outbound Transportation $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), 'choice_value' => 'id', 'expanded' => true, 'multiple' => false, 'required' => true, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } $attributes = []; // 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 $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), 'choice_value' => 'id', 'expanded' => true, 'multiple' => false, 'required' => true, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } $attributes = []; // 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 $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), 'choice_value' => 'id', 'expanded' => false, // Dropdown for pickups 'multiple' => false, 'required' => true, 'placeholder' => 'Zu- und Ausstieg auswählen', ]; // Parking (conditional - only shown when outbound transportation is PKW) // Simple checkbox since there's only ever one parking type $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) // 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, ]; // 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 $this->fieldOptionProviders['insurance'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Reiseversicherung', 'multiple' => false, 'expanded' => true, 'required' => false, 'choices' => $this->getEligibleInsurances($bookingDto, $participantIndex), 'choice_value' => 'id', 'choice_label' => function (?Insurance $insurance) { $label = $insurance->label; if (null !== $insurance->price && $insurance->price > 0) { $label .= sprintf(' (€%s)', number_format($insurance->price, 2, ',', '.')); } return $label; }, 'placeholder' => 'Keine Versicherung gewünscht', ]; // 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 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 || 0.0 === $service->price) { return $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, ',', '.')); } /** * Format transportation service labels with type indicator and pricing. * * Creates user-friendly labels for transportation services that include: * - Transportation type icon (🚌 for bus, 🚗 for car) * - Service name * - Pricing (with discount indication for negative prices) * - Availability warning for limited services * * @param Service $service The transportation service to format * * @return string The formatted transportation service label */ private function formatTransportationServiceLabel(Service $service): string { $label = $service->label; if (null === $service->price || 0.0 === $service->price) { return $service->label; } if ($service->price > 0) { $label .= sprintf(' (€%s)', number_format($service->price, 2, ',', '.')); } else { $label .= sprintf(' (-%s€ Rabatt)', number_format(abs($service->price), 2, ',', '.')); } // Add availability warning if limited if (null !== $service->available && $service->available <= 5) { $label .= sprintf(' (nur %d verfügbar)', $service->available); } return $label; } private function formatPickupLabelWithPrice(?Pickup $pickup): string { if (null === $pickup) { return ''; } return $pickup->getLabelWithPrice(); } /** * 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), '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 []; } return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex, $ageEvaluator) { // No age constraints = available to all if (false === $ageEvaluator->canEvaluate($service)) { return true; } return $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex); }); } /** * 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. * * @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 return $this->insuranceService->getEligibleInsurances( $selectableInsurances, $participant, $bookingDto, $travelPrice ); } }