Files
myep/src/Form/Service/ParticipantFieldHandlerRegistry.php
T

636 lines
26 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Contract\ParticipantFieldHandlerInterface;
/**
* Registry for managing and executing participant field handlers in dependency order.
*
* This class ensures that participant field handlers are executed in the correct order
* based on their dependencies, preventing issues where a field depends on
* another field that hasn't been processed yet.
*/
class ParticipantFieldHandlerRegistry
{
private const MUTABILITY_ADDITIONAL_SERVICES = 'additional_services';
private const MUTABILITY_TRANSPORTATION = 'transportation';
private const MUTABILITY_PICKUPS = 'pickups';
/**
* Maps handler names to mutability categories for edit-mode enforcement.
*
* @var array<string, string>
*/
private const HANDLER_MUTABILITY_CATEGORY_MAP = [
'additionalServices' => self::MUTABILITY_ADDITIONAL_SERVICES,
'courses' => self::MUTABILITY_ADDITIONAL_SERVICES,
'board' => self::MUTABILITY_ADDITIONAL_SERVICES,
'veg' => self::MUTABILITY_ADDITIONAL_SERVICES,
'skiPass' => self::MUTABILITY_ADDITIONAL_SERVICES,
'rentals' => self::MUTABILITY_ADDITIONAL_SERVICES,
'rentalInsurance' => self::MUTABILITY_ADDITIONAL_SERVICES,
'transportationOutbound' => self::MUTABILITY_TRANSPORTATION,
'transportationInbound' => self::MUTABILITY_TRANSPORTATION,
'parking' => self::MUTABILITY_TRANSPORTATION,
'licensePlate' => self::MUTABILITY_TRANSPORTATION,
'transportationDiscountReplacement' => self::MUTABILITY_TRANSPORTATION,
'pickup' => self::MUTABILITY_PICKUPS,
'dropOff' => self::MUTABILITY_PICKUPS,
];
/** @var array<string, ParticipantFieldHandlerInterface> Registered handlers indexed by field name */
private array $handlers = [];
/** @var string[]|null Cached array of handler names sorted by dependency order */
private ?array $sortedHandlers = null;
/**
* Initializes the registry with a hybrid array of handlers.
*
* This constructor supports both simple handlers (passed as class names) and
* complex handlers (passed as instantiated service objects). Simple handlers
* with no dependencies can be passed as strings and will be instantiated
* automatically. Complex handlers with dependencies should be passed as
* already-instantiated objects via dependency injection.
*
* @param array<ParticipantFieldHandlerInterface|string> $handlers Array of handler instances or class names
*
* @throws \Exception If a class name cannot be instantiated
*/
public function __construct(array $handlers)
{
foreach ($handlers as $handler) {
if (is_string($handler)) {
// It's a class name - instantiate it (for simple handlers without dependencies)
$handler = new $handler();
}
// If it's already an object (service), use as-is (for complex handlers with dependencies)
$this->addHandler($handler);
}
}
/**
* Registers a participant field handler with the registry.
*
* Handlers are indexed by their field name to ensure uniqueness and enable
* fast lookups. Adding a handler invalidates the dependency sort cache,
* forcing a re-sort on the next processing request.
*
* @param ParticipantFieldHandlerInterface $handler The handler to register
*/
public function addHandler(ParticipantFieldHandlerInterface $handler): void
{
$this->handlers[$handler->getFieldName()] = $handler;
$this->sortedHandlers = null; // Reset cache to force re-sorting with new handler
}
/**
* Processes all participant fields and synchronizes submitted data with cleaned DTO state.
*
* This method combines field processing with data synchronization to ensure that
* the submitted form data reflects any changes made by field handlers. This is
* particularly useful for HTMX form updates where invalid selections need to be
* automatically cleared.
*
* @param array<string, mixed> $submittedData The submitted form data containing participants array
* @param BookingDto $bookingDto The booking DTO to update with processed field values
*
* @return array<string, mixed> The synchronized submitted data reflecting DTO changes
*/
public function processFieldsAndSync(array $submittedData, BookingDto $bookingDto): array
{
// Process all field handlers to clean the DTO
$this->processFields($submittedData, $bookingDto);
// Synchronize submitted data with the cleaned DTO state
return $this->syncSubmittedDataWithDto($submittedData, $bookingDto);
}
/**
* Processes all participant fields from submitted form data using registered handlers.
*
* This is the main entry point for field processing. It processes handlers in dependency
* order, applying each handler to ALL participants before moving to the next handler.
* This ensures that cross-participant logic (like family booking detection) has access
* to complete data from all participants.
*
* Processing order: handler-first, then participants
* - Process handler A for all participants
* - Process handler B for all participants
* - etc.
*
* This is critical for handlers that depend on booking-level state (like insurance
* family detection which needs all participants' ages to be processed first).
*
* @param array<string, mixed> $submittedData The submitted form data containing participants array
* @param BookingDto $bookingDto The booking DTO to update with processed field values (create or edit)
*/
public function processFields(array $submittedData, BookingDto $bookingDto): void
{
// Early return if no participant data exists in submission
if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) {
return;
}
// Get handlers sorted by dependency order (uses cache if available)
$sortedHandlerNames = $this->getSortedHandlers();
// Process each handler across all participants before moving to the next handler
// This ensures booking-level state (like family booking detection) is accurate
foreach ($sortedHandlerNames as $handlerName) {
if (false === $this->isHandlerMutableForBooking($handlerName, $bookingDto)) {
continue;
}
$handler = $this->handlers[$handlerName];
// Apply this handler to all participants
foreach ($submittedData['participants'] as $participantIndex => $participantData) {
// Skip invalid participant data
if (false === is_array($participantData)) {
continue;
}
// Let each handler decide if it should process this participant's data
if ($handler->shouldProcess($participantData, $bookingDto->getMode(), (int) $participantIndex)) {
$handler->processField($participantData, $bookingDto, (int) $participantIndex);
}
}
}
}
/**
* Processes field handlers for a single participant.
*
* This method is used by the card-based booking flows where participants are
* edited individually. Unlike processFields() which processes all participants,
* this method applies all handlers to only the specified participant.
*
* Handlers are executed in dependency order to ensure proper data consistency.
*
* @param array<string, mixed> $participantData Submitted data for one participant
* @param BookingDto $bookingDto The booking DTO to update
* @param int $participantIndex Index of participant to process
*/
public function processFieldsForParticipant(array $participantData, BookingDto $bookingDto, int $participantIndex): void
{
// Get handlers sorted by dependency order (uses cache if available)
$sortedHandlerNames = $this->getSortedHandlers();
// Apply each handler to the specified participant
foreach ($sortedHandlerNames as $handlerName) {
if (false === $this->isHandlerMutableForBooking($handlerName, $bookingDto)) {
continue;
}
$handler = $this->handlers[$handlerName];
// Let each handler decide if it should process this participant's data
if ($handler->shouldProcess($participantData, $bookingDto->getMode(), $participantIndex)) {
$handler->processField($participantData, $bookingDto, $participantIndex);
}
}
}
/**
* Processes field handlers for a single participant and synchronizes submitted data.
*
* This method combines field processing with data synchronization to ensure that
* the submitted form data reflects any changes made by field handlers. This is
* essential for handlers that modify fields (like discount replacement) to have
* their changes reflected in the form binding.
*
* Only fields that are actually modified by handlers (detected by comparing
* submitted ID with DTO value after processing) are synchronized. This prevents
* overwriting partial user input (like incomplete dates).
*
* @param array<string, mixed> $participantData Submitted data for one participant
* @param BookingDto $bookingDto The booking DTO to update
* @param int $participantIndex Index of participant to process
*
* @return array<string, mixed> The synchronized participant data reflecting DTO changes
*/
public function processFieldsForParticipantAndSync(array $participantData, BookingDto $bookingDto, int $participantIndex): array
{
// Process all field handlers for this participant
$this->processFieldsForParticipant($participantData, $bookingDto, $participantIndex);
// Synchronize only modified service fields with the cleaned DTO state
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
return $participantData;
}
return $this->syncModifiedServiceFields($participantData, $participant);
}
/**
* Synchronizes only service fields that were modified by handlers.
*
* This method compares the submitted service ID with the DTO value after processing.
* Only fields where the handler assigned a different service are synchronized back.
* This prevents overwriting user input for fields like dateOfBirth where the handler
* just parses the value without changing it.
*
* @param array<string, mixed> $participantData The submitted participant data
* @param ParticipantDto $participant The processed participant DTO
*
* @return array<string, mixed> Updated participant data with modified fields synchronized
*/
private function syncModifiedServiceFields(array $participantData, ParticipantDto $participant): array
{
// Define fields that may be reassigned by handlers and need syncing
$serviceFields = [
'transportationOutbound',
'transportationInbound',
'insurance',
'skiPass',
'rentals',
'courses',
'additionalServices',
'board',
'veg',
'pickup',
'dropOff',
'parking',
'licensePlate',
// Cleared by the voucher handler once applied or removed; must not be bound again
'voucherCode',
'removeVoucherCode',
];
foreach ($serviceFields as $fieldName) {
if (false === array_key_exists($fieldName, $participantData)) {
continue;
}
if (false === property_exists($participant, $fieldName)) {
continue;
}
$dtoValue = $participant->{$fieldName};
$submittedValue = $participantData[$fieldName];
// Check if the DTO value differs from submitted value
if ($this->hasFieldValueChanged($submittedValue, $dtoValue)) {
$participantData[$fieldName] = $this->convertDtoValueToSubmittedFormat($dtoValue);
}
}
return $participantData;
}
/**
* Checks if a field value was changed by a handler.
*
* Compares the submitted value (usually an ID string) with the DTO value
* (usually a Service/Insurance object) to detect if a handler reassigned it.
*
* @param mixed $submittedValue The original submitted value (ID or array of IDs)
* @param mixed $dtoValue The DTO value after handler processing
*
* @return bool True if the values differ (handler modified the field)
*/
private function hasFieldValueChanged(mixed $submittedValue, mixed $dtoValue): bool
{
// Handle empty string as null (form submits empty string for unselected)
if ('' === $submittedValue) {
$submittedValue = null;
}
// Handle null cases
if (null === $dtoValue && null === $submittedValue) {
return false;
}
if (null === $dtoValue || null === $submittedValue) {
return true;
}
// Handle arrays (multiple selections like rentals, courses)
if (is_array($submittedValue) && is_array($dtoValue)) {
$submittedIds = array_map('strval', $submittedValue);
$dtoIds = array_map(fn ($item) => $this->extractId($item), $dtoValue);
sort($submittedIds);
sort($dtoIds);
return $submittedIds !== $dtoIds;
}
// Handle single service/insurance objects
$dtoId = $this->extractId($dtoValue);
// Compare as integers for reliable comparison (form may submit string, DTO has int)
return (int) $submittedValue !== (int) $dtoId;
}
/**
* Extracts the ID from a service, insurance, or pickup object.
*
* @param mixed $value The value to extract ID from
*
* @return string|null The extracted ID or null
*/
private function extractId(mixed $value): ?string
{
if ($value instanceof Service || $value instanceof Insurance || $value instanceof Pickup) {
return (string) $value->id;
}
if (is_scalar($value)) {
return (string) $value;
}
return null;
}
/**
* Returns handler names sorted by dependency order using cached results when possible.
*
* This method implements lazy loading with caching for performance. The dependency
* sort is only performed once and the result is cached until handlers are added
* or modified.
*
* @return string[] Array of handler field names in dependency execution order
*/
private function getSortedHandlers(): array
{
// Return cached result if available
if (null !== $this->sortedHandlers) {
return $this->sortedHandlers;
}
// Perform topological sort and cache the result
$this->sortedHandlers = $this->topologicalSort();
return $this->sortedHandlers;
}
/**
* Performs topological sort to determine safe handler execution order.
*
* This method uses Kahn's algorithm to sort handlers based on their declared
* dependencies. It ensures that no handler is executed before its dependencies
* have been processed, preventing data consistency issues.
*
* The algorithm works by:
* 1. Building a dependency graph of handlers
* 2. Finding handlers with no dependencies (in-degree = 0)
* 3. Iteratively removing handlers and updating dependencies
* 4. Detecting circular dependencies (deadlock prevention)
*
* @return string[] Array of handler field names in safe execution order
*
* @throws \InvalidArgumentException When a handler depends on a non-existent handler
* @throws \InvalidArgumentException When circular dependencies are detected
*/
private function topologicalSort(): array
{
$inDegree = []; // Count of dependencies for each handler
$graph = []; // Adjacency list of handler dependencies
$handlerNames = array_keys($this->handlers);
// Initialize all handlers with zero dependencies
foreach ($handlerNames as $handlerName) {
$inDegree[$handlerName] = 0;
$graph[$handlerName] = [];
}
// Build dependency graph by examining each handler's dependencies
foreach ($this->handlers as $handlerName => $handler) {
foreach ($handler->getDependencies() as $dependency) {
// Validate that the dependency exists
if (false === isset($this->handlers[$dependency])) {
throw new \InvalidArgumentException(sprintf('Handler "%s" depends on unknown handler "%s"', $handlerName, $dependency));
}
// Add edge from dependency to dependent handler
$graph[$dependency][] = $handlerName;
++$inDegree[$handlerName];
}
}
// Kahn's topological sort algorithm
$queue = []; // Handlers ready to be processed (no remaining dependencies)
$result = []; // Final sorted order
// Start with handlers that have no dependencies
foreach ($inDegree as $handlerName => $degree) {
if (0 === $degree) {
$queue[] = $handlerName;
}
}
// Process handlers in dependency order
while (false === empty($queue)) {
$current = array_shift($queue);
$result[] = $current;
// Remove this handler's dependencies from dependent handlers
foreach ($graph[$current] as $dependent) {
--$inDegree[$dependent];
// If dependent now has no remaining dependencies, add to queue
if (0 === $inDegree[$dependent]) {
$queue[] = $dependent;
}
}
}
// Detect circular dependencies (if not all handlers were processed)
if (count($result) !== count($handlerNames)) {
throw new \InvalidArgumentException('Circular dependency detected in participant field handlers');
}
return $result;
}
/**
* Synchronizes submitted data with the cleaned DTO state.
*
* This method updates the submitted form data to reflect any changes made by
* field handlers (such as clearing invalid service selections). This ensures
* that the form continues processing with cleaned data rather than the original
* submitted data that may contain invalid selections.
*
* In edit mode, this also adds the applicant's personal data to the submitted data
* for participant 0, ensuring those fields are bound to the DTO by handleRequest.
*
* The synchronization is generic and works with any field handlers by examining
* the current DTO state and updating the corresponding submitted data fields.
*
* @param array<string, mixed> $submittedData The original submitted form data
* @param BookingDto $bookingDto The DTO with cleaned data from field handlers
*
* @return array<string, mixed> Updated submitted data reflecting DTO state
*/
private function syncSubmittedDataWithDto(array $submittedData, BookingDto $bookingDto): array
{
// Ensure participants array exists in submitted data
if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) {
return $submittedData;
}
// Sync each participant's data with the cleaned DTO
foreach ($submittedData['participants'] as $index => $participantData) {
if (false === is_array($participantData)) {
continue;
}
$participant = $bookingDto->getParticipant((int) $index);
if (null === $participant) {
continue;
}
// Update participant data to match cleaned DTO state
$submittedData['participants'][$index] = $this->syncParticipantData($participantData, $participant, $index, $bookingDto);
}
return $submittedData;
}
/**
* Synchronizes individual participant submitted data with cleaned participant DTO.
*
* This method examines the participant DTO and updates the submitted data to match
* any changes made by field handlers. It automatically detects which fields have
* been processed by checking against registered handlers.
*
* In edit mode for participant 0 (applicant), this also adds the personal data fields
* that were patched from the applicant, ensuring they're bound to the DTO.
*
* @param array<string, mixed> $participantData The submitted participant data
* @param ParticipantDto $participant The cleaned participant DTO
* @param int $index The participant index
* @param BookingDto $bookingDto The booking DTO for mode detection
*
* @return array<string, mixed> Updated participant data with synchronized field values
*/
private function syncParticipantData(array $participantData, ParticipantDto $participant, int $index, BookingDto $bookingDto): array
{
// Sync fields for all registered handlers
foreach ($this->handlers as $fieldName => $handler) {
// Only sync fields that were in the original submission
// This prevents adding extra fields that would cause validation errors
if (property_exists($participant, $fieldName) && array_key_exists($fieldName, $participantData)) {
$dtoValue = $participant->{$fieldName};
$participantData[$fieldName] = $this->convertDtoValueToSubmittedFormat($dtoValue);
}
}
return $participantData;
}
/**
* Converts DTO field values to the format expected in submitted form data.
*
* This method handles the conversion from DTO field values to the format
* that Symfony forms expect in submitted data. It supports various data types
* including service objects, arrays, and primitive values.
*
* @param mixed $dtoValue The field value from the DTO
*
* @return mixed The value in submitted data format
*/
private function convertDtoValueToSubmittedFormat(mixed $dtoValue): mixed
{
// Handle null values
if (null === $dtoValue) {
return null;
}
// Handle arrays (service collections, etc.)
if (is_array($dtoValue)) {
$submittedFormat = [];
foreach ($dtoValue as $item) {
$submittedFormat[] = $this->convertSingleValueToSubmittedFormat($item);
}
return $submittedFormat;
}
// Handle single values
return $this->convertSingleValueToSubmittedFormat($dtoValue);
}
/**
* Checks whether a field handler is allowed to run based on edit-mode mutability flags.
*/
private function isHandlerMutableForBooking(string $handlerName, BookingDto $bookingDto): bool
{
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
return true;
}
$mutabilityCategory = self::HANDLER_MUTABILITY_CATEGORY_MAP[$handlerName] ?? null;
return match ($mutabilityCategory) {
self::MUTABILITY_ADDITIONAL_SERVICES => $bookingDto->travel->additionalServicesMutable,
self::MUTABILITY_TRANSPORTATION => $bookingDto->travel->transportationServicesMutable,
self::MUTABILITY_PICKUPS => $bookingDto->travel->pickupsMutable,
default => true,
};
}
/**
* Converts a single DTO value to submitted form format.
*
* @param mixed $value The value to convert
*
* @return mixed The converted value
*/
private function convertSingleValueToSubmittedFormat(mixed $value): mixed
{
// Handle Service objects -> convert to ID (as string for form compatibility)
if ($value instanceof Service) {
return (string) $value->id;
}
// Handle Pickup objects -> convert to ID (as string for form compatibility)
if ($value instanceof Pickup) {
return (string) $value->id;
}
// Handle Insurance objects -> convert to ID (as string for form compatibility)
if ($value instanceof Insurance) {
return (string) $value->id;
}
// Handle Address objects -> convert to array of properties
if ($value instanceof Address) {
return [
'street' => $value->street,
'postCode' => $value->postCode,
'city' => $value->city,
'district' => $value->district,
'country' => $value->country,
];
}
// Handle DateTimeInterface -> convert to array format for BirthdayType widget
if ($value instanceof \DateTimeInterface) {
return [
'year' => $value->format('Y'),
'month' => $value->format('n'),
'day' => $value->format('j'),
];
}
// Handle numeric values
if (is_numeric($value)) {
return $value;
}
// Handle strings and other primitive types
return $value;
}
}