wip: transportation services

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent a019c7dae0
commit 81b8237570
18 changed files with 1113 additions and 59 deletions
+11 -1
View File
@@ -178,6 +178,11 @@ class BookingCreateParticipantType extends AbstractType
'board',
'rentals',
'skiPass',
'transportationOutbound',
'transportationInbound',
'pickupOutbound',
'pickupInbound',
'parking',
];
foreach ($dynamicFields as $fieldName) {
@@ -209,7 +214,7 @@ class BookingCreateParticipantType extends AbstractType
$this->addBaseFields($form, $bookingDto, $participantIndex);
// Rebuild dynamic fields
$dynamicFields = ['assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'skiPass'];
$dynamicFields = ['assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'skiPass', 'transportationOutbound', 'transportationInbound', 'pickupOutbound', 'pickupInbound', 'parking'];
foreach ($dynamicFields as $fieldName) {
if ($form->has($fieldName)) {
$form->remove($fieldName);
@@ -233,6 +238,11 @@ class BookingCreateParticipantType extends AbstractType
'board' => ChoiceType::class,
'rentals' => ChoiceType::class,
'skiPass' => ChoiceType::class,
'transportationOutbound' => ChoiceType::class,
'transportationInbound' => ChoiceType::class,
'pickupOutbound' => ChoiceType::class,
'pickupInbound' => ChoiceType::class,
'parking' => ChoiceType::class,
];
foreach ($dynamicFields as $fieldName => $fieldType) {
-1
View File
@@ -14,7 +14,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class BookingCreateStep2Type extends AbstractType
{
public function __construct(
private readonly ParticipantFieldHandlerRegistry $participantFieldHandlerRegistry,
) {
+1 -1
View File
@@ -22,7 +22,7 @@ class RoomSelectType extends AbstractType
$form = $event->getForm();
// Build label with pricing
$label = 'Anzahl ' . $data->roomLabel;
$label = 'Anzahl '.$data->roomLabel;
if (null !== $options['room_price']) {
$formattedPrice = number_format((float) $options['room_price'], 2, ',', '.');
$label .= sprintf(' (€%s pro Nacht)', $formattedPrice);
@@ -49,6 +49,7 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface
}
$hiddenCondition = $this->fieldStateConditions[$fieldName]['hidden'];
return !$hiddenCondition->evaluate($bookingDto, $participantIndex, $formData);
}
@@ -87,6 +87,7 @@ class RoomSelectionCondition implements FieldConditionInterface
public function getDescription(): string
{
$codes = implode(', ', $this->requiredRoomCodes);
return "Field visible when room with code(s) [{$codes}] is selected";
}
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that evaluates service sub-types.
*
* This condition enables field state logic based on the sub-type property
* of Service objects. It can be used with any service field (transportation,
* additional services, etc.) to show/hide or enable/disable dependent fields
* based on the selected service type.
*
* Common Use Cases:
* - Show/hide pickup fields when transportation sub-type is BUS
* - Show/hide parking fields when transportation sub-type is PKW
* - Enable/disable fields based on service category selection
* - Conditional logic for different service types
*/
class ServiceSubTypeCondition implements FieldConditionInterface
{
private const OPERATOR_EQUALS = 'equals';
private const OPERATOR_NOT_EQUALS = 'not_equals';
private const OPERATOR_IN = 'in';
private const OPERATOR_NOT_IN = 'not_in';
/**
* Creates a new service sub-type condition.
*
* @param string $serviceFieldName The name of the service field to check
* @param string $operator The comparison operator to use
* @param string|string[] $expectedSubType The expected service sub-type(s)
*/
public function __construct(
private readonly string $serviceFieldName,
private readonly string $operator,
private readonly string|array $expectedSubType,
) {
$this->validateOperator($operator);
$this->validateExpectedSubType($operator, $expectedSubType);
}
/**
* Evaluates the service sub-type condition against current form data.
*
* Retrieves the service from the specified field and checks its subType
* property against the expected value(s). Handles both form data and
* participant DTO data sources.
*
* @param BookingDtoInterface $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
* @return bool True if the service sub-type meets the condition criteria, false otherwise
*/
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
{
$service = $this->getService($formData, $participantIndex, $bookingDto);
if (null === $service) {
// If no service is selected, condition fails
return false;
}
$actualSubType = $service->subType ?? '';
return match ($this->operator) {
self::OPERATOR_EQUALS => $actualSubType === $this->expectedSubType,
self::OPERATOR_NOT_EQUALS => $actualSubType !== $this->expectedSubType,
self::OPERATOR_IN => $this->isSubTypeIn($actualSubType, $this->expectedSubType),
self::OPERATOR_NOT_IN => !$this->isSubTypeIn($actualSubType, $this->expectedSubType),
default => false,
};
}
/**
* Returns the field names that this condition depends on.
*
* This condition depends on the service field being evaluated, so any changes
* to that field should trigger re-evaluation of dependent field states.
*
* @return string[] Array containing the service field name being evaluated
*/
public function getDependentFields(): array
{
return [$this->serviceFieldName];
}
/**
* Returns a human-readable description of the service sub-type condition.
*
* Generates a descriptive string explaining what service field is being
* checked and what the expected sub-type criteria are.
*
* @return string Description of the service sub-type condition
*/
public function getDescription(): string
{
$subTypeDescription = is_array($this->expectedSubType)
? '['.implode(', ', $this->expectedSubType).']'
: (string) $this->expectedSubType;
return match ($this->operator) {
self::OPERATOR_EQUALS => sprintf('Service "%s" sub-type equals %s', $this->serviceFieldName, $subTypeDescription),
self::OPERATOR_NOT_EQUALS => sprintf('Service "%s" sub-type does not equal %s', $this->serviceFieldName, $subTypeDescription),
self::OPERATOR_IN => sprintf('Service "%s" sub-type is in %s', $this->serviceFieldName, $subTypeDescription),
self::OPERATOR_NOT_IN => sprintf('Service "%s" sub-type is not in %s', $this->serviceFieldName, $subTypeDescription),
default => sprintf('Service "%s" sub-type %s %s', $this->serviceFieldName, $this->operator, $subTypeDescription),
};
}
/**
* Creates a condition for service sub-type equality.
*
* @param string $serviceFieldName The service field to check
* @param string $expectedSubType The expected service sub-type
*/
public static function equals(string $serviceFieldName, string $expectedSubType): self
{
return new self($serviceFieldName, self::OPERATOR_EQUALS, $expectedSubType);
}
/**
* Creates a condition for service sub-type non-equality.
*
* @param string $serviceFieldName The service field to check
* @param string $expectedSubType The sub-type that should not match
*/
public static function notEquals(string $serviceFieldName, string $expectedSubType): self
{
return new self($serviceFieldName, self::OPERATOR_NOT_EQUALS, $expectedSubType);
}
/**
* Creates a condition for service sub-type inclusion.
*
* @param string $serviceFieldName The service field to check
* @param string[] $allowedSubTypes The allowed sub-types
*/
public static function in(string $serviceFieldName, array $allowedSubTypes): self
{
return new self($serviceFieldName, self::OPERATOR_IN, $allowedSubTypes);
}
/**
* Creates a condition for service sub-type exclusion.
*
* @param string $serviceFieldName The service field to check
* @param string[] $excludedSubTypes The excluded sub-types
*/
public static function notIn(string $serviceFieldName, array $excludedSubTypes): self
{
return new self($serviceFieldName, self::OPERATOR_NOT_IN, $excludedSubTypes);
}
/**
* Retrieves service from form data or participant data.
*/
private function getService(array $formData, int $participantIndex, BookingDtoInterface $bookingDto): ?Service
{
// First check participant-specific form data
if (isset($formData['participants'][$participantIndex][$this->serviceFieldName])) {
$serviceData = $formData['participants'][$participantIndex][$this->serviceFieldName];
// Form data might contain Service objects or service IDs
if ($serviceData instanceof Service) {
return $serviceData;
}
}
// Then check participant DTO data
$participant = $bookingDto->getParticipant($participantIndex);
if (null !== $participant && property_exists($participant, $this->serviceFieldName)) {
return $participant->{$this->serviceFieldName};
}
return null;
}
/**
* Checks if sub-type is in the expected array.
*/
private function isSubTypeIn(string $actualSubType, string|array $expectedSubTypes): bool
{
if (is_string($expectedSubTypes)) {
return $actualSubType === $expectedSubTypes;
}
return in_array($actualSubType, $expectedSubTypes, true);
}
/**
* Validates that the operator is supported.
*/
private function validateOperator(string $operator): void
{
$validOperators = [
self::OPERATOR_EQUALS,
self::OPERATOR_NOT_EQUALS,
self::OPERATOR_IN,
self::OPERATOR_NOT_IN,
];
if (!in_array($operator, $validOperators, true)) {
throw new \InvalidArgumentException(sprintf('Invalid operator "%s". Valid operators: %s', $operator, implode(', ', $validOperators)));
}
}
/**
* Validates expected sub-type based on operator requirements.
*/
private function validateExpectedSubType(string $operator, string|array $expectedSubType): void
{
if (in_array($operator, [self::OPERATOR_IN, self::OPERATOR_NOT_IN], true) && !is_array($expectedSubType)) {
throw new \InvalidArgumentException(sprintf('Operator "%s" requires expectedSubType to be an array', $operator));
}
if (in_array($operator, [self::OPERATOR_EQUALS, self::OPERATOR_NOT_EQUALS], true) && !is_string($expectedSubType)) {
throw new \InvalidArgumentException(sprintf('Operator "%s" requires expectedSubType to be a string', $operator));
}
}
}
@@ -4,11 +4,14 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Service\Abstract\AbstractFieldStateProvider;
use App\Form\Service\Condition\CompositeCondition;
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
use App\Form\Service\Condition\FieldValueCondition;
use App\Form\Service\Condition\RentalSelectionCondition;
use App\Form\Service\Condition\RoomSelectionCondition;
use App\Form\Service\Condition\ServiceSubTypeCondition;
/**
* Field state provider for the booking create workflow.
@@ -20,6 +23,8 @@ use App\Form\Service\Condition\RoomSelectionCondition;
* Current field state conditions:
* - Body dimension fields become required when rental services are selected
* - Age-dependent service fields are hidden until birth date is provided
* - Transportation pickup fields are hidden when transportation type is not BUS
* - Parking field is hidden when outbound transportation is not PKW
*/
class CreateFieldStateProvider extends AbstractFieldStateProvider
{
@@ -92,6 +97,24 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
'hidden' => CompositeCondition::not($mbzRoomCondition),
];
// Transportation-related field conditions
// Hide outbound pickup when transportation is not BUS
$this->fieldStateConditions['pickupOutbound'] = [
'hidden' => ServiceSubTypeCondition::notEquals('transportationOutbound', DirectionMapper::SUBTYPE_BUS_API),
];
// Hide inbound pickup when transportation is not BUS
$this->fieldStateConditions['pickupInbound'] = [
'hidden' => ServiceSubTypeCondition::notEquals('transportationInbound', DirectionMapper::SUBTYPE_BUS_API),
];
// Hide parking when outbound transportation is not PKW (car)
// Parking is offered at holiday destination for those arriving by car
$this->fieldStateConditions['parking'] = [
'hidden' => ServiceSubTypeCondition::notEquals('transportationOutbound', DirectionMapper::SUBTYPE_CAR_API),
];
// Example field state conditions would be registered here
// For demonstration purposes, here are some example patterns:
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractFieldOptionsProvider;
@@ -187,6 +189,75 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
],
];
// Transportation field providers - handles outbound/inbound transportation and pickup selection
// Outbound Transportation
$this->fieldOptionProviders['transportationOutbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Hinfahrt',
'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL),
'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service),
'choice_value' => 'id',
'expanded' => true,
'multiple' => false,
'required' => true,
'attr' => [
'hx-post' => '#', // Will be configured when HTMX integration is implemented
'hx-target' => '#booking-summary',
'hx-trigger' => 'change',
],
];
// Inbound Transportation
$this->fieldOptionProviders['transportationInbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Rückfahrt',
'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL),
'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service),
'choice_value' => 'id',
'expanded' => true,
'multiple' => false,
'required' => true,
'attr' => [
'hx-post' => '#', // Will be configured when HTMX integration is implemented
'hx-target' => '#booking-summary',
'hx-trigger' => 'change',
],
];
// Outbound Pickup (conditional - only shown when outbound transportation is bus)
$this->fieldOptionProviders['pickupOutbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Zustieg Hinfahrt',
'choices' => $bookingDto->travel->pickupsTo,
'choice_label' => fn (Pickup $pickup) => $this->formatPickupLabel($pickup),
'choice_value' => 'id',
'expanded' => false, // Dropdown for pickups
'multiple' => false,
'required' => true,
'placeholder' => 'Zustieg auswählen',
];
// Inbound Pickup (conditional - only shown when inbound transportation is bus)
$this->fieldOptionProviders['pickupInbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Zustieg Rückfahrt',
'choices' => $bookingDto->travel->pickupsFro,
'choice_label' => fn (Pickup $pickup) => $this->formatPickupLabel($pickup),
'choice_value' => 'id',
'expanded' => false,
'multiple' => false,
'required' => true,
'placeholder' => 'Zustieg auswählen',
];
// Parking (conditional - only shown when at least one transportation direction is PKW)
$this->fieldOptionProviders['parking'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Parkplatz',
'choices' => $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true),
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
'choice_value' => 'id',
'expanded' => true,
'multiple' => false,
'required' => false,
];
// Future field providers would be added here, for example:
//
// $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [
@@ -219,6 +290,73 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return sprintf('%s (€%s)', $service->label, number_format($service->price, 2, ',', '.'));
}
/**
* Format transportation service labels with type indicator and pricing.
*
* Creates user-friendly labels for transportation services that include:
* - Transportation type icon (🚌 for bus, 🚗 for car)
* - Service name
* - Pricing (with discount indication for negative prices)
* - Availability warning for limited services
*
* @param Service $service The transportation service to format
*
* @return string The formatted transportation service label
*/
private function formatTransportationServiceLabel(Service $service): string
{
$label = $service->label;
// Add transportation type indicator
$typeIndicator = match ($service->subType) {
'BUS' => '🚌',
'PKW' => '🚗',
default => '',
};
if ($typeIndicator) {
$label = $typeIndicator.' '.$label;
}
// Add pricing with discount indication
if (null !== $service->price) {
if ($service->price > 0) {
$label .= sprintf(' (+€%.2f)', $service->price);
} elseif ($service->price < 0) {
$label .= sprintf(' (-€%.2f Discount)', abs($service->price));
}
}
// Add availability warning if limited
if (null !== $service->available && $service->available <= 5) {
$label .= sprintf(' (nur %d verfügbar)', $service->available);
}
return $label;
}
/**
* Format pickup labels with city and street information.
*
* Creates user-friendly labels for pickup locations following the existing pattern:
* - Primary format: "City (Street)" if street is available
* - Fallback format: "City" if no street information
*
* @param Pickup $pickup The pickup location to format
*
* @return string The formatted pickup location label
*/
private function formatPickupLabel(Pickup $pickup): string
{
$label = $pickup->city ?? '';
if (null !== $pickup->street && '' !== trim($pickup->street)) {
$label .= ' ('.$pickup->street.')';
}
return $label;
}
/**
* Filters services based on participant's age constraints.
*
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
* Handles parking service selection for self-organized transportation.
*
* Parking is only available when at least one direction uses
* self-organized (PKW) transportation. Automatically clears parking
* when both transportation directions are bus-only.
*/
class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler
{
public function getFieldName(): string
{
return 'parking';
}
public function getDependencies(): array
{
return ['transportationOutbound', 'transportationInbound'];
}
/**
* Determines if this handler should process the field based on submitted data.
*
* For parking selection fields, we always need to process to handle cases
* where the parking is cleared due to transportation changes. This ensures the
* participant DTO is updated correctly when transportation switches to bus-only.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for parking selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
{
return true; // Always process for state changes
}
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
return;
}
// Check if parking is applicable (at least one PKW direction)
if (!$this->isParkingApplicable($participant)) {
$participant->parking = null; // Clear parking for bus-only transport
return;
}
$selectedParking = $this->getFieldValue($submittedData, $this->getFieldName());
// Get available parking services (subtype PAR)
$availableParkingServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true);
$validSelection = null;
if (null !== $selectedParking) {
$validSelection = $this->findValidParkingService($selectedParking, $availableParkingServices);
}
$participant->parking = $validSelection;
}
/**
* Checks if parking is applicable based on transportation selections.
*
* @param object $participant The participant DTO
*
* @return bool True if parking is applicable, false otherwise
*/
private function isParkingApplicable(object $participant): bool
{
$outboundIsPkw = DirectionMapper::SUBTYPE_CAR_API === $participant->transportationOutbound?->subType;
$inboundIsPkw = DirectionMapper::SUBTYPE_CAR_API === $participant->transportationInbound?->subType;
return $outboundIsPkw || $inboundIsPkw;
}
/**
* Finds a valid parking service from available parking services.
*
* @param mixed $selectedServiceId The submitted service ID
* @param array<int, Service> $availableServices Array of available parking services
*
* @return Service|null The valid service object, or null if invalid
*/
private function findValidParkingService(mixed $selectedServiceId, array $availableServices): ?Service
{
if (null === $selectedServiceId || false === is_string($selectedServiceId) && false === is_int($selectedServiceId)) {
return null;
}
$serviceId = (int) $selectedServiceId;
foreach ($availableServices as $service) {
if ($service->id === $serviceId) {
return $service;
}
}
return null;
}
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
* Handles inbound pickup location selection.
*
* Pickup selection is only processed when inbound transportation
* is bus type. Automatically clears pickup when transportation
* changes to self-organized (PKW).
*/
class ParticipantPickupInboundFieldHandler extends AbstractParticipantFieldHandler
{
public function getFieldName(): string
{
return 'pickupInbound';
}
public function getDependencies(): array
{
return ['transportationInbound']; // Must process transportation first
}
/**
* Determines if this handler should process the field based on submitted data.
*
* For pickup selection fields, we always need to process to handle cases
* where the pickup is cleared due to transportation changes. This ensures the
* participant DTO is updated correctly when transportation switches from bus to PKW.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for pickup selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
{
return true; // Always process to handle clearing pickup
}
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
return;
}
// Only process pickup if inbound transportation is bus
if (null === $participant->transportationInbound || DirectionMapper::SUBTYPE_BUS_API !== $participant->transportationInbound->subType) {
$participant->pickupInbound = null; // Clear pickup for non-bus transport
return;
}
$selectedPickup = $this->getFieldValue($submittedData, $this->getFieldName());
// Validate pickup selection against available inbound pickups
$validSelection = null;
if (null !== $selectedPickup) {
$validSelection = $this->findValidPickup($selectedPickup, $bookingDto->travel->pickupsFro);
}
$participant->pickupInbound = $validSelection;
}
/**
* Finds a valid pickup from available pickups.
*
* @param mixed $selectedPickupId The submitted pickup ID
* @param array<int, Pickup> $availablePickups Array of available pickup locations
*
* @return Pickup|null The valid pickup object, or null if invalid
*/
private function findValidPickup(mixed $selectedPickupId, array $availablePickups): ?Pickup
{
if (null === $selectedPickupId || false === is_string($selectedPickupId) && false === is_int($selectedPickupId)) {
return null;
}
$pickupId = (int) $selectedPickupId;
foreach ($availablePickups as $pickup) {
if ($pickup->id === $pickupId) {
return $pickup;
}
}
return null;
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
* Handles outbound pickup location selection.
*
* Pickup selection is only processed when outbound transportation
* is bus type. Automatically clears pickup when transportation
* changes to self-organized (PKW).
*/
class ParticipantPickupOutboundFieldHandler extends AbstractParticipantFieldHandler
{
public function getFieldName(): string
{
return 'pickupOutbound';
}
public function getDependencies(): array
{
return ['transportationOutbound']; // Must process transportation first
}
/**
* Determines if this handler should process the field based on submitted data.
*
* For pickup selection fields, we always need to process to handle cases
* where the pickup is cleared due to transportation changes. This ensures the
* participant DTO is updated correctly when transportation switches from bus to PKW.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for pickup selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
{
return true; // Always process to handle clearing pickup
}
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
return;
}
// Only process pickup if outbound transportation is bus
if (null === $participant->transportationOutbound || DirectionMapper::SUBTYPE_BUS_API !== $participant->transportationOutbound->subType) {
$participant->pickupOutbound = null; // Clear pickup for non-bus transport
$participant->pickup = null; // Backward compatibility
return;
}
$selectedPickup = $this->getFieldValue($submittedData, $this->getFieldName());
// Validate pickup selection against available outbound pickups
$validSelection = null;
if (null !== $selectedPickup) {
$validSelection = $this->findValidPickup($selectedPickup, $bookingDto->travel->pickupsTo);
}
$participant->pickupOutbound = $validSelection;
$participant->pickup = $validSelection; // Backward compatibility
}
/**
* Finds a valid pickup from available pickups.
*
* @param mixed $selectedPickupId The submitted pickup ID
* @param array<int, Pickup> $availablePickups Array of available pickup locations
*
* @return Pickup|null The valid pickup object, or null if invalid
*/
private function findValidPickup(mixed $selectedPickupId, array $availablePickups): ?Pickup
{
if (null === $selectedPickupId || false === is_string($selectedPickupId) && false === is_int($selectedPickupId)) {
return null;
}
$pickupId = (int) $selectedPickupId;
foreach ($availablePickups as $pickup) {
if ($pickup->id === $pickupId) {
return $pickup;
}
}
return null;
}
}
@@ -58,4 +58,4 @@ class ParticipantRemarksRoomFieldHandler extends AbstractParticipantFieldHandler
// Normalize empty string to null and update participant
$participant->remarksRoom = $this->normalizeEmptyValue($remarksRoom);
}
}
}
@@ -14,7 +14,7 @@ use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
*
* This handler manages skipass selection for participants in the booking
* creation process. It processes the skiPass field from form submissions,
* validates age-appropriate and date-valid skipasses, and updates the
* validates age-appropriate and date-valid skipasses, and updates the
* participant DTO with the valid selection.
*
* Key responsibilities:
@@ -109,7 +109,6 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
$participant->skiPass = $validSelection;
}
/**
* Validates if a selected skipass is still valid for the participant.
*
@@ -148,7 +147,7 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
// Date constraints are already handled by the travel->getAdditionalServicesBySubTypes
// method with date filtering enabled (true, true parameters), so if the service
// is in the available services list, it already passed date validation.
return true; // Service passed both age and date validation
}
@@ -203,4 +202,4 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
return false;
}
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
* Handles processing of inbound transportation service selection.
*
* This handler manages inbound (RUECK) transportation options including bus and
* self-organized (PKW) services with pricing and availability validation.
* It processes the transportationInbound field from form submissions and updates
* the participant DTO with validated selections.
*/
class ParticipantTransportationInboundFieldHandler extends AbstractParticipantFieldHandler
{
public function getFieldName(): string
{
return 'transportationInbound';
}
/**
* Determines if this handler should process the field based on submitted data.
*
* For transportation selection fields, we always need to process to handle cases
* where the selection is cleared (field not present in data). This ensures the
* participant DTO is updated with null when no transportation is selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for transportation selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
return;
}
$selectedTransportation = $this->getFieldValue($submittedData, $this->getFieldName());
// Get available inbound transportation services
$availableServices = $bookingDto->travel->getTransportationServicesByDirection(
DirectionMapper::INBOUND_TRAVEL,
true // filter available
);
// Validate and convert selection to Service object
$validSelection = null;
if (null !== $selectedTransportation) {
$validSelection = $this->findValidTransportationService(
$selectedTransportation,
$availableServices
);
}
// Update participant with validated selection
$participant->transportationInbound = $validSelection;
// Backward compatibility: also update deprecated property
$participant->transportationServiceFro = $validSelection;
}
/**
* Finds a valid transportation service from available services.
*
* @param mixed $selectedServiceId The submitted service ID
* @param array<int, Service> $availableServices Array of available transportation services
*
* @return Service|null The valid service object, or null if invalid
*/
private function findValidTransportationService(
mixed $selectedServiceId,
array $availableServices,
): ?Service {
if (null === $selectedServiceId || false === is_string($selectedServiceId) && false === is_int($selectedServiceId)) {
return null;
}
$serviceId = (int) $selectedServiceId;
foreach ($availableServices as $service) {
if ($service->id === $serviceId) {
return $service;
}
}
return null;
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
* Handles processing of outbound transportation service selection.
*
* This handler manages outbound (HIN) transportation options including bus and
* self-organized (PKW) services with pricing and availability validation.
* It processes the transportationOutbound field from form submissions and updates
* the participant DTO with validated selections.
*/
class ParticipantTransportationOutboundFieldHandler extends AbstractParticipantFieldHandler
{
public function getFieldName(): string
{
return 'transportationOutbound';
}
/**
* Determines if this handler should process the field based on submitted data.
*
* For transportation selection fields, we always need to process to handle cases
* where the selection is cleared (field not present in data). This ensures the
* participant DTO is updated with null when no transportation is selected.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param int $participantIndex The index of the participant being processed
*
* @return bool Always returns true for transportation selection fields
*/
public function shouldProcess(array $submittedData, int $participantIndex): bool
{
return true; // Always process to handle deselection cases
}
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
return;
}
$selectedTransportation = $this->getFieldValue($submittedData, $this->getFieldName());
// Get available outbound transportation services
$availableServices = $bookingDto->travel->getTransportationServicesByDirection(
DirectionMapper::OUTBOUND_TRAVEL,
true // filter available
);
// Validate and convert selection to Service object
$validSelection = null;
if (null !== $selectedTransportation) {
$validSelection = $this->findValidTransportationService(
$selectedTransportation,
$availableServices
);
}
// Update participant with validated selection
$participant->transportationOutbound = $validSelection;
// Backward compatibility: also update deprecated property
$participant->transportationServiceTo = $validSelection;
}
/**
* Finds a valid transportation service from available services.
*
* @param mixed $selectedServiceId The submitted service ID
* @param array<int, Service> $availableServices Array of available transportation services
*
* @return Service|null The valid service object, or null if invalid
*/
private function findValidTransportationService(
mixed $selectedServiceId,
array $availableServices,
): ?Service {
if (null === $selectedServiceId || false === is_string($selectedServiceId) && false === is_int($selectedServiceId)) {
return null;
}
$serviceId = (int) $selectedServiceId;
foreach ($availableServices as $service) {
if ($service->id === $serviceId) {
return $service;
}
}
return null;
}
}