1118 lines
48 KiB
PHP
1118 lines
48 KiB
PHP
<?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\Room;
|
|
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\Service\BookingPriceCalculatorService;
|
|
use App\Service\InsuranceService;
|
|
use App\Service\ServiceAvailabilityCalculator;
|
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
public function __construct(
|
|
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
|
|
private readonly InsuranceService $insuranceService,
|
|
private readonly BookingPriceCalculatorService $priceCalculatorService,
|
|
private readonly TranslatorInterface $translator,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
/**
|
|
* 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 = []) {
|
|
$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);
|
|
}
|
|
|
|
$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
|
|
$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,
|
|
$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,
|
|
$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['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,
|
|
$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;
|
|
},
|
|
];
|
|
|
|
// Veg (vegetarian/vegan) field provider - provides dietary preference options as radio buttons (mutually exclusive)
|
|
$this->fieldOptionProviders['veg'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
|
'label' => 'Verpflegungswunsch',
|
|
'multiple' => false,
|
|
'expanded' => true,
|
|
'required' => false,
|
|
'choices' => $this->filterServicesByAgeConstraints(
|
|
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_VEG, true),
|
|
$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;
|
|
}
|
|
|
|
// 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
|
|
$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, true),
|
|
$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)),
|
|
'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
|
|
$this->fieldOptionProviders['skiPass'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
|
'label' => 'Skipass',
|
|
'multiple' => false,
|
|
'expanded' => true,
|
|
'required' => true,
|
|
'choices' => $this->filterSkiPassChoices(
|
|
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true),
|
|
$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;
|
|
}
|
|
|
|
// 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,
|
|
],
|
|
];
|
|
|
|
// 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) => $pickup?->getLabel(),
|
|
'choice_value' => 'id',
|
|
'expanded' => true, // Radio buttons in table layout like other services
|
|
'multiple' => false,
|
|
'required' => true,
|
|
];
|
|
|
|
// 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)),
|
|
'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
|
|
$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' => 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 <gutscheine> 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 <aktionscode>
|
|
$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);
|
|
|
|
// Baby participants always get regular PKW (no discounts)
|
|
$age = $participant?->getAge($bookingDto->travel->dateFrom);
|
|
if (null !== $age && $age <= Constants::BABY_MAX_AGE) {
|
|
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) {
|
|
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];
|
|
}
|
|
}
|