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
@@ -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.
*
+25 -5
View File
@@ -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.
*
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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
+5 -4
View File
@@ -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');
+14 -14
View File
@@ -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;
}
}
}
+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);
}
}
+22 -1
View File
@@ -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;
}
/**
+4 -2
View File
@@ -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;
+9
View File
@@ -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