feat: selectable drop-offs for inbound bus travel

This commit is contained in:
Björn Fromme
2026-03-16 12:03:00 +01:00
parent f8a56b7de2
commit f795a3ea21
31 changed files with 701 additions and 200 deletions
+2
View File
@@ -341,6 +341,8 @@ class BookingParticipantType extends AbstractType
'transportationOutbound' => ChoiceType::class,
'transportationInbound' => ChoiceType::class,
'pickup' => ChoiceType::class,
'differentDropOff' => CheckboxType::class,
'dropOff' => ChoiceType::class,
'parking' => CheckboxType::class,
'licensePlate' => TextType::class,
];
+21
View File
@@ -32,6 +32,8 @@ class ParticipantDto
'transportationOutbound',
'transportationInbound',
'pickup',
'differentDropOff',
'dropOff',
'parking',
'licensePlate',
'bulkInsuranceBooking',
@@ -106,6 +108,12 @@ class ParticipantDto
// Pickup location (applies to both directions)
public ?Pickup $pickup = null;
// Drop-off location (for inbound/return direction)
public ?Pickup $dropOff = null;
// UI-only flag: true when user selected a different drop-off location (BUS+BUS scenario)
public bool $differentDropOff = false;
// Parking service for self-organized transportation (boolean: true if parking requested)
public bool $parking = false;
@@ -242,6 +250,19 @@ class ParticipantDto
return $this->dateOfBirth->diff($referenceDate)->y;
}
/**
* Checks if any transportation-related data is present for summary display.
*/
public function hasTransportationData(): bool
{
return null !== $this->transportationOutbound
|| null !== $this->transportationInbound
|| null !== $this->pickup
|| null !== $this->dropOff
|| true === $this->parking
|| null !== $this->licensePlate;
}
/**
* Checks if the participant has selected an insurance.
*/
@@ -260,6 +260,23 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
),
];
// Show differentDropOff checkbox only when BOTH outbound and inbound are BUS
$this->fieldStateConditions['differentDropOff'] = [
'hidden' => CompositeCondition::not(
CompositeCondition::and(
ServiceSubTypeCondition::equals('transportationOutbound', DirectionMapper::SUBTYPE_BUS_API),
ServiceSubTypeCondition::equals('transportationInbound', DirectionMapper::SUBTYPE_BUS_API)
)
),
];
// Show drop-off when inbound is BUS (options provider further gates on checkbox state in BUS+BUS)
$this->fieldStateConditions['dropOff'] = [
'hidden' => CompositeCondition::not(
ServiceSubTypeCondition::equals('transportationInbound', DirectionMapper::SUBTYPE_BUS_API)
),
];
// Show parking only when outbound transportation is PKW AND participant is 18+ (hidden by default)
// Parking is offered at holiday destination for those arriving by car
// Participants under 18 cannot drive in Germany, so parking is not relevant for them
@@ -197,6 +197,25 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
'readonly' => $pickupsMutabilityCondition,
];
// Show differentDropOff checkbox only when BOTH outbound and inbound are BUS, readonly if pickups not mutable
$this->fieldStateConditions['differentDropOff'] = [
'hidden' => CompositeCondition::not(
CompositeCondition::and(
ServiceSubTypeCondition::equals('transportationOutbound', DirectionMapper::SUBTYPE_BUS_API),
ServiceSubTypeCondition::equals('transportationInbound', DirectionMapper::SUBTYPE_BUS_API)
)
),
'readonly' => $pickupsMutabilityCondition,
];
// Show drop-off when inbound is BUS (options provider further gates on checkbox state in BUS+BUS)
$this->fieldStateConditions['dropOff'] = [
'hidden' => CompositeCondition::not(
ServiceSubTypeCondition::equals('transportationInbound', DirectionMapper::SUBTYPE_BUS_API)
),
'readonly' => $pickupsMutabilityCondition,
];
// Parking - shown only when outbound transportation is PKW AND participant is 18+, readonly if transportation not mutable
// Participants under 18 cannot drive in Germany, so parking is not relevant for them
$minimumDrivingAgeCondition = new AgeRangeCondition(18);
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
* Handles drop-off location selection for the inbound bus journey.
*
* Drop-off behavior depends on the transportation combination:
* - BUS+BUS: drop-off is gated behind a "differentDropOff" checkbox.
* When unchecked, BusProNet defaults to the same location as the pickup.
* - PKW+BUS: drop-off is shown directly (no pickup field, no checkbox needed).
* - BUS+PKW / PKW+PKW: no drop-off applicable, fields are cleared.
*/
class ParticipantDropOffFieldHandler extends AbstractParticipantFieldHandler
{
public function getFieldName(): string
{
return 'dropOff';
}
public function getDependencies(): array
{
return ['transportationOutbound', 'transportationInbound', 'pickup'];
}
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return true; // Always process to handle clearing drop-off
}
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
return;
}
$hasOutboundBus = null !== $participant->transportationOutbound
&& DirectionMapper::SUBTYPE_BUS_API === $participant->transportationOutbound->subType;
$hasInboundBus = null !== $participant->transportationInbound
&& DirectionMapper::SUBTYPE_BUS_API === $participant->transportationInbound->subType;
// Drop-off only applies when inbound is BUS
if (false === $hasInboundBus) {
$participant->dropOff = null;
$participant->differentDropOff = false;
return;
}
if ($hasOutboundBus) {
// BUS+BUS: gated by differentDropOff checkbox
$checkboxValue = $this->getFieldValue($submittedData, 'differentDropOff');
$isChecked = true === $checkboxValue || '1' === $checkboxValue || 1 === $checkboxValue;
if (false === $isChecked) {
$participant->dropOff = null;
$participant->differentDropOff = false;
return;
}
$participant->differentDropOff = true;
}
// PKW+BUS or BUS+BUS with checkbox checked: validate drop-off selection
$selectedDropOff = $this->getFieldValue($submittedData, $this->getFieldName());
$participant->dropOff = $this->findItemById($selectedDropOff, $bookingDto->travel->dropOffs);
// For PKW+BUS, differentDropOff is not relevant (no checkbox), keep it false
if (false === $hasOutboundBus) {
$participant->differentDropOff = false;
}
}
}
@@ -225,6 +225,7 @@ class ParticipantFieldHandlerRegistry
'additionalServices',
'board',
'pickup',
'dropOff',
];
foreach ($serviceFields as $fieldName) {
@@ -547,14 +547,14 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Uses outbound pickups list, applies to both directions
// Returns empty array when no pickup options available
$this->fieldOptionProviders['pickup'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
$choices = $bookingDto->travel->pickupsOutbound;
$choices = $bookingDto->travel->pickups;
if (true === empty($choices)) {
return [];
}
return [
'label' => 'Zu- und Ausstieg',
'label' => 'Zustieg',
'choices' => $choices,
'choice_label' => fn (?Pickup $pickup) => $pickup?->getLabel(),
'choice_value' => 'id',
@@ -564,6 +564,43 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
};
// "Different drop-off" checkbox (BUS+BUS only)
$this->fieldOptionProviders['differentDropOff'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'abweichender Ausstieg',
'required' => false,
];
// Drop-off location (inbound direction)
// In BUS+BUS mode, gated by the differentDropOff checkbox state
// In PKW+BUS mode, shown directly (no checkbox involved)
$this->fieldOptionProviders['dropOff'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array {
$choices = $bookingDto->travel->dropOffs;
if (true === empty($choices)) {
return [];
}
$participant = $bookingDto->getParticipant($participantIndex);
// In BUS+BUS mode: only show drop-off choices when checkbox is checked
$hasOutboundBus = null !== $participant?->transportationOutbound
&& DirectionMapper::SUBTYPE_BUS_API === $participant->transportationOutbound->subType;
if ($hasOutboundBus && false === ($participant->differentDropOff ?? false)) {
return [];
}
return [
'label' => 'Ausstieg',
'choices' => $choices,
'choice_label' => fn (?Pickup $pickup) => $pickup?->getLabel(),
'choice_value' => 'id',
'expanded' => true,
'multiple' => false,
'required' => true,
];
};
// Parking (conditional - only shown when outbound transportation is PKW)
// Simple checkbox since there's only ever one parking type
// Returns empty array when no parking services exist to prevent field from rendering
@@ -57,6 +57,6 @@ class ParticipantPickupFieldHandler extends AbstractParticipantFieldHandler
$selectedPickup = $this->getFieldValue($submittedData, $this->getFieldName());
// Validate pickup selection against available outbound pickups
$participant->pickup = $this->findItemById($selectedPickup, $bookingDto->travel->pickupsOutbound);
$participant->pickup = $this->findItemById($selectedPickup, $bookingDto->travel->pickups);
}
}