23 KiB
Age Constraints Model Extension Plan
Current Situation Analysis
XML Data Contains Two Age Constraint Formats:
- Absolute Age:
<altervon>6</altervon><alterbis>14</alterbis>(current age 6-14) - Birth Year Ranges:
<hinweis_stamm>JG:2007-2009</hinweis_stamm>(birth years 2007-2009)
Future Considerations:
hinweis_stammmay contain additional constraint types beyondJG:(birth year)- Need extensible parsing system for future constraint formats
- Maintain English naming conventions throughout
Current State:
Servicemodel hasageFrom/ageToproperties but they're not populatedTravelParserdoesn'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
- Create extensible constraint parser system (interfaces, registry, birth year parser)
- Extend Service model with new age constraint properties
- Update TravelParser with extensible constraint parsing
- Create age evaluator system for service filtering
- Update field options provider with age-aware filtering
- Add comprehensive tests for parsing and evaluation
- Add helper methods to parser base class
- 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:2007andJG:2007-2009formats
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.