feat: selectable drop-offs for inbound bus travel
This commit is contained in:
@@ -124,6 +124,10 @@ class BookingDataProcessor
|
||||
// Pickup location
|
||||
$participantData->pickup = $booking->getPickupForParticipant($index);
|
||||
|
||||
// Drop-off location
|
||||
$participantData->dropOff = $booking->getDropOffForParticipant($index);
|
||||
$participantData->differentDropOff = null !== $participantData->dropOff;
|
||||
|
||||
// Parking service (for self-organized PKW transportation)
|
||||
$parkingServices = $booking->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_PARKING);
|
||||
if (false === empty($parkingServices)) {
|
||||
@@ -236,9 +240,9 @@ class BookingDataProcessor
|
||||
}
|
||||
|
||||
// Enrich pickup with data from travel (includes both outbound and inbound prices)
|
||||
// The outbound pickups already have inbound prices merged in from TravelParser
|
||||
if (null !== $participant->pickup && isset($travel->pickupsOutbound[$participant->pickup->id])) {
|
||||
$enrichedPickup = $travel->pickupsOutbound[$participant->pickup->id];
|
||||
// The pickups already have inbound prices merged in from TravelParser
|
||||
if (null !== $participant->pickup && isset($travel->pickups[$participant->pickup->id])) {
|
||||
$enrichedPickup = $travel->pickups[$participant->pickup->id];
|
||||
|
||||
// Copy all properties from enriched pickup to participant's pickup
|
||||
$participant->pickup->price = $enrichedPickup->price;
|
||||
@@ -251,6 +255,19 @@ class BookingDataProcessor
|
||||
$participant->pickup->code = $enrichedPickup->code;
|
||||
}
|
||||
|
||||
// Enrich drop-off with data from travel
|
||||
if (null !== $participant->dropOff && isset($travel->dropOffs[$participant->dropOff->id])) {
|
||||
$enrichedDropOff = $travel->dropOffs[$participant->dropOff->id];
|
||||
$participant->dropOff->price = $enrichedDropOff->price;
|
||||
$participant->dropOff->priceOutbound = $enrichedDropOff->priceOutbound;
|
||||
$participant->dropOff->priceInbound = $enrichedDropOff->priceInbound;
|
||||
$participant->dropOff->time = $enrichedDropOff->time;
|
||||
$participant->dropOff->city = $enrichedDropOff->city;
|
||||
$participant->dropOff->street = $enrichedDropOff->street;
|
||||
$participant->dropOff->postalCode = $enrichedDropOff->postalCode;
|
||||
$participant->dropOff->code = $enrichedDropOff->code;
|
||||
}
|
||||
|
||||
// Enrich insurance
|
||||
if (null !== $participant->insurance && isset($travel->insurances[$participant->insurance->id])) {
|
||||
$participant->insurance = $travel->insurances[$participant->insurance->id];
|
||||
@@ -301,6 +318,7 @@ class BookingDataProcessor
|
||||
$this->payloadBuilder->buildParticipantPayload($payload, $bookingData, $formData->participants);
|
||||
$this->payloadBuilder->buildServicesPayload($payload, $bookingData);
|
||||
$this->payloadBuilder->buildPickupPayload($payload, $bookingData);
|
||||
$this->payloadBuilder->buildDropOffPayload($payload, $bookingData);
|
||||
|
||||
// Add purchase vouchers if any exist
|
||||
$purchaseVouchers = $this->mappingCollector->collectPurchaseVouchers($formData);
|
||||
|
||||
@@ -203,9 +203,9 @@ class BookingPayloadBuilder
|
||||
*/
|
||||
public function buildPickupPayload(array &$payload, Booking $bookingData): void
|
||||
{
|
||||
if (0 < count($bookingData->pickupsOutbound)) {
|
||||
if (0 < count($bookingData->pickups)) {
|
||||
$payload['zustiege']['zustieg'] = [];
|
||||
foreach ($bookingData->pickupsOutbound as $pickup) {
|
||||
foreach ($bookingData->pickups as $pickup) {
|
||||
$uniqueMapping = array_unique($pickup->mapping);
|
||||
$payload['zustiege']['zustieg'][] = [
|
||||
'@idzustieg' => $pickup->id,
|
||||
@@ -216,6 +216,29 @@ class BookingPayloadBuilder
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the drop-off locations section of the payload.
|
||||
*
|
||||
* Only included in payload if there are actual drop-off assignments for bus transportation.
|
||||
*
|
||||
* @param array $payload The payload array to modify
|
||||
* @param Booking $bookingData The booking data object
|
||||
*/
|
||||
public function buildDropOffPayload(array &$payload, Booking $bookingData): void
|
||||
{
|
||||
if (0 < count($bookingData->dropOffs)) {
|
||||
$payload['ausstiege']['ausstieg'] = [];
|
||||
foreach ($bookingData->dropOffs as $dropOff) {
|
||||
$uniqueMapping = array_unique($dropOff->mapping);
|
||||
$payload['ausstiege']['ausstieg'][] = [
|
||||
'@idzustieg' => $dropOff->id,
|
||||
'@anzahl' => count($uniqueMapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a create booking request payload.
|
||||
*
|
||||
@@ -367,6 +390,10 @@ class BookingPayloadBuilder
|
||||
$this->addRoomMappingsToPayload($payload, $roomMap, $bookingDto);
|
||||
$this->addServicesFromMap($payload, 'zusatzleistungen', 'zusatzleistung', '@idleistung', $serviceMap);
|
||||
$this->addServicesFromMap($payload, 'zustiege', 'zustieg', '@idzustieg', $pickupMap);
|
||||
|
||||
$dropOffMap = $this->mappingCollector->collectDropOffMappings($bookingDto);
|
||||
$this->addServicesFromMap($payload, 'ausstiege', 'ausstieg', '@idzustieg', $dropOffMap);
|
||||
|
||||
$this->addServicesFromMap($payload, 'versicherungen', 'versicherung', '@idversicherung', $insuranceMap);
|
||||
|
||||
// Add purchase vouchers (regular purchase vouchers, excluding goodwill vouchers)
|
||||
|
||||
@@ -36,8 +36,8 @@ class ParticipantServiceProcessor
|
||||
$servicesToReset = [
|
||||
...$bookingData->additionalServices,
|
||||
...$bookingData->transportationServices,
|
||||
...$bookingData->pickupsOutbound,
|
||||
...$bookingData->pickupsInbound,
|
||||
...$bookingData->pickups,
|
||||
...$bookingData->dropOffs,
|
||||
...$bookingData->rooms,
|
||||
...$bookingData->insurances,
|
||||
];
|
||||
@@ -66,6 +66,7 @@ class ParticipantServiceProcessor
|
||||
$this->processAdditionalServices($participant, $bookingData, $travelData);
|
||||
$this->processTransportationServices($participant, $bookingData, $travelData);
|
||||
$this->processPickupLocations($participant, $bookingData);
|
||||
$this->processDropOffLocations($participant, $bookingData);
|
||||
$this->processRoomAssignment($participant, $bookingData);
|
||||
$this->processInsurance($participant, $bookingData, $travelData);
|
||||
}
|
||||
@@ -92,15 +93,15 @@ class ParticipantServiceProcessor
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($bookingData->pickupsOutbound as $pickup) {
|
||||
foreach ($bookingData->pickups as $pickup) {
|
||||
if (0 === count($pickup->mapping)) {
|
||||
unset($bookingData->pickupsOutbound[$pickup->id]);
|
||||
unset($bookingData->pickups[$pickup->id]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($bookingData->pickupsInbound as $pickup) {
|
||||
if (0 === count($pickup->mapping)) {
|
||||
unset($bookingData->pickupsInbound[$pickup->id]);
|
||||
foreach ($bookingData->dropOffs as $dropOff) {
|
||||
if (0 === count($dropOff->mapping)) {
|
||||
unset($bookingData->dropOffs[$dropOff->id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,10 +233,34 @@ class ParticipantServiceProcessor
|
||||
$hasInboundBus = null !== $participant->transportationInbound && 'BUS' === $participant->transportationInbound->subType;
|
||||
|
||||
if (($hasOutboundBus || $hasInboundBus) && null !== $selectedPickup = $participant->pickup) {
|
||||
if (false === isset($bookingData->pickupsOutbound[$selectedPickup->id])) {
|
||||
$bookingData->pickupsOutbound[$selectedPickup->id] = $selectedPickup;
|
||||
if (false === isset($bookingData->pickups[$selectedPickup->id])) {
|
||||
$bookingData->pickups[$selectedPickup->id] = $selectedPickup;
|
||||
}
|
||||
$bookingData->pickupsOutbound[$selectedPickup->id]->mapping[] = $participant->index;
|
||||
$bookingData->pickups[$selectedPickup->id]->mapping[] = $participant->index;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes drop-off locations for participants using bus transportation.
|
||||
*
|
||||
* Only processes drop-off locations for bus transportation services and maps the participant
|
||||
* to their selected drop-off location.
|
||||
*
|
||||
* @param ParticipantDto $participant The participant data from the form
|
||||
* @param Booking $bookingData The booking data object to update
|
||||
*/
|
||||
private function processDropOffLocations(ParticipantDto $participant, Booking $bookingData): void
|
||||
{
|
||||
$hasOutboundBus = null !== $participant->transportationOutbound
|
||||
&& 'BUS' === $participant->transportationOutbound->subType;
|
||||
$hasInboundBus = null !== $participant->transportationInbound
|
||||
&& 'BUS' === $participant->transportationInbound->subType;
|
||||
|
||||
if (($hasOutboundBus || $hasInboundBus) && null !== $selectedDropOff = $participant->dropOff) {
|
||||
if (false === isset($bookingData->dropOffs[$selectedDropOff->id])) {
|
||||
$bookingData->dropOffs[$selectedDropOff->id] = $selectedDropOff;
|
||||
}
|
||||
$bookingData->dropOffs[$selectedDropOff->id]->mapping[] = $participant->index;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,14 +31,14 @@ class PersonalDataSynchronizer
|
||||
* IMPORTANT: The applicant's address must never be modified. This method updates
|
||||
* participant addresses independently to ensure applicant data remains intact.
|
||||
*
|
||||
* @param array<ParticipantDto> $participants The participants array from the form
|
||||
* @param Booking $bookingData The booking data object to update
|
||||
* @param bool $isInternalAgencyBooking Whether to fill default names for canceled participants
|
||||
* @param array<ParticipantDto> $participants The participants array from the form
|
||||
* @param Booking $bookingData The booking data object to update
|
||||
* @param bool $isInternalAgencyBooking Whether to fill default names for canceled participants
|
||||
*/
|
||||
public function updateParticipantPersonalData(
|
||||
array $participants,
|
||||
Booking $bookingData,
|
||||
bool $isInternalAgencyBooking = false
|
||||
bool $isInternalAgencyBooking = false,
|
||||
): void {
|
||||
// Fill missing mandatory fields on applicant (for company bookings by travel agencies)
|
||||
// This is separate from participants - applicant may be a company while participants are real people
|
||||
|
||||
@@ -143,6 +143,28 @@ class ServiceMappingCollector
|
||||
return $pickupMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects drop-off location mappings.
|
||||
*
|
||||
* Collects the drop-off selection for the inbound/return direction.
|
||||
*
|
||||
* @return array<string, array<int>> Map of drop-off ID to participant IDs
|
||||
*/
|
||||
public function collectDropOffMappings(BookingDto $bookingDto): array
|
||||
{
|
||||
$dropOffMap = [];
|
||||
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
$participantId = $index + 1;
|
||||
|
||||
if (null !== $participant->dropOff) {
|
||||
$dropOffMap[$participant->dropOff->id][] = $participantId;
|
||||
}
|
||||
}
|
||||
|
||||
return $dropOffMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects insurance mappings.
|
||||
*
|
||||
|
||||
@@ -41,8 +41,8 @@ class Booking
|
||||
public array $transportationServices = [];
|
||||
public array $additionalServices = [];
|
||||
public array $rooms = [];
|
||||
public array $pickupsOutbound = [];
|
||||
public array $pickupsInbound = [];
|
||||
public array $pickups = [];
|
||||
public array $dropOffs = [];
|
||||
public array $surcharges = [];
|
||||
public array $insurances = [];
|
||||
public ?int $invoiceNumber = null;
|
||||
@@ -136,8 +136,7 @@ class Booking
|
||||
* Retrieves pickup service for a specific participant.
|
||||
*
|
||||
* Finds the pickup service assigned to the specified participant
|
||||
* from the outbound pickup services. The API only supports pickups
|
||||
* when outbound transportation is bus.
|
||||
* from the pickup services.
|
||||
*
|
||||
* @param int $participantIndex The participant index to search for
|
||||
*
|
||||
@@ -145,7 +144,7 @@ class Booking
|
||||
*/
|
||||
public function getPickupForParticipant(int $participantIndex): ?Pickup
|
||||
{
|
||||
foreach ($this->pickupsOutbound as $pickup) {
|
||||
foreach ($this->pickups as $pickup) {
|
||||
if (in_array($participantIndex, $pickup->mapping)) {
|
||||
return $pickup;
|
||||
}
|
||||
@@ -154,6 +153,27 @@ class Booking
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves drop-off service for a specific participant.
|
||||
*
|
||||
* Finds the drop-off service assigned to the specified participant
|
||||
* from the drop-off services (inbound/return direction).
|
||||
*
|
||||
* @param int $participantIndex The participant index to search for
|
||||
*
|
||||
* @return Pickup|null The matching drop-off service or null if not found
|
||||
*/
|
||||
public function getDropOffForParticipant(int $participantIndex): ?Pickup
|
||||
{
|
||||
foreach ($this->dropOffs as $dropOff) {
|
||||
if (in_array($participantIndex, $dropOff->mapping)) {
|
||||
return $dropOff;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the insurance assigned to a specific participant.
|
||||
*
|
||||
|
||||
@@ -84,10 +84,10 @@ class Travel
|
||||
public bool $transportationServicesMutable = true;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public array $pickupsOutbound = [];
|
||||
public array $pickups = [];
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public array $pickupsInbound = [];
|
||||
public array $dropOffs = [];
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public bool $pickupsMutable = true;
|
||||
|
||||
@@ -61,8 +61,8 @@ class PickupLoader extends AbstractLoader
|
||||
|
||||
public function patchPickupsDetails(Travel $travel): void
|
||||
{
|
||||
$travel->pickupsOutbound = $this->patchAndSortPickups($travel->pickupsOutbound);
|
||||
$travel->pickupsInbound = $this->patchAndSortPickups($travel->pickupsInbound);
|
||||
$travel->pickups = $this->patchAndSortPickups($travel->pickups);
|
||||
$travel->dropOffs = $this->patchAndSortPickups($travel->dropOffs);
|
||||
}
|
||||
|
||||
private function patchAndSortPickups(array $pickups): array
|
||||
|
||||
@@ -91,11 +91,12 @@ class BookingParser extends AbstractParser
|
||||
|
||||
$pickupsData = $node->filterXPath('//zustiege/zustieg');
|
||||
if (0 < $pickupsData->count()) {
|
||||
$booking->pickupsOutbound = $this->pickupsParser->parse($pickupsData);
|
||||
$booking->pickups = $this->pickupsParser->parse($pickupsData);
|
||||
}
|
||||
$pickupsData = $node->filterXPath('//zustiege_rueck/zustieg_rueck');
|
||||
if (0 < $pickupsData->count()) {
|
||||
$booking->pickupsInbound = $this->pickupsParser->parse($pickupsData);
|
||||
|
||||
$dropOffsData = $node->filterXPath('//ausstiege/ausstieg');
|
||||
if (0 < $dropOffsData->count()) {
|
||||
$booking->dropOffs = $this->pickupsParser->parse($dropOffsData);
|
||||
}
|
||||
|
||||
$surchargesData = $node->filterXPath('//zuschlaege/zuschlag');
|
||||
|
||||
@@ -76,11 +76,11 @@ class TravelParser extends AbstractParser
|
||||
$travel->transportationServices = $this
|
||||
->getTransportationServices($node->filterXPath('//lei_befoerderung/leistung'));
|
||||
$travel->rooms = $this->getRooms($hotelNode);
|
||||
$travel->pickupsOutbound = $this->getPickups($node->filterXPath('//zustiege/zustieg'), $dateFrom, true);
|
||||
$travel->pickupsInbound = $this->getPickups($node->filterXPath('//zustiege_rueck/zustieg_rueck'), $dateTo, false);
|
||||
$travel->pickups = $this->getPickups($node->filterXPath('//zustiege/zustieg'), $dateFrom, true);
|
||||
$travel->dropOffs = $this->getPickups($node->filterXPath('//zustiege_rueck/zustieg_rueck'), $dateTo, false);
|
||||
|
||||
// Merge inbound prices into outbound pickups for split pricing support
|
||||
$this->mergeInboundPricesIntoOutboundPickups($travel->pickupsOutbound, $travel->pickupsInbound);
|
||||
// Merge drop-off prices into pickups for split pricing support
|
||||
$this->mergeDropOffPricesIntoPickups($travel->pickups, $travel->dropOffs);
|
||||
|
||||
$travel->guide = $this->getGuide($node);
|
||||
|
||||
@@ -422,21 +422,21 @@ class TravelParser extends AbstractParser
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges inbound pickup prices into outbound pickups for split pricing support.
|
||||
* Merges drop-off prices into pickups for split pricing support.
|
||||
*
|
||||
* For each outbound pickup, finds the matching inbound pickup by ID and copies
|
||||
* the inbound price to the outbound pickup's priceInbound property. This enables
|
||||
* split pricing calculations where pickup costs are distributed between outbound
|
||||
* For each pickup, finds the matching drop-off by ID and copies
|
||||
* the inbound price to the pickup's priceInbound property. This enables
|
||||
* split pricing calculations where costs are distributed between outbound
|
||||
* and inbound transportation.
|
||||
*
|
||||
* @param array<int, Pickup> $pickupsOutbound Outbound pickups indexed by ID (modified in place)
|
||||
* @param array<int, Pickup> $pickupsInbound Inbound pickups indexed by ID
|
||||
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
|
||||
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID
|
||||
*/
|
||||
private function mergeInboundPricesIntoOutboundPickups(array &$pickupsOutbound, array $pickupsInbound): void
|
||||
private function mergeDropOffPricesIntoPickups(array &$pickups, array $dropOffs): void
|
||||
{
|
||||
foreach ($pickupsOutbound as $pickupId => $outboundPickup) {
|
||||
if (isset($pickupsInbound[$pickupId])) {
|
||||
$outboundPickup->priceInbound = $pickupsInbound[$pickupId]->priceInbound;
|
||||
foreach ($pickups as $pickupId => $pickup) {
|
||||
if (isset($dropOffs[$pickupId])) {
|
||||
$pickup->priceInbound = $dropOffs[$pickupId]->priceInbound;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +401,15 @@ class BookingEditDraftService
|
||||
}
|
||||
}
|
||||
|
||||
// Drop-off (single) - merge strategy: only apply if resolves to valid drop-off
|
||||
if (true === array_key_exists('dropOff', $data) && null !== $data['dropOff']) {
|
||||
$resolved = $this->resolveDropOff($data['dropOff'], $travel);
|
||||
if (null !== $resolved) {
|
||||
$participant->dropOff = $resolved;
|
||||
$participant->differentDropOff = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Parking (boolean) - overwrite strategy: user can uncheck
|
||||
if (true === array_key_exists('parking', $data)) {
|
||||
$participant->parking = (bool) $data['parking'];
|
||||
@@ -482,7 +491,19 @@ class BookingEditDraftService
|
||||
return null;
|
||||
}
|
||||
|
||||
return $travel->pickupsOutbound[$pickupId] ?? $travel->pickupsInbound[$pickupId] ?? null;
|
||||
return $travel->pickups[$pickupId] ?? $travel->dropOffs[$pickupId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a drop-off ID to a Pickup object from the travel drop-offs list.
|
||||
*/
|
||||
private function resolveDropOff(?int $dropOffId, Travel $travel): ?object
|
||||
{
|
||||
if (null === $dropOffId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $travel->dropOffs[$dropOffId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,6 +45,7 @@ class BookingExportService
|
||||
'Hinfahrt',
|
||||
'Rückfahrt',
|
||||
'Zustieg',
|
||||
'Ausstieg',
|
||||
'Parkplatz',
|
||||
'Versicherung',
|
||||
'Sammelversicherung',
|
||||
@@ -133,13 +134,13 @@ class BookingExportService
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($travel->pickupsOutbound as $pickup) {
|
||||
foreach ($travel->pickups as $pickup) {
|
||||
if (null !== $pickup->id) {
|
||||
$pickups[$pickup->id] = $pickup->getLabel();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($travel->pickupsInbound as $pickup) {
|
||||
foreach ($travel->dropOffs as $pickup) {
|
||||
if (null !== $pickup->id) {
|
||||
$pickups[$pickup->id] = $pickup->getLabel();
|
||||
}
|
||||
@@ -242,6 +243,7 @@ class BookingExportService
|
||||
$this->resolveService($services['transportationOutbound'] ?? null, $lookups['services']),
|
||||
$this->resolveService($services['transportationInbound'] ?? null, $lookups['services']),
|
||||
$this->resolvePickup($services['pickup'] ?? null, $lookups['pickups']),
|
||||
$this->resolvePickup($services['dropOff'] ?? null, $lookups['pickups']),
|
||||
$this->formatBoolean($services['parking'] ?? false),
|
||||
$this->resolveService($services['insurance'] ?? null, $lookups['services']),
|
||||
$this->formatBoolean($services['bulkInsuranceBooking'] ?? false),
|
||||
|
||||
@@ -101,6 +101,7 @@ class BookingFingerprintService
|
||||
'transportationOutbound' => $participant->transportationOutbound?->id,
|
||||
'transportationInbound' => $participant->transportationInbound?->id,
|
||||
'pickup' => $participant->pickup?->id,
|
||||
'dropOff' => $participant->dropOff?->id,
|
||||
'parking' => $participant->parking,
|
||||
'insurance' => $participant->insurance?->id,
|
||||
'bulkInsuranceBooking' => $participant->bulkInsuranceBooking,
|
||||
|
||||
@@ -111,6 +111,7 @@ class ParticipantPricingCalculator
|
||||
'transportationOut' => $participant->transportationOutbound?->id ?? 'none',
|
||||
'transportationIn' => $participant->transportationInbound?->id ?? 'none',
|
||||
'pickup' => $participant->pickup?->id ?? 'none',
|
||||
'dropOff' => $participant->dropOff?->id ?? 'none',
|
||||
'parking' => $participant->parkingService?->id ?? 'none',
|
||||
'courses' => implode('_', array_map(fn ($s) => $s->id, $participant->courses ?? [])),
|
||||
'additionalServices' => implode('_', array_map(fn ($s) => $s->id, $participant->additionalServices ?? [])),
|
||||
@@ -240,6 +241,11 @@ class ParticipantPricingCalculator
|
||||
$serviceTotal += $participant->pickup->price;
|
||||
}
|
||||
|
||||
// Drop-off pricing (charged when inbound transportation is BUS and a drop-off is selected)
|
||||
if (null !== $participant->dropOff && null !== $participant->dropOff->price) {
|
||||
$serviceTotal += $participant->dropOff->price;
|
||||
}
|
||||
|
||||
if (null !== $participant->parkingService && null !== $participant->parkingService->price) {
|
||||
if (false === $onlyInsuranceCalculationServices || true === $participant->parkingService->includeInInsuranceCalculation) {
|
||||
$serviceTotal += $participant->parkingService->price;
|
||||
|
||||
@@ -143,6 +143,15 @@ class ServicePricingCalculator
|
||||
}
|
||||
}
|
||||
|
||||
// Drop-off pricing (charged when inbound is BUS and a drop-off is selected)
|
||||
if (null !== $participant->dropOff && null !== $participant->dropOff->price) {
|
||||
if ($participant->dropOff->price < 0) {
|
||||
$participantTransportationDiscountCost += $participant->dropOff->price;
|
||||
} else {
|
||||
$participantTransportationPositiveCost += $participant->dropOff->price;
|
||||
}
|
||||
}
|
||||
|
||||
// Parking service pricing
|
||||
if (null !== $participant->parkingService && null !== $participant->parkingService->price) {
|
||||
$participantParkingCost += $participant->parkingService->price;
|
||||
|
||||
@@ -22,6 +22,7 @@ class ParticipantValidator extends ConstraintValidator
|
||||
$participant = $value;
|
||||
|
||||
$this->assertPickupSelected($participant);
|
||||
$this->assertDropOffSelected($participant);
|
||||
$this->assertBodyDimensionsWhenRentalsSelected($participant);
|
||||
}
|
||||
|
||||
@@ -42,6 +43,29 @@ class ParticipantValidator extends ConstraintValidator
|
||||
}
|
||||
}
|
||||
|
||||
public function assertDropOffSelected(ParticipantDto $participant): void
|
||||
{
|
||||
$hasOutboundBus = null !== $participant->transportationOutbound
|
||||
&& 'BUS' === $participant->transportationOutbound->subType;
|
||||
$hasInboundBus = null !== $participant->transportationInbound
|
||||
&& 'BUS' === $participant->transportationInbound->subType;
|
||||
|
||||
if (false === $hasInboundBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
// BUS+BUS: drop-off required only when differentDropOff checkbox is checked
|
||||
// PKW+BUS: drop-off always required (no checkbox, field shown directly)
|
||||
$requireDropOff = $hasOutboundBus ? $participant->differentDropOff : true;
|
||||
|
||||
if ($requireDropOff && null === $participant->dropOff) {
|
||||
$this->context->buildViolation('Bitte auswählen')
|
||||
->atPath('dropOff')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
public function assertBodyDimensionsWhenRentalsSelected(ParticipantDto $participant): void
|
||||
{
|
||||
// Check if any rental services are selected
|
||||
|
||||
Reference in New Issue
Block a user