wip: implement transportation, pickups and parking

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 81b8237570
commit fe624cfd1c
15 changed files with 1497 additions and 190 deletions
+2 -1
View File
@@ -9,6 +9,7 @@ use App\Form\Service\Contract\FieldOptionsProviderInterface;
use App\Form\Service\CreateFieldStateProvider;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
@@ -242,7 +243,7 @@ class BookingCreateParticipantType extends AbstractType
'transportationInbound' => ChoiceType::class,
'pickupOutbound' => ChoiceType::class,
'pickupInbound' => ChoiceType::class,
'parking' => ChoiceType::class,
'parking' => CheckboxType::class,
];
foreach ($dynamicFields as $fieldName => $fieldType) {
+2 -2
View File
@@ -60,8 +60,8 @@ class ParticipantDto
public ?Pickup $pickupOutbound = null;
public ?Pickup $pickupInbound = null;
// Parking service for self-organized transportation
public ?Service $parking = null;
// Parking service for self-organized transportation (boolean: true if parking requested)
public bool $parking = false;
// Deprecated properties for backward compatibility - will be removed in future version
public ?Service $transportationServiceTo = null;
+14 -8
View File
@@ -23,8 +23,8 @@ use App\Form\Service\Condition\ServiceSubTypeCondition;
* 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
* - Transportation pickup fields are hidden by default, shown only when transportation type is BUS
* - Parking field is hidden by default, shown only when outbound transportation is PKW
*/
class CreateFieldStateProvider extends AbstractFieldStateProvider
{
@@ -99,20 +99,26 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
// Transportation-related field conditions
// Hide outbound pickup when transportation is not BUS
// Show outbound pickup only when transportation is BUS (hidden by default)
$this->fieldStateConditions['pickupOutbound'] = [
'hidden' => ServiceSubTypeCondition::notEquals('transportationOutbound', DirectionMapper::SUBTYPE_BUS_API),
'hidden' => CompositeCondition::not(
ServiceSubTypeCondition::equals('transportationOutbound', DirectionMapper::SUBTYPE_BUS_API)
),
];
// Hide inbound pickup when transportation is not BUS
// Show inbound pickup only when transportation is BUS (hidden by default)
$this->fieldStateConditions['pickupInbound'] = [
'hidden' => ServiceSubTypeCondition::notEquals('transportationInbound', DirectionMapper::SUBTYPE_BUS_API),
'hidden' => CompositeCondition::not(
ServiceSubTypeCondition::equals('transportationInbound', DirectionMapper::SUBTYPE_BUS_API)
),
];
// Hide parking when outbound transportation is not PKW (car)
// Show parking only when outbound transportation is PKW (hidden by default)
// Parking is offered at holiday destination for those arriving by car
$this->fieldStateConditions['parking'] = [
'hidden' => ServiceSubTypeCondition::notEquals('transportationOutbound', DirectionMapper::SUBTYPE_CAR_API),
'hidden' => CompositeCondition::not(
ServiceSubTypeCondition::equals('transportationOutbound', DirectionMapper::SUBTYPE_CAR_API)
),
];
// Example field state conditions would be registered here
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\ParticipantDto;
@@ -339,6 +340,11 @@ class ParticipantFieldHandlerRegistry
return $value->id;
}
// Handle Pickup objects -> convert to ID
if ($value instanceof Pickup) {
return $value->id;
}
// Handle DateTimeInterface -> convert to string format
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d');
@@ -227,7 +227,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$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_label' => fn (?Pickup $pickup) => $this->formatPickupLabelWithPrice($pickup),
'choice_value' => 'id',
'expanded' => false, // Dropdown for pickups
'multiple' => false,
@@ -237,24 +237,20 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Inbound Pickup (conditional - only shown when inbound transportation is bus)
$this->fieldOptionProviders['pickupInbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Zustieg Rückfahrt',
'label' => 'Ausstieg Rückfahrt',
'choices' => $bookingDto->travel->pickupsFro,
'choice_label' => fn (Pickup $pickup) => $this->formatPickupLabel($pickup),
'choice_label' => fn (?Pickup $pickup) => $this->formatPickupLabelWithPrice($pickup),
'choice_value' => 'id',
'expanded' => false,
'multiple' => false,
'required' => true,
'placeholder' => 'Zustieg auswählen',
'placeholder' => 'Ausstieg auswählen',
];
// Parking (conditional - only shown when at least one transportation direction is PKW)
// Parking (conditional - only shown when outbound transportation is PKW)
// Simple checkbox since there's only ever one parking type
$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,
'label' => $this->getParkingCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true)),
'required' => false,
];
@@ -287,6 +283,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $service->label;
}
if ($service->price < 0) {
// Negative prices are discounts
return sprintf('%s (-%s€ Rabatt)', $service->label, number_format(abs($service->price), 2, ',', '.'));
}
return sprintf('%s (€%s)', $service->label, number_format($service->price, 2, ',', '.'));
}
@@ -307,16 +308,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
{
$label = $service->label;
// Add transportation type indicator
$typeIndicator = match ($service->subType) {
'BUS' => '🚌',
'PKW' => '🚗',
default => '',
};
if ($typeIndicator) {
$label = $typeIndicator.' '.$label;
}
// Transportation type indicators removed for cleaner labels
// Add pricing with discount indication
if (null !== $service->price) {
@@ -357,6 +349,47 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $label;
}
private function formatPickupLabelWithPrice(?Pickup $pickup): string
{
if (null === $pickup) {
return '';
}
$label = $this->formatPickupLabel($pickup);
if (null === $pickup->price || 0.0 === $pickup->price) {
return $label;
}
if ($pickup->price < 0) {
// Negative prices are discounts
return sprintf('%s (-%s€ Rabatt)', $label, number_format(abs($pickup->price), 2, ',', '.'));
}
return sprintf('%s (€%s)', $label, number_format($pickup->price, 2, ',', '.'));
}
/**
* Gets the parking checkbox label with pricing information.
*
* Creates a checkbox label for the single parking service including pricing.
* Since there's only ever one parking type, we take the first available service.
*
* @param array $parkingServices Array of available parking services
*
* @return string The formatted checkbox label with pricing
*/
private function getParkingCheckboxLabel(array $parkingServices): string
{
if (empty($parkingServices)) {
return 'Parkplatz';
}
$parkingService = reset($parkingServices); // Get the first (and only) parking service
return $this->formatServiceLabelWithPrice($parkingService);
}
/**
* Filters services based on participant's age constraints.
*
@@ -4,8 +4,6 @@ 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;
@@ -13,9 +11,9 @@ 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.
* Parking is only available when outbound transportation is PKW (car).
* This is because parking is needed at the destination for those arriving by car.
* Automatically clears parking when outbound transportation is not PKW.
*/
class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler
{
@@ -53,28 +51,23 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler
return;
}
// Check if parking is applicable (at least one PKW direction)
// Check if parking is applicable (outbound transportation is PKW)
if (!$this->isParkingApplicable($participant)) {
$participant->parking = null; // Clear parking for bus-only transport
$participant->parking = false; // Clear parking when outbound is not PKW
return;
}
$selectedParking = $this->getFieldValue($submittedData, $this->getFieldName());
$parkingSelected = $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;
// Store boolean value directly (true if checkbox checked, false otherwise)
$participant->parking = (bool) $parkingSelected;
}
/**
* Checks if parking is applicable based on transportation selections.
* Checks if parking is applicable based on outbound transportation selection.
*
* Parking is only needed when arriving by car at the destination.
*
* @param object $participant The participant DTO
*
@@ -82,34 +75,6 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler
*/
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;
return DirectionMapper::SUBTYPE_CAR_API === $participant->transportationOutbound?->subType;
}
}