379 lines
15 KiB
PHP
379 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Form\Service;
|
|
|
|
use App\BusProNet\Model\Insurance;
|
|
use App\BusProNet\Model\Pickup;
|
|
use App\BusProNet\Model\Service;
|
|
use App\Form\Model\BookingDtoInterface;
|
|
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
|
|
{
|
|
/** @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 BookingDtoInterface $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, BookingDtoInterface $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 BookingDtoInterface $bookingDto The booking DTO to update with processed field values (create or edit)
|
|
*/
|
|
public function processFields(array $submittedData, BookingDtoInterface $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) {
|
|
$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, (int) $participantIndex)) {
|
|
$handler->processField($participantData, $bookingDto, (int) $participantIndex);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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 BookingDtoInterface $bookingDto The DTO with cleaned data from field handlers
|
|
*
|
|
* @return array<string, mixed> Updated submitted data reflecting DTO state
|
|
*/
|
|
private function syncSubmittedDataWithDto(array $submittedData, BookingDtoInterface $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);
|
|
}
|
|
|
|
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.
|
|
*
|
|
* @param array<string, mixed> $participantData The submitted participant data
|
|
* @param ParticipantDto $participant The cleaned participant DTO
|
|
*
|
|
* @return array<string, mixed> Updated participant data with synchronized field values
|
|
*/
|
|
private function syncParticipantData(array $participantData, ParticipantDto $participant): 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);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
if ($value instanceof Service) {
|
|
return $value->id;
|
|
}
|
|
|
|
// Handle Pickup objects -> convert to ID
|
|
if ($value instanceof Pickup) {
|
|
return $value->id;
|
|
}
|
|
|
|
// Handle Insurance objects -> convert to ID
|
|
if ($value instanceof Insurance) {
|
|
return $value->id;
|
|
}
|
|
|
|
// Handle DateTimeInterface -> convert to string format
|
|
if ($value instanceof \DateTimeInterface) {
|
|
return $value->format('Y-m-d');
|
|
}
|
|
|
|
// Handle numeric values
|
|
if (is_numeric($value)) {
|
|
return $value;
|
|
}
|
|
|
|
// Handle strings and other primitive types
|
|
return $value;
|
|
}
|
|
}
|