Files
myep/docs/AGE_CONSTRAINTS_MODEL_EXTENSION_PLAN.md
T
2026-03-16 11:59:10 +01:00

23 KiB

Age Constraints Model Extension Plan

Current Situation Analysis

XML Data Contains Two Age Constraint Formats:

  1. Absolute Age: <altervon>6</altervon><alterbis>14</alterbis> (current age 6-14)
  2. Birth Year Ranges: <hinweis_stamm>JG:2007-2009</hinweis_stamm> (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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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)

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:

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

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.