diff --git a/src/Form/Service/Condition/BookingEligibilityCondition.php b/src/Form/Service/Condition/BookingEligibilityCondition.php new file mode 100644 index 0000000..df2e22f --- /dev/null +++ b/src/Form/Service/Condition/BookingEligibilityCondition.php @@ -0,0 +1,74 @@ + $formData Current form data for condition evaluation + * + * @return bool True if participant is ineligible (no skipasses available for their age) + */ + public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + { + // Invert the eligibility check since conditions typically evaluate to TRUE for "hide" + // isParticipantEligible() returns TRUE when eligible, we need TRUE when INELIGIBLE + return !$this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex); + } + + /** + * Returns field names that this condition depends on. + * + * This condition depends on the dateOfBirth field since eligibility + * is determined by age-based skipass availability. + * + * @return string[] Array containing field names this condition depends on + */ + public function getDependentFields(): array + { + return ['dateOfBirth']; + } + + /** + * Returns a human-readable description of this condition. + * + * @return string Description of the booking eligibility condition + */ + public function getDescription(): string + { + return 'Participant has no available skipasses for their age'; + } +} \ No newline at end of file diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index 7e10056..2ab887f 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -6,6 +6,7 @@ namespace App\Form\Service; use App\BusProNet\Utility\DirectionMapper; use App\Form\Service\Abstract\AbstractFieldStateProvider; +use App\Form\Service\Condition\BookingEligibilityCondition; use App\Form\Service\Condition\BulkInsuranceBookingCondition; use App\Form\Service\Condition\CompositeCondition; use App\Form\Service\Condition\DateOfBirthProvidedCondition; @@ -14,6 +15,7 @@ use App\Form\Service\Condition\RentalSelectionCondition; use App\Form\Service\Condition\RoomSelectionCondition; use App\Form\Service\Condition\ServiceSubTypeCondition; use App\Form\Service\Condition\SkiPassSelectionCondition; +use App\Service\ParticipantEligibilityService; /** * Field state provider for the booking create workflow. @@ -27,9 +29,15 @@ use App\Form\Service\Condition\SkiPassSelectionCondition; * - Age-dependent service fields are hidden until birth date is provided * - Transportation pickup fields are hidden by default, shown only when transportation type is BUS * - Parking field is hidden by default, shown only when outbound transportation is PKW + * - All service fields are hidden for ineligible participants (no skipasses available for their age) */ class CreateFieldStateProvider extends AbstractFieldStateProvider { + public function __construct( + private readonly ParticipantEligibilityService $participantEligibilityService + ) { + parent::__construct(); + } /** * Registers field state conditions for the create workflow. * @@ -56,6 +64,9 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider */ protected function registerFieldStateConditions(): void { + // Booking eligibility condition - hide all service fields when no skipasses available for participant's age + $bookingEligibilityCondition = new BookingEligibilityCondition($this->participantEligibilityService); + $rentalCondition = new RentalSelectionCondition(); $skiPassCondition = new SkiPassSelectionCondition(); @@ -67,25 +78,43 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider // Hide age-dependent fields when no date of birth is provided $dateOfBirthProvidedCondition = new DateOfBirthProvidedCondition(); - // Age-dependent service fields are hidden until birth date is provided + // Age-dependent service fields are hidden until birth date is provided OR when participant is ineligible $this->fieldStateConditions['courses'] = [ - 'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition), + 'hidden' => CompositeCondition::or( + CompositeCondition::not($dateOfBirthProvidedCondition), + $bookingEligibilityCondition + ), ]; $this->fieldStateConditions['additionalServices'] = [ - 'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition), + 'hidden' => CompositeCondition::or( + CompositeCondition::not($dateOfBirthProvidedCondition), + $bookingEligibilityCondition + ), ]; - // Show rentals only when both date of birth is provided AND skipass is selected + // Skipass field - hidden when participant is ineligible OR when no date of birth + $this->fieldStateConditions['skiPass'] = [ + 'hidden' => CompositeCondition::or( + CompositeCondition::not($dateOfBirthProvidedCondition), + $bookingEligibilityCondition + ), + ]; + + // Show rentals only when both date of birth is provided AND skipass is selected AND participant is eligible $this->fieldStateConditions['rentals'] = [ 'hidden' => CompositeCondition::or( CompositeCondition::not($dateOfBirthProvidedCondition), - CompositeCondition::not($skiPassCondition) + CompositeCondition::not($skiPassCondition), + $bookingEligibilityCondition ), ]; $this->fieldStateConditions['board'] = [ - 'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition), + 'hidden' => CompositeCondition::or( + CompositeCondition::not($dateOfBirthProvidedCondition), + $bookingEligibilityCondition + ), ]; // Bulk insurance booking conditions @@ -114,11 +143,12 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider ), ]; - // Hide insurance field until date of birth is provided OR when bulk insurance booking is active for dependent participants + // Hide insurance field until date of birth is provided OR when bulk insurance booking is active OR when participant is ineligible $this->fieldStateConditions['insurance'] = [ 'hidden' => CompositeCondition::or( CompositeCondition::not($dateOfBirthProvidedCondition), - $bulkInsuranceBookingCondition + $bulkInsuranceBookingCondition, + $bookingEligibilityCondition ), ]; @@ -131,6 +161,23 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider ]; // Transportation-related field conditions + // All transportation fields require date of birth and participant eligibility + + // Outbound transportation - hidden until date of birth provided AND participant is eligible + $this->fieldStateConditions['transportationOutbound'] = [ + 'hidden' => CompositeCondition::or( + CompositeCondition::not($dateOfBirthProvidedCondition), + $bookingEligibilityCondition + ), + ]; + + // Inbound transportation - hidden until date of birth provided AND participant is eligible + $this->fieldStateConditions['transportationInbound'] = [ + 'hidden' => CompositeCondition::or( + CompositeCondition::not($dateOfBirthProvidedCondition), + $bookingEligibilityCondition + ), + ]; // Show outbound pickup only when transportation is BUS (hidden by default) $this->fieldStateConditions['pickupOutbound'] = [ diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 19a1155..273ec4f 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -685,6 +685,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider }); } + /** * Generates label for rental insurance checkbox including pricing information. */ diff --git a/src/Service/BookingPriceCalculatorService.php b/src/Service/BookingPriceCalculatorService.php index cdfab25..560b0f5 100644 --- a/src/Service/BookingPriceCalculatorService.php +++ b/src/Service/BookingPriceCalculatorService.php @@ -21,6 +21,10 @@ use App\Form\Model\ParticipantDto; */ class BookingPriceCalculatorService { + public function __construct( + private readonly ParticipantEligibilityService $participantEligibilityService + ) { + } /** * Calculates comprehensive pricing breakdown for a booking. * @@ -89,6 +93,8 @@ class BookingPriceCalculatorService /** * Calculates pricing for all selected services across all participants, grouped by subtype. * + * Only includes services from eligible participants (those with available skipasses for their age). + * * @param BookingDtoInterface $bookingDto The booking data containing participants and their service selections * * @return array Array of service groups with each group containing services of the same subtype @@ -101,10 +107,15 @@ class BookingPriceCalculatorService return []; } - // Aggregate service selections across all participants + // Aggregate service selections across all eligible participants $serviceAggregation = []; - foreach ($participants as $participant) { + foreach ($participants as $participantIndex => $participant) { + // Skip ineligible participants (no skipasses available for their age) + if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { + continue; + } + $this->aggregateParticipantServices($participant, $serviceAggregation); } @@ -285,6 +296,8 @@ class BookingPriceCalculatorService * - Zustieg: Sum of all pickup prices and base transportation costs (positive and negative) * - Parkplatz: Sum of all parking service prices * + * Only includes transportation costs from eligible participants. + * * Note: Transportation discounts will be handled generically by groupServicesBySubtype as "Beförderung - Rabatt" * * @param BookingDtoInterface $bookingDto The booking data containing participants @@ -301,7 +314,12 @@ class BookingPriceCalculatorService $pickupParticipants = 0; $parkingParticipants = 0; - foreach ($participants as $participant) { + foreach ($participants as $participantIndex => $participant) { + // Skip ineligible participants (no skipasses available for their age) + if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { + continue; + } + $participantPickupCost = 0.0; $participantParkingCost = 0.0; diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index 13ddf2b..be3aeaf 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -19,6 +19,7 @@ class BookingService public function __construct( private readonly TravelDataService $travelDataService, private readonly BookingPriceCalculatorService $priceCalculator, + private readonly ParticipantEligibilityService $participantEligibilityService, ) { } @@ -149,7 +150,7 @@ class BookingService * Converts a Room model into a RoomSelectionDto with the specified quantity * selection. Used during booking initialization to create selectable room options. * - * @param Room $room The room model to convert + * @param Room $room The room model to convert * @param array $roomsIdsAndQuantities Array of room ID to quantity mappings * * @return RoomSelectionDto The room selection DTO @@ -329,6 +330,10 @@ class BookingService * and pricing calculations, resolving timing issues where mandatory services * were only selected during form rendering via choice_attr callbacks. * + * Mandatory services are only preselected for eligible participants - those who + * have at least one skipass available for their age. Ineligible participants are + * skipped to prevent their mandatory services from being included in pricing. + * * @param BookingCreateDto $bookingDto The booking DTO to update with mandatory services */ public function preselectMandatoryServices(BookingCreateDto $bookingDto): void @@ -337,11 +342,16 @@ class BookingService $mandatoryServices = array_filter($additionalServices, fn ($service) => true === $service->mandatory); // Pre-select mandatory services for each participant - foreach ($bookingDto->participants as $participant) { + foreach ($bookingDto->participants as $participantIndex => $participant) { if (null === $participant->dateOfBirth) { continue; // Skip participants without age information } + // Skip ineligible participants (no skipasses available for their age) + if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { + continue; + } + // Get age-appropriate mandatory services for this participant $ageAppropriateServices = array_filter($mandatoryServices, function ($service) use ($bookingDto, $participant) { if (null === $service->ageFrom && null === $service->ageTo) { diff --git a/src/Service/ParticipantEligibilityService.php b/src/Service/ParticipantEligibilityService.php new file mode 100644 index 0000000..e334dac --- /dev/null +++ b/src/Service/ParticipantEligibilityService.php @@ -0,0 +1,117 @@ +getParticipant($participantIndex); + + if (null === $participant || null === $participant->dateOfBirth) { + return false; + } + + // Create cache key based on participant's birth date, travel date, and index + $cacheKey = sprintf( + 'participant_eligibility_%s_%s_%d', + $participant->dateOfBirth->format('Y-m-d'), + $bookingDto->travel->dateFrom->format('Y-m-d'), + $participantIndex + ); + + return Blink::global()->once($cacheKey, function () use ($bookingDto, $participantIndex) { + $allSkiPasses = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true); + $availableSkiPasses = array_filter( + $allSkiPasses, + fn (Service $service) => $this->isSkiPassAvailableForParticipant($service, $bookingDto, $participantIndex) + ); + + return !empty($availableSkiPasses); + }); + } + + /** + * Checks if a skipass service is available for the given participant based on age constraints. + */ + private function isSkiPassAvailableForParticipant(Service $service, BookingDtoInterface $bookingDto, int $participantIndex): bool + { + $participant = $bookingDto->getParticipant($participantIndex); + + if (null === $participant || null === $participant->dateOfBirth) { + return false; + } + + // No age constraints = available to all + if (null === $service->ageConstraintType) { + return true; + } + + $travelStartDate = CarbonImmutable::instance($bookingDto->travel->dateFrom); + $birthDate = CarbonImmutable::instance($participant->dateOfBirth); + $ageAtTravelStart = $travelStartDate->diffInYears($birthDate); + $birthYear = (int) $birthDate->format('Y'); + + $constraintType = $service->ageConstraintType ?? 'absolute_age'; + + if ('absolute_age' === $constraintType || 'mixed' === $constraintType) { + $minAge = $service->ageFrom; + $maxAge = $service->ageTo; + + if (null !== $minAge && $ageAtTravelStart < $minAge) { + return false; + } + + if (null !== $maxAge && $ageAtTravelStart > $maxAge) { + return false; + } + } + + if ('birth_year' === $constraintType || 'mixed' === $constraintType) { + $minBirthYear = $service->birthYearFrom; + $maxBirthYear = $service->birthYearTo; + + if (null !== $minBirthYear && $birthYear < $minBirthYear) { + return false; + } + + if (null !== $maxBirthYear && $birthYear > $maxBirthYear) { + return false; + } + } + + return true; + } +} diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php index 8ab5f84..9ebfcbe 100644 --- a/src/Twig/AppExtension.php +++ b/src/Twig/AppExtension.php @@ -25,6 +25,7 @@ class AppExtension extends AbstractExtension { return [ new TwigFunction('icon', [AppRuntime::class, 'renderIcon'], ['needs_environment' => true, 'is_safe' => ['html']]), + new TwigFunction('is_participant_eligible', [AppRuntime::class, 'isParticipantEligible']), ]; } } diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index 188c23a..be8b713 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -3,6 +3,8 @@ namespace App\Twig; use App\BusProNet\DataProvider\CountryDataProvider; +use App\Form\Model\BookingDtoInterface; +use App\Service\ParticipantEligibilityService; use Twig\Environment; use Twig\Extension\RuntimeExtensionInterface; use Twig\Extra\Intl\IntlExtension; @@ -12,6 +14,7 @@ class AppRuntime implements RuntimeExtensionInterface public function __construct( private readonly IntlExtension $intlExtension, private readonly CountryDataProvider $countryDataProvider, + private readonly ParticipantEligibilityService $participantEligibilityService, ) { } @@ -93,4 +96,9 @@ class AppRuntime implements RuntimeExtensionInterface return $this->countryDataProvider->get($nationality)?->nationality; } + + public function isParticipantEligible(BookingDtoInterface $bookingDto, int $participantIndex): bool + { + return $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex); + } } diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index 0dc36e5..a339998 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -63,12 +63,15 @@