# Age Constraints Model Extension Plan ## Current Situation Analysis **XML Data Contains Two Age Constraint Formats:** 1. **Absolute Age**: `614` (current age 6-14) 2. **Birth Year Ranges**: `JG:2007-2009` (birth years 2007-2009) **Future Considerations:** - `hinweis_stamm` may contain additional constraint types beyond `JG:` (birth year) - Need extensible parsing system for future constraint formats - Maintain English naming conventions throughout **Current State:** - `Service` model has `ageFrom`/`ageTo` properties but they're not populated - `TravelParser` doesn't parse age-related XML nodes - Form processing doesn't consider age constraints ## Required Changes ### 1. Extend Service Model with Extensible Age Constraints **Add New Properties for Flexible Age Constraints:** ```php // Add to Service class (src/BusProNet/Model/Service.php) #[Groups(['api:single', 'api:list'])] public ?int $birthYearFrom = null; #[Groups(['api:single', 'api:list'])] public ?int $birthYearTo = null; #[Groups(['api:single', 'api:list'])] public ?string $ageConstraintType = null; // 'absolute_age', 'birth_year', 'mixed' #[Groups(['api:single'])] public ?array $ageConstraintMetadata = null; // Extensible metadata for future constraint types #[Groups(['api:single'])] public ?string $rawAgeConstraintData = null; // Store original XML data for debugging/future parsing ``` ### 2. Create Extensible Age Constraint Parser System **A. Create Age Constraint Parser Interface:** ```php // src/BusProNet/XmlParser/Contract/AgeConstraintParserInterface.php interface AgeConstraintParserInterface { public function canParse(string $constraintData): bool; public function parse(string $constraintData): AgeConstraintResult; public function getConstraintType(): string; } ``` **B. Create Age Constraint Result DTO:** ```php // src/BusProNet/XmlParser/Model/AgeConstraintResult.php class AgeConstraintResult { public function __construct( public readonly string $type, public readonly ?int $ageFrom = null, public readonly ?int $ageTo = null, public readonly ?int $birthYearFrom = null, public readonly ?int $birthYearTo = null, public readonly array $metadata = [], public readonly ?string $rawData = null ) {} public function hasAgeConstraints(): bool { return null !== $this->ageFrom || null !== $this->ageTo; } public function hasBirthYearConstraints(): bool { return null !== $this->birthYearFrom || null !== $this->birthYearTo; } public function isEmpty(): bool { return !$this->hasAgeConstraints() && !$this->hasBirthYearConstraints(); } } ``` **C. Create Birth Year Constraint Parser:** ```php // src/BusProNet/XmlParser/AgeConstraint/BirthYearConstraintParser.php class BirthYearConstraintParser implements AgeConstraintParserInterface { private const BIRTH_YEAR_PREFIX = 'JG:'; public function canParse(string $constraintData): bool { return str_starts_with($constraintData, self::BIRTH_YEAR_PREFIX); } public function parse(string $constraintData): AgeConstraintResult { if (!$this->canParse($constraintData)) { throw new InvalidArgumentException('Cannot parse constraint data: ' . $constraintData); } $yearData = substr($constraintData, strlen(self::BIRTH_YEAR_PREFIX)); // Parse range format "2007-2009" if (str_contains($yearData, '-')) { [$fromYear, $toYear] = explode('-', $yearData, 2); return new AgeConstraintResult( type: 'birth_year', birthYearFrom: (int) trim($fromYear), birthYearTo: (int) trim($toYear), metadata: [ 'range_type' => 'birth_year_range', 'original_format' => $yearData ], rawData: $constraintData ); } // Parse single year format "2007" $year = (int) trim($yearData); return new AgeConstraintResult( type: 'birth_year', birthYearFrom: $year, birthYearTo: $year, metadata: [ 'range_type' => 'birth_year_single', 'original_format' => $yearData ], rawData: $constraintData ); } public function getConstraintType(): string { return 'birth_year'; } } ``` **D. Create Age Constraint Parser Registry:** ```php // src/BusProNet/XmlParser/AgeConstraint/AgeConstraintParserRegistry.php class AgeConstraintParserRegistry { /** @var AgeConstraintParserInterface[] */ private array $parsers = []; public function __construct() { // Register built-in parsers $this->addParser(new BirthYearConstraintParser()); } public function addParser(AgeConstraintParserInterface $parser): void { $this->parsers[] = $parser; } public function parseConstraints(string $constraintData): AgeConstraintResult { // Try multiple constraint types (semicolon-separated, e.g., 'JG:2007-2009;GL:5-8') $constraints = array_map('trim', explode(';', $constraintData)); $results = []; foreach ($constraints as $constraint) { if (empty($constraint)) { continue; } foreach ($this->parsers as $parser) { if ($parser->canParse($constraint)) { $results[] = $parser->parse($constraint); break; // First matching parser wins } } } // Merge results if multiple constraints found return $this->mergeConstraintResults($results, $constraintData); } private function mergeConstraintResults(array $results, string $rawData): AgeConstraintResult { if (empty($results)) { return new AgeConstraintResult(type: 'unknown', rawData: $rawData); } if (count($results) === 1) { return $results[0]; } // Merge multiple constraint results $type = 'mixed'; $ageFrom = null; $ageTo = null; $birthYearFrom = null; $birthYearTo = null; $metadata = ['merged_from' => []]; foreach ($results as $result) { $ageFrom = $this->mergeMinValue($ageFrom, $result->ageFrom); $ageTo = $this->mergeMaxValue($ageTo, $result->ageTo); $birthYearFrom = $this->mergeMinValue($birthYearFrom, $result->birthYearFrom); $birthYearTo = $this->mergeMaxValue($birthYearTo, $result->birthYearTo); $metadata['merged_from'][] = $result->type; } return new AgeConstraintResult( type: $type, ageFrom: $ageFrom, ageTo: $ageTo, birthYearFrom: $birthYearFrom, birthYearTo: $birthYearTo, metadata: $metadata, rawData: $rawData ); } private function mergeMinValue(?int $current, ?int $new): ?int { if (null === $current) return $new; if (null === $new) return $current; return max($current, $new); // Most restrictive minimum } private function mergeMaxValue(?int $current, ?int $new): ?int { if (null === $current) return $new; if (null === $new) return $current; return min($current, $new); // Most restrictive maximum } } ``` ### 3. Enhance TravelParser with Extensible Constraint Parsing **Add Age Constraint Parsing to Service Methods:** ```php // Add to TravelParser class private AgeConstraintParserRegistry $ageConstraintRegistry; public function __construct() { $this->ageConstraintRegistry = new AgeConstraintParserRegistry(); // Future: inject via DI for custom parsers } // Update getAdditionalServices() method: private function parseServiceAgeConstraints(Crawler $serviceNode, Service $service): void { // Parse absolute age constraints (altervon/alterbis) $ageFrom = $this->getIntOrNullValue($serviceNode->filterXPath('.//altervon')); $ageTo = $this->getIntOrNullValue($serviceNode->filterXPath('.//alterbis')); // Parse extensible constraint data (hinweis_stamm -> ageConstraintData) $constraintData = $this->getStringOrNullValue($serviceNode->filterXPath('.//hinweis_stamm')); $constraintResult = null; if (null !== $constraintData && !empty(trim($constraintData))) { $constraintResult = $this->ageConstraintRegistry->parseConstraints($constraintData); } // Apply absolute age constraints if (null !== $ageFrom || null !== $ageTo) { $service->ageFrom = $ageFrom; $service->ageTo = $ageTo; if (null !== $constraintResult && !$constraintResult->isEmpty()) { // Mixed constraints scenario $service->ageConstraintType = 'mixed'; $service->birthYearFrom = $constraintResult->birthYearFrom; $service->birthYearTo = $constraintResult->birthYearTo; $service->ageConstraintMetadata = array_merge( $constraintResult->metadata, ['has_absolute_age' => true, 'has_birth_year' => true] ); } else { $service->ageConstraintType = 'absolute_age'; } } elseif (null !== $constraintResult && !$constraintResult->isEmpty()) { // Only constraint data (birth year, etc.) $service->ageConstraintType = $constraintResult->type; $service->birthYearFrom = $constraintResult->birthYearFrom; $service->birthYearTo = $constraintResult->birthYearTo; $service->ageConstraintMetadata = $constraintResult->metadata; } // Always store raw data for debugging/future parsing if (null !== $constraintData) { $service->rawAgeConstraintData = $constraintData; } } // Update getAdditionalServices() method: public function getAdditionalServices(Crawler $node): array { $additionalServices = []; $node->each(function (Crawler $serviceNode) use (&$additionalServices) { $serviceId = (int) $serviceNode->attr('idbuspro'); $service = new Service(); $service->source = Constants::SOURCE_TRAVEL; $service->category = Constants::CATEGORY_ADDITIONAL; $service->id = $serviceId; $service->subType = $serviceNode->attr('unterart'); $service->mandatory = $this->stringToBool($serviceNode->attr('pflicht')); $service->dateFrom = $this->stringToDate($serviceNode->attr('termin')); $service->dateTo = $this->stringToDate($serviceNode->attr('bis')); $service->label = $this->getStringOrNullValue($serviceNode->filterXPath('.//text')); $service->price = $this->stringToFloat($this->getStringOrNullValue($serviceNode->filterXPath('.//preis'))); $service->status = $this->getStringOrNullValue($serviceNode->filterXPath('.//status')); // Parse age constraints $this->parseServiceAgeConstraints($serviceNode, $service); $additionalServices[$serviceId] = $service; }); return $additionalServices; } ``` ### 4. Create Extensible Age Evaluation System **A. Enhanced Age Evaluation Interface:** ```php // src/Form/Service/Contract/AgeEvaluatorInterface.php interface AgeEvaluatorInterface { public function canEvaluate(Service $service): bool; public function isServiceAvailableForParticipant(Service $service, BookingDtoInterface $bookingDto, int $participantIndex): bool; public function getConstraintDescription(Service $service): string; } ``` **B. Create Service Age Evaluator:** ```php // src/Form/Service/AgeEvaluator/ServiceAgeEvaluator.php class ServiceAgeEvaluator implements AgeEvaluatorInterface { public function canEvaluate(Service $service): bool { return null !== $service->ageConstraintType; } 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 }; } 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; } 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; } private function calculateAge(\DateTimeImmutable $dateOfBirth): int { $today = new \DateTimeImmutable(); return (int) $dateOfBirth->diff($today)->y; } 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' }; } 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 ''; } 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 ''; } } ``` ### 5. Enhanced Form Field Options Provider **Update with Age-Aware Service Filtering:** ```php // Add to ParticipantFieldOptionsProvider (simplified approach) // ServiceAgeEvaluator is instantiated directly when needed protected function registerFieldOptionProviders(): void { // Enhanced field providers with age-aware filtering $this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ 'label' => 'Kurse', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES), $bookingDto, $participantIndex ), 'choice_label' => 'label', ]; // Similar updates for additionalServices, rentals, board, etc. } private function filterServicesByAgeConstraints(array $services, BookingDtoInterface $bookingDto, int $participantIndex): array { $participant = $bookingDto->getParticipant($participantIndex); // If no birth date provided, return empty array (handled by DateOfBirthProvidedCondition) if (null === $participant || null === $participant->dateOfBirth) { return []; } return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) { // No age constraints = available to all $ageEvaluator = new ServiceAgeEvaluator(); if (!$ageEvaluator->canEvaluate($service)) { return true; } return $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex); }); } ``` ### 6. Future Extension Examples **A. Example: Adding Grade Level Constraints (GL:5-8)** ```php class GradeLevelConstraintParser implements AgeConstraintParserInterface { private const GRADE_PREFIX = 'GL:'; public function canParse(string $constraintData): bool { return str_starts_with($constraintData, self::GRADE_PREFIX); } public function parse(string $constraintData): AgeConstraintResult { $gradeData = substr($constraintData, strlen(self::GRADE_PREFIX)); if (str_contains($gradeData, '-')) { [$fromGrade, $toGrade] = explode('-', $gradeData, 2); return new AgeConstraintResult( type: 'grade_level', metadata: [ 'grade_from' => (int) trim($fromGrade), 'grade_to' => (int) trim($toGrade), 'constraint_type' => 'grade_range' ], rawData: $constraintData ); } // Single grade return new AgeConstraintResult( type: 'grade_level', metadata: [ 'grade' => (int) trim($gradeData), 'constraint_type' => 'grade_single' ], rawData: $constraintData ); } public function getConstraintType(): string { return 'grade_level'; } } // Register in registry constructor: $this->addParser(new GradeLevelConstraintParser()); ``` **B. Example: Complex Mixed Constraints (JG:2007-2009;GL:5-8)** - Registry automatically handles semicolon-separated constraints - Merges results into mixed constraint type - Evaluator can handle multiple constraint types ### 7. Add Helper Methods **Add to AbstractParser:** ```php protected function getIntOrNullValue(Crawler $node): ?int { $value = $this->getStringOrNullValue($node); if (null === $value || '' === trim($value)) { return null; } return (int) $value; } ``` ## Implementation Order 1. **Create extensible constraint parser system** (interfaces, registry, birth year parser) 2. **Extend Service model** with new age constraint properties 3. **Update TravelParser** with extensible constraint parsing 4. **Create age evaluator system** for service filtering 5. **Update field options provider** with age-aware filtering 6. **Add comprehensive tests** for parsing and evaluation 7. **Add helper methods** to parser base class 8. **Update documentation** with extensible patterns ## Key Benefits ### Technical Benefits - **Fully extensible** - easy to add new constraint types (grade level, membership status, etc.) - **Backward compatible** - existing absolute age constraints continue working - **English naming** - all properties and methods use clear English names - **Robust parsing** - handles malformed data gracefully - **Debuggable** - stores raw constraint data for troubleshooting - **Testable** - clear separation of parsing, evaluation, and filtering concerns ### Future Extensibility - **Plugin architecture** - new constraint parsers can be added via DI - **Mixed constraints** - supports multiple constraint types per service - **Metadata storage** - extensible metadata for complex constraint types - **Version resilient** - unknown constraint types don't break existing functionality ### Business Benefits - **Accurate service filtering** - services only shown to eligible participants - **Clear constraint communication** - descriptive messages for age restrictions - **Flexible business rules** - supports complex eligibility scenarios ## Edge Cases Handled - **Invalid constraint formats** - graceful handling with fallback to 'unknown' type - **Mixed constraint scenarios** - services with both absolute age and birth year requirements - **Empty/null constraint data** - treated as no constraints (available to all) - **Future constraint types** - unknown parsers don't break existing functionality - **Malformed date ranges** - validation and error handling in parsers - **Single vs range values** - supports both `JG:2007` and `JG:2007-2009` formats ## Testing Strategy ### Unit Tests - **Constraint parsing** for all supported formats and edge cases - **Age evaluation** for different constraint types and participant scenarios - **Service filtering** with mixed constraint types - **Registry behavior** with multiple parsers and constraint merging ### Integration Tests - **XML parsing** with real BPN export data containing age constraints - **Form field generation** with age-restricted services - **HTMX updates** when birth date changes affect service availability - **End-to-end booking flow** with age-restricted services ## Related Completed Improvements **✅ Form Processing System Enhancements** (complementary to age constraints): - **Service Field HTMX Integration**: Fixed HTMX triggers for service fields (board, skipass, courses, etc.) to enable real-time updates for age-based field filtering - **Field Handler Data Storage**: Updated all service field handlers to store complete Service objects instead of IDs, enabling access to age constraint data - **Service Label Formatting**: Implemented smart service label formatting with pricing integration and quantity display - **Pricing Integration**: Service selections now properly integrate with pricing calculations, supporting age-restricted service pricing These improvements provide the foundation for implementing age constraint filtering once the XML parsing and model extensions described in this plan are completed. This plan provides a robust, extensible foundation for handling current age constraints while being prepared for future constraint types that may emerge from the XML data.