wip: age based filtering of options

This commit is contained in:
Björn Fromme
2025-08-27 12:42:02 +02:00
parent 803b10f981
commit 25bbf3d44f
11 changed files with 751 additions and 27 deletions
+44 -10
View File
@@ -105,7 +105,7 @@ class BookingCreateParticipantType extends AbstractType
$submittedData = $event->getData();
$form = $event->getForm();
if (!is_array($submittedData)) {
if (false === is_array($submittedData)) {
return;
}
@@ -118,7 +118,7 @@ class BookingCreateParticipantType extends AbstractType
// Get participant index from form data
$participantData = $form->getData();
if (null === $participantData || !property_exists($participantData, 'index')) {
if (null === $participantData || false === property_exists($participantData, 'index')) {
return;
}
@@ -153,7 +153,7 @@ class BookingCreateParticipantType extends AbstractType
continue;
}
if ($form->has($fieldName)) {
if (true === $form->has($fieldName)) {
$field = $form->get($fieldName);
$currentOptions = $field->getConfig()->getOptions();
@@ -168,7 +168,7 @@ class BookingCreateParticipantType extends AbstractType
}
// Apply body dimension field states to the nested bodyDimensions form
if (!empty($bodyDimensionStates) && $form->has('bodyDimensions')) {
if (false === empty($bodyDimensionStates) && true === $form->has('bodyDimensions')) {
$this->applyBodyDimensionStates($form, $bodyDimensionStates);
}
}
@@ -212,17 +212,51 @@ class BookingCreateParticipantType extends AbstractType
// Get base field options
$fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingDto, $participantIndex);
// Apply dynamic field state if conditions exist
if ($this->fieldStateProvider->hasStateConditions($fieldName)) {
$fieldState = $this->fieldStateProvider->getFieldState($fieldName, $bookingDto, $participantIndex);
$fieldOptions = $this->mergeFieldState($fieldOptions, $fieldState);
}
// Only add the field if there are actually choices/options available
if (true === $this->hasValidFieldOptions($fieldOptions)) {
// Apply dynamic field state if conditions exist
if (true === $this->fieldStateProvider->hasStateConditions($fieldName)) {
$fieldState = $this->fieldStateProvider->getFieldState($fieldName, $bookingDto, $participantIndex);
$fieldOptions = $this->mergeFieldState($fieldOptions, $fieldState);
}
$form->add($fieldName, ChoiceType::class, $fieldOptions);
$form->add($fieldName, ChoiceType::class, $fieldOptions);
}
}
}
}
/**
* Checks if field options contain valid choices for rendering.
*
* This method validates that the field options contain either choices array,
* choice_loader, or other valid choice sources. Empty choice arrays or
* null choice loaders indicate the field should not be rendered.
*
* @param array<string, mixed> $fieldOptions The field options to validate
*
* @return bool True if the field has valid options for rendering, false otherwise
*/
private function hasValidFieldOptions(array $fieldOptions): bool
{
// Check if choices array exists and is not empty
if (false === empty($fieldOptions['choices'])) {
return true;
}
// Check if choice_loader exists and is not null
if (isset($fieldOptions['choice_loader'])) {
return true;
}
// Check for other valid choice sources
if (isset($fieldOptions['choice_list'])) {
return true;
}
return false;
}
/**
* Merges field state modifications into existing field options.
*
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that evaluates whether a participant has provided their date of birth.
*
* This condition checks if a participant has a valid date of birth set, which is
* required for evaluating age-based service constraints and showing age-dependent
* form fields. When no birth date is provided, age-restricted fields should be
* hidden until this prerequisite is met.
*
* The condition is commonly used to control field visibility, ensuring that
* age-dependent services and options are only displayed when the participant's
* age can be calculated.
*/
class DateOfBirthProvidedCondition implements FieldConditionInterface
{
/**
* Evaluates if the participant has provided their date of birth.
*
* Checks if the participant exists and has a non-null dateOfBirth property.
* This is a prerequisite for showing age-dependent form fields and services.
*
* @param BookingDtoInterface $bookingDto The current booking data
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused for this condition)
*
* @return bool True if the participant has provided their date of birth, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
{
$participant = $bookingDto->getParticipant($participantIndex);
return null !== $participant && null !== $participant->dateOfBirth;
}
/**
* Returns field names that this condition depends on.
*
* The date of birth condition depends on the participant's dateOfBirth field.
* When this field changes, any conditions based on birth date availability
* should be re-evaluated.
*
* @return string[] Array containing 'dateOfBirth' field name
*/
public function getDependentFields(): array
{
return ['dateOfBirth'];
}
/**
* Returns a human-readable description of this condition.
*
* Provides a clear description of what this condition checks for,
* useful for debugging and understanding field state logic.
*
* @return string Description of the date of birth requirement
*/
public function getDescription(): string
{
return 'Participant must provide their date of birth';
}
}
+25 -2
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Service\Abstract\AbstractFieldStateProvider;
use App\Form\Service\Condition\CompositeCondition;
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
use App\Form\Service\Condition\RentalSelectionCondition;
/**
@@ -14,8 +16,9 @@ use App\Form\Service\Condition\RentalSelectionCondition;
* for participant fields in the create flow. It extends the common field
* state functionality provided by AbstractFieldStateProvider.
*
* Currently, no specific field state conditions are implemented for the
* create workflow, but the infrastructure is ready for future additions.
* Current field state conditions:
* - Body dimension fields become required when rental services are selected
* - Age-dependent service fields are hidden until birth date is provided
*/
class CreateFieldStateProvider extends AbstractFieldStateProvider
{
@@ -60,6 +63,26 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
'required' => $rentalCondition,
];
// 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
$this->fieldStateConditions['courses'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
];
$this->fieldStateConditions['additionalServices'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
];
$this->fieldStateConditions['rentals'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
];
$this->fieldStateConditions['board'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
];
// Example field state conditions would be registered here
// For demonstration purposes, here are some example patterns:
@@ -10,6 +10,7 @@ use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractFieldOptionsProvider;
use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory;
use App\Form\Service\ServiceAgeEvaluator;
/**
* Provides dynamic field options for participant form fields.
@@ -40,9 +41,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
*
* @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders
*/
public function __construct(
private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory,
) {
public function __construct(private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory)
{
parent::__construct();
}
@@ -86,23 +86,31 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
: null,
];
// Courses field provider - provides available courses from travel data
$this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto) => [
// Courses field provider - provides age-appropriate courses from travel data
$this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
'label' => 'Kurse',
'multiple' => true,
'expanded' => true,
'required' => false,
'choices' => $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES),
'choices' => $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES),
$bookingDto,
$participantIndex
),
'choice_label' => 'label',
];
// Additional services field provider - provides additional services with mandatory pre-selection
$this->fieldOptionProviders['additionalServices'] = fn (BookingDtoInterface $bookingDto) => [
// Additional services field provider - provides age-appropriate additional services with mandatory pre-selection
$this->fieldOptionProviders['additionalServices'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
'label' => 'Zusatzleistungen',
'multiple' => true,
'expanded' => true,
'required' => false,
'choices' => $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL),
'choices' => $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL),
$bookingDto,
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
'choice_attr' => function (?Service $service) {
@@ -124,23 +132,31 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
},
];
// Board field provider - provides available board options from travel data
$this->fieldOptionProviders['board'] = fn (BookingDtoInterface $bookingDto) => [
// Board field provider - provides age-appropriate board options from travel data
$this->fieldOptionProviders['board'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
'label' => 'Verpflegung',
'multiple' => true,
'expanded' => true,
'required' => false,
'choices' => $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD),
'choices' => $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD),
$bookingDto,
$participantIndex
),
'choice_label' => 'label',
];
// Rentals field provider - provides available rental options from travel data filtered by date range
$this->fieldOptionProviders['rentals'] = fn (BookingDtoInterface $bookingDto) => [
// Rentals field provider - provides age-appropriate rental options from travel data filtered by date range
$this->fieldOptionProviders['rentals'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
'label' => 'Leihmaterial',
'multiple' => true,
'expanded' => true,
'required' => false,
'choices' => $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true, true),
'choices' => $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true, true),
$bookingDto,
$participantIndex
),
'choice_label' => 'label',
];
@@ -155,4 +171,38 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// ],
// ];
}
/**
* 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 Services to filter
* @param BookingDtoInterface $bookingDto Booking data containing participant info
* @param int $participantIndex Index of participant to evaluate
*
* @return array Filtered services array
*/
private function filterServicesByAgeConstraints(array $services, BookingDtoInterface $bookingDto, int $participantIndex): array
{
$ageEvaluator = new ServiceAgeEvaluator();
$participant = $bookingDto->getParticipant($participantIndex);
// If no birth date 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);
});
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
/**
* Evaluates service availability based on participant age constraints.
*
* This service handles the business logic of determining whether a service
* is available to a specific participant based on their age or birth year.
* It supports both absolute age constraints and birth year range constraints.
*/
class ServiceAgeEvaluator
{
/**
* Determines if this evaluator can process the given service.
*
* @param Service $service The service to evaluate
*
* @return bool True if the service has age constraints that can be evaluated
*/
public function canEvaluate(Service $service): bool
{
return null !== $service->ageConstraintType;
}
/**
* Evaluates if a service is available for a specific participant.
*
* Checks the participant's age or birth year against the service's
* constraint requirements. Returns false if the participant doesn't
* meet the age requirements or if no birth date is provided.
*
* @param Service $service The service to evaluate
* @param BookingDtoInterface $bookingDto The booking containing participant data
* @param int $participantIndex The index of the participant to evaluate
*
* @return bool True if the service is available for the participant
*/
public function isServiceAvailableForParticipant(Service $service, BookingDtoInterface $bookingDto, int $participantIndex): bool
{
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant || null === $participant->dateOfBirth) {
return false; // Cannot evaluate without birth date
}
return match($service->ageConstraintType) {
'absolute_age' => $this->evaluateAbsoluteAge($service, $participant->dateOfBirth),
'birth_year' => $this->evaluateBirthYear($service, $participant->dateOfBirth),
'mixed' => $this->evaluateAbsoluteAge($service, $participant->dateOfBirth)
&& $this->evaluateBirthYear($service, $participant->dateOfBirth),
default => true // No constraints or unknown type
};
}
/**
* Evaluates absolute age constraints against participant's current age.
*
* @param Service $service The service with age constraints
* @param \DateTimeImmutable $dateOfBirth The participant's date of birth
*
* @return bool True if the participant meets the absolute age requirements
*/
private function evaluateAbsoluteAge(Service $service, \DateTimeImmutable $dateOfBirth): bool
{
$age = $this->calculateAge($dateOfBirth);
if (null !== $service->ageFrom && $age < $service->ageFrom) {
return false;
}
if (null !== $service->ageTo && $age > $service->ageTo) {
return false;
}
return true;
}
/**
* Evaluates birth year constraints against participant's birth year.
*
* @param Service $service The service with birth year constraints
* @param \DateTimeImmutable $dateOfBirth The participant's date of birth
*
* @return bool True if the participant meets the birth year requirements
*/
private function evaluateBirthYear(Service $service, \DateTimeImmutable $dateOfBirth): bool
{
$birthYear = (int) $dateOfBirth->format('Y');
if (null !== $service->birthYearFrom && $birthYear < $service->birthYearFrom) {
return false;
}
if (null !== $service->birthYearTo && $birthYear > $service->birthYearTo) {
return false;
}
return true;
}
/**
* Calculates age in years from a date of birth.
*
* Uses DateTimeImmutable to ensure accurate age calculations accounting
* for leap years and exact birth date anniversaries.
*
* @param \DateTimeImmutable $dateOfBirth The participant's date of birth
*
* @return int The calculated age in complete years
*/
private function calculateAge(\DateTimeImmutable $dateOfBirth): int
{
$today = new \DateTimeImmutable();
return (int) $dateOfBirth->diff($today)->y;
}
/**
* Gets a human-readable description of the service's age constraints.
*
* Generates descriptive text explaining the age requirements,
* useful for user interfaces and debugging.
*
* @param Service $service The service to describe
*
* @return string Description of the age constraints
*/
public function getConstraintDescription(Service $service): string
{
return match($service->ageConstraintType) {
'absolute_age' => $this->getAbsoluteAgeDescription($service),
'birth_year' => $this->getBirthYearDescription($service),
'mixed' => sprintf('%s and %s',
$this->getAbsoluteAgeDescription($service),
$this->getBirthYearDescription($service)),
default => 'No age restrictions'
};
}
/**
* Gets description for absolute age constraints.
*
* @param Service $service The service with age constraints
*
* @return string Description of absolute age requirements
*/
private function getAbsoluteAgeDescription(Service $service): string
{
if (null !== $service->ageFrom && null !== $service->ageTo) {
return sprintf('Ages %d-%d', $service->ageFrom, $service->ageTo);
}
if (null !== $service->ageFrom) {
return sprintf('Age %d+', $service->ageFrom);
}
if (null !== $service->ageTo) {
return sprintf('Age up to %d', $service->ageTo);
}
return '';
}
/**
* Gets description for birth year constraints.
*
* @param Service $service The service with birth year constraints
*
* @return string Description of birth year requirements
*/
private function getBirthYearDescription(Service $service): string
{
if (null !== $service->birthYearFrom && null !== $service->birthYearTo) {
if ($service->birthYearFrom === $service->birthYearTo) {
return sprintf('Born in %d', $service->birthYearFrom);
}
return sprintf('Born %d-%d', $service->birthYearFrom, $service->birthYearTo);
}
if (null !== $service->birthYearFrom) {
return sprintf('Born %d or later', $service->birthYearFrom);
}
if (null !== $service->birthYearTo) {
return sprintf('Born up to %d', $service->birthYearTo);
}
return '';
}
}