Files
myep/src/Form/Service/ParticipantFieldOptionsProvider.php
T

964 lines
43 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto;
use App\Form\Model\RoomSelectionDto;
use App\Form\Service\Abstract\AbstractFieldOptionsProvider;
use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory;
use App\Service\BookingPriceCalculatorService;
use App\Service\InsuranceService;
use App\Service\ServiceAvailabilityCalculator;
/**
* Provides dynamic field options for participant form fields.
*
* This service generates context-aware Symfony form field options for
* dynamic fields in the participant form system. It handles fields that
* require options to be calculated based on the current booking context,
* participant data, and business logic.
*
* Key Responsibilities:
* - Manages field option providers for dynamic fields
* - Provides context-aware field configurations
* - Handles interdependent field relationships
* - Supports extensible field option generation
*
* The provider uses a callable pattern for field options, enabling
* lazy evaluation and complex conditional field behavior.
*/
class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
{
/** @var array<string, array<string, mixed>> Request-scoped cache for field options */
private array $optionsCache = [];
/**
* Initializes the provider with required dependencies.
*
* The provider automatically registers all field option providers during
* construction to ensure they're available for form building. This approach
* keeps all field configuration logic centralized and makes it easy to add
* new dynamic fields.
*
* @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders
* @param ServiceAvailabilityCalculator $serviceAvailabilityCalculator Service for calculating dynamic availability
* @param InsuranceService $insuranceService Service for insurance operations
* @param BookingPriceCalculatorService $priceCalculatorService Service for calculating participant prices
*/
public function __construct(
private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory,
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
private readonly InsuranceService $insuranceService,
private readonly BookingPriceCalculatorService $priceCalculatorService,
) {
parent::__construct();
}
/**
* Retrieves form field options with request-scoped caching.
*
* Overrides parent to add instance-level caching, preventing redundant
* field option calculations when the same field is accessed multiple times
* during form building (happens ~16 times per participant × 50 participants = 800 calls).
*
* Cache key includes: field name, participant index, mode, and date of birth.
* The cache is automatically cleared between requests since the service is request-scoped.
*
* @param string $fieldName The name of the field to configure
* @param BookingDto $bookingDto The current booking data for context
* @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior
*
* @return array<string, mixed> Symfony form field options, or empty array if field not supported
*/
public function getFieldOptions(string $fieldName, BookingDto $bookingDto, int $participantIndex, array $options = []): array
{
// Generate cache key based on factors that affect field options
$participant = $bookingDto->getParticipant($participantIndex);
$cacheKey = sprintf(
'%s_%d_%s_%s',
$fieldName,
$participantIndex,
$bookingDto->getMode(),
$participant?->dateOfBirth?->format('Y-m-d') ?? 'no_dob'
);
// Return cached result if available, otherwise compute and cache
return $this->optionsCache[$cacheKey] ??= parent::getFieldOptions($fieldName, $bookingDto, $participantIndex, $options);
}
/**
* Registers all field option providers during service initialization.
*
* This method defines the option generation logic for each supported dynamic field.
* Each provider is a callable that receives the booking DTO and participant
* index and returns appropriate Symfony form field options.
*
* Adding new fields:
* To add support for a new dynamic field, simply add a new provider here:
*
* $this->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;
$singleChoice = false;
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
// Create context: use selected rooms from step 1, filtered by participant age
$filteredRooms = $this->filterRoomsByParticipantAge(
$bookingDto->getSelectedRooms(),
$bookingDto,
$participantIndex
);
$choiceLoader = $this->roomChoiceLoaderFactory->createForCreate($filteredRooms);
$singleChoice = 1 === count($filteredRooms);
} elseif (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
// Edit context: use already-booked rooms from booking
$choiceLoader = $this->roomChoiceLoaderFactory->createForEdit(
$bookingDto->booking->rooms
);
$singleChoice = 1 === count($bookingDto->booking->rooms);
}
return [
'label' => 'Zimmer',
// No placeholder when only one room available - the only option is pre-selected
'placeholder' => $singleChoice ? false : '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
// - Filters Baby rooms for participants over 2 years old
'choice_loader' => $choiceLoader,
];
};
// 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) => $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
$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) => $service?->label,
'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) => $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, '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) => $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, '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) => $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, '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' => $this->filterTransportationChoices(
$bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL),
$bookingDto,
$participantIndex
),
'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
$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) => $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
$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,
'attr' => [
'data-description' => 'Der hier angezeigte Preis gilt nur für den/die Anmelder:in. Die Preise für die anderen Teilnehmer:innen werden automatisch aktualisiert.',
],
];
// 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,
'placeholder' => false, // Disable default placeholder - synthetic "keine Versicherung gewünscht" option injected instead
'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;
},
];
// Purchase voucher field provider - redemption code for vouchers that apply to complete booking
// Collected from all participants and aggregated into single <gutscheine> collection in booking payload
$this->fieldOptionProviders['purchaseVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Gutschein-Einlösecode',
'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 <aktionscode>
$this->fieldOptionProviders['promoVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Aktionscode',
'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->roomId] ?? 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;
});
}
/**
* 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, ',', '.'));
}
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.
*
* 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) {
// Get participant to check inbound transportation
$participant = $bookingDto->getParticipant($participantIndex);
$inboundIsBus = null !== $participant?->transportationInbound
&& DirectionMapper::SUBTYPE_BUS_API === $participant->transportationInbound->subType;
// If inbound is BUS, always show regular PKW (not discounted)
if ($inboundIsBus) {
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
return [...$otherServices, $regularPkw];
} else {
// Discounted has availability, show only discounted (hide regular)
return [...$otherServices, $discountedPkw];
}
}
// Fallback: return all services if we don't have the expected discount/regular pair
return [...$otherServices, ...$pkwServices];
}
}