From 0884a39f3da6d0376432b0c759eaa7cefdb1feaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Fri, 13 Feb 2026 13:42:52 +0100 Subject: [PATCH] feat: selectable drop-offs for inbound bus travel --- config/services.yaml | 1 + docs/pickup-api-limitation.md | 130 ---------- docs/technical-documentation.md | 4 +- .../DataProcessor/BookingDataProcessor.php | 24 +- .../DataProcessor/BookingPayloadBuilder.php | 31 ++- .../ParticipantServiceProcessor.php | 45 +++- .../PersonalDataSynchronizer.php | 8 +- .../DataProcessor/ServiceMappingCollector.php | 22 ++ src/BusProNet/Model/Booking.php | 30 ++- src/BusProNet/Model/Travel.php | 4 +- src/BusProNet/XmlLoader/PickupLoader.php | 4 +- src/BusProNet/XmlParser/BookingParser.php | 9 +- src/BusProNet/XmlParser/TravelParser.php | 28 +- src/Form/BookingParticipantType.php | 2 + src/Form/Model/ParticipantDto.php | 21 ++ src/Form/Service/CreateFieldStateProvider.php | 17 ++ src/Form/Service/EditFieldStateProvider.php | 19 ++ .../ParticipantDropOffFieldHandler.php | 81 ++++++ .../ParticipantFieldHandlerRegistry.php | 1 + .../ParticipantFieldOptionsProvider.php | 41 ++- .../Service/ParticipantPickupFieldHandler.php | 2 +- src/Service/BookingEditDraftService.php | 23 +- src/Service/BookingExportService.php | 6 +- src/Service/BookingFingerprintService.php | 1 + src/Service/ParticipantPricingCalculator.php | 6 + src/Service/ServicePricingCalculator.php | 9 + .../Constraints/ParticipantValidator.php | 24 ++ templates/booking/_participant_form.html.twig | 42 ++- templates/booking/create/step_4.html.twig | 14 +- .../BookingDataProcessorTest.php | 10 +- .../ParticipantDropOffFieldHandlerTest.php | 242 ++++++++++++++++++ 31 files changed, 701 insertions(+), 200 deletions(-) delete mode 100644 docs/pickup-api-limitation.md create mode 100644 src/Form/Service/ParticipantDropOffFieldHandler.php create mode 100644 tests/Form/Service/ParticipantDropOffFieldHandlerTest.php diff --git a/config/services.yaml b/config/services.yaml index 28a0da0..ebdd626 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -137,6 +137,7 @@ services: - 'App\Form\Service\ParticipantTransportationOutboundFieldHandler' - 'App\Form\Service\ParticipantTransportationInboundFieldHandler' - 'App\Form\Service\ParticipantPickupFieldHandler' + - 'App\Form\Service\ParticipantDropOffFieldHandler' - 'App\Form\Service\ParticipantParkingFieldHandler' - 'App\Form\Service\ParticipantRentalInsuranceFieldHandler' - 'App\Form\Service\ParticipantLicensePlateFieldHandler' diff --git a/docs/pickup-api-limitation.md b/docs/pickup-api-limitation.md deleted file mode 100644 index 90601f1..0000000 --- a/docs/pickup-api-limitation.md +++ /dev/null @@ -1,130 +0,0 @@ -# Pickup Pricing - Implementation Documentation - -## Current BusPro Behavior (January 2026) - -BusPro currently only charges pickup price when the **outbound** transportation is BUS, regardless of the inbound selection. - -### Pricing Matrix (Current) - -| Outbound | Inbound | BusPro Charges | Notes | -|----------|---------|----------------|-------| -| BUS | BUS | Pickup price once | Full price from outbound | -| BUS | PKW | Pickup price once | Full price from outbound | -| PKW | BUS | **€0** | Known loophole in BusPro | -| PKW | PKW | €0 | No pickup available | - -The "loophole" (PKW outbound + BUS inbound = no charge) exists on BusPro's side. Our portal must match this behavior to avoid price validation errors when submitting bookings. - -## XML Data Structure - -Both travel sections contain pickup data with prices: - -```xml - - - - - - - - - -``` - -Currently, prices are duplicated in both sections. In the future, when BusPro supports split pricing, travels may be configured with different prices per direction (e.g., 2.95 each way instead of 5.90 outbound only). - -## Portal Implementation - -### Forward-Compatible Data Layer - -The portal parses and stores both direction prices for future use: - -1. **Pickup Model** (`src/BusProNet/Model/Pickup.php`): - - `price`: The outbound price (used for pricing calculations) - - `priceOutbound`: Explicit outbound price (populated but not used yet) - - `priceInbound`: Explicit inbound price (populated but not used yet) - - `calculateEffectivePrice()`: Ready for future split pricing activation - -2. **TravelParser** (`src/BusProNet/XmlParser/TravelParser.php`): - - Parses `zustiege` section and sets `priceOutbound` - - Parses `zustiege_rueck` section and sets `priceInbound` - - Merges inbound prices into outbound pickup objects by ID - -3. **BookingDataProcessor** (`src/BusProNet/DataProcessor/BookingDataProcessor.php`): - - Enriches participant pickups with all price properties from travel data - -### Current Pricing Logic - -The pricing calculators use simple outbound-only logic to match BusPro: - -```php -// Only charge if outbound is BUS - matches current BusPro behavior -$hasOutboundBus = null !== $participant->transportationOutbound - && 'BUS' === $participant->transportationOutbound->subType; - -if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) { - $serviceTotal += $participant->pickup->price; -} -``` - -This ensures portal prices always match BusPro responses, avoiding validation errors. - -## API Constraints - -- Pickups can only be submitted via the `zustiege` (outbound) XML section -- There is no `zustiege_rueck` (inbound) equivalent for submission -- Booking responses return pickups in the outbound section only -- A future API update will support separate pickup/drop-off locations - -## Future Activation (When BusPro Supports Split Pricing) - -When BusPro is updated to charge based on actual transportation selections, update the pricing calculators to use `calculateEffectivePrice()`: - -```php -$hasOutboundBus = null !== $participant->transportationOutbound - && 'BUS' === $participant->transportationOutbound->subType; -$hasInboundBus = null !== $participant->transportationInbound - && 'BUS' === $participant->transportationInbound->subType; - -if (null !== $participant->pickup) { - $pickupPrice = $participant->pickup->calculateEffectivePrice($hasOutboundBus, $hasInboundBus); - if (null !== $pickupPrice) { - $serviceTotal += $pickupPrice; - } -} -``` - -### Important Caveat for Split Pricing Configuration - -If travels are configured with split pricing (e.g., outbound=2.95, inbound=2.95) before BusPro supports it: - -| Scenario | Portal Charges | BusPro Charges | Intended | -|----------|----------------|----------------|----------| -| BUS+BUS | 2.95 | 2.95 | 5.90 | -| BUS+PKW | 2.95 | 2.95 | 2.95 | -| PKW+BUS | 0.00 | 0.00 | 2.95 | - -**Recommendation**: Keep full price on outbound (`preis="5,90"`) until BusPro supports split pricing, then reconfigure to true split values. - -## Related Code Locations - -- `src/BusProNet/Model/Pickup.php` - Pickup model with split pricing properties and calculation methods -- `src/BusProNet/XmlParser/TravelParser.php` - Parses and merges pickup prices from XML -- `src/BusProNet/DataProcessor/BookingDataProcessor.php` - Enriches pickups with travel data -- `src/BusProNet/DataProcessor/BookingPayloadBuilder.php` - Pickup submission -- `src/BusProNet/Model/Booking.php` - `getPickupForParticipant()` method -- `src/Form/Service/EditFieldStateProvider.php` - Pickup field visibility -- `src/Form/Service/CreateFieldStateProvider.php` - Pickup field visibility -- `src/Service/ParticipantPricingCalculator.php` - Pickup pricing logic (individual) -- `src/Service/ServicePricingCalculator.php` - Pickup pricing aggregation (summary) - -## Test Scenarios - -1. **BUS + BUS**: Charges `pickup->price` (outbound) -2. **BUS + PKW**: Charges `pickup->price` (outbound) -3. **PKW + BUS**: Charges €0 (matches BusPro loophole) -4. **PKW + PKW**: No pickup available - ---- - -*Last updated: 2026-01-21* diff --git a/docs/technical-documentation.md b/docs/technical-documentation.md index db148b7..95b7dc0 100644 --- a/docs/technical-documentation.md +++ b/docs/technical-documentation.md @@ -329,8 +329,8 @@ class Travel public array $additionalServices = []; public array $transportationServices = []; public array $rooms = []; - public array $pickupsOutbound = []; - public array $pickupsInbound = []; + public array $pickups = []; + public array $dropOffs = []; public array $insurances = []; public function getAdditionalServicesBySubTypes(mixed $subTypes): array; diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index aa318b2..2c8d1fe 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -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); diff --git a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php index 56991c9..3af25f2 100644 --- a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php +++ b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php @@ -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) diff --git a/src/BusProNet/DataProcessor/ParticipantServiceProcessor.php b/src/BusProNet/DataProcessor/ParticipantServiceProcessor.php index fc88c09..dfe4d2a 100644 --- a/src/BusProNet/DataProcessor/ParticipantServiceProcessor.php +++ b/src/BusProNet/DataProcessor/ParticipantServiceProcessor.php @@ -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; } } diff --git a/src/BusProNet/DataProcessor/PersonalDataSynchronizer.php b/src/BusProNet/DataProcessor/PersonalDataSynchronizer.php index b760b0d..12de4f5 100644 --- a/src/BusProNet/DataProcessor/PersonalDataSynchronizer.php +++ b/src/BusProNet/DataProcessor/PersonalDataSynchronizer.php @@ -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 $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 $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 diff --git a/src/BusProNet/DataProcessor/ServiceMappingCollector.php b/src/BusProNet/DataProcessor/ServiceMappingCollector.php index 48913ac..653688f 100644 --- a/src/BusProNet/DataProcessor/ServiceMappingCollector.php +++ b/src/BusProNet/DataProcessor/ServiceMappingCollector.php @@ -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> 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. * diff --git a/src/BusProNet/Model/Booking.php b/src/BusProNet/Model/Booking.php index 16c3dae..e2b76d4 100644 --- a/src/BusProNet/Model/Booking.php +++ b/src/BusProNet/Model/Booking.php @@ -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. * diff --git a/src/BusProNet/Model/Travel.php b/src/BusProNet/Model/Travel.php index 4691f8e..ace477d 100644 --- a/src/BusProNet/Model/Travel.php +++ b/src/BusProNet/Model/Travel.php @@ -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; diff --git a/src/BusProNet/XmlLoader/PickupLoader.php b/src/BusProNet/XmlLoader/PickupLoader.php index 9180a5d..69648e6 100644 --- a/src/BusProNet/XmlLoader/PickupLoader.php +++ b/src/BusProNet/XmlLoader/PickupLoader.php @@ -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 diff --git a/src/BusProNet/XmlParser/BookingParser.php b/src/BusProNet/XmlParser/BookingParser.php index c595000..452f2b1 100644 --- a/src/BusProNet/XmlParser/BookingParser.php +++ b/src/BusProNet/XmlParser/BookingParser.php @@ -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'); diff --git a/src/BusProNet/XmlParser/TravelParser.php b/src/BusProNet/XmlParser/TravelParser.php index 036e5e5..26d378a 100644 --- a/src/BusProNet/XmlParser/TravelParser.php +++ b/src/BusProNet/XmlParser/TravelParser.php @@ -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 $pickupsOutbound Outbound pickups indexed by ID (modified in place) - * @param array $pickupsInbound Inbound pickups indexed by ID + * @param array $pickups Pickups indexed by ID (modified in place) + * @param array $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; } } } diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index 412b582..34ca7d5 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -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, ]; diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 5c8da02..8c2d219 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -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. */ diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index 5d2f6aa..6095193 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -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 diff --git a/src/Form/Service/EditFieldStateProvider.php b/src/Form/Service/EditFieldStateProvider.php index d1c4010..385be70 100644 --- a/src/Form/Service/EditFieldStateProvider.php +++ b/src/Form/Service/EditFieldStateProvider.php @@ -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); diff --git a/src/Form/Service/ParticipantDropOffFieldHandler.php b/src/Form/Service/ParticipantDropOffFieldHandler.php new file mode 100644 index 0000000..8251cdf --- /dev/null +++ b/src/Form/Service/ParticipantDropOffFieldHandler.php @@ -0,0 +1,81 @@ +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; + } + } +} diff --git a/src/Form/Service/ParticipantFieldHandlerRegistry.php b/src/Form/Service/ParticipantFieldHandlerRegistry.php index 485f54a..cc2e72e 100644 --- a/src/Form/Service/ParticipantFieldHandlerRegistry.php +++ b/src/Form/Service/ParticipantFieldHandlerRegistry.php @@ -225,6 +225,7 @@ class ParticipantFieldHandlerRegistry 'additionalServices', 'board', 'pickup', + 'dropOff', ]; foreach ($serviceFields as $fieldName) { diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 2f74651..ef3d1b4 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -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 diff --git a/src/Form/Service/ParticipantPickupFieldHandler.php b/src/Form/Service/ParticipantPickupFieldHandler.php index 734dffb..865ff10 100644 --- a/src/Form/Service/ParticipantPickupFieldHandler.php +++ b/src/Form/Service/ParticipantPickupFieldHandler.php @@ -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); } } diff --git a/src/Service/BookingEditDraftService.php b/src/Service/BookingEditDraftService.php index 9dd4389..ba3ce8a 100644 --- a/src/Service/BookingEditDraftService.php +++ b/src/Service/BookingEditDraftService.php @@ -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; } /** diff --git a/src/Service/BookingExportService.php b/src/Service/BookingExportService.php index 97dc889..0f407a3 100644 --- a/src/Service/BookingExportService.php +++ b/src/Service/BookingExportService.php @@ -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), diff --git a/src/Service/BookingFingerprintService.php b/src/Service/BookingFingerprintService.php index 6fad900..1257e12 100644 --- a/src/Service/BookingFingerprintService.php +++ b/src/Service/BookingFingerprintService.php @@ -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, diff --git a/src/Service/ParticipantPricingCalculator.php b/src/Service/ParticipantPricingCalculator.php index 9034041..96b38c3 100644 --- a/src/Service/ParticipantPricingCalculator.php +++ b/src/Service/ParticipantPricingCalculator.php @@ -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; diff --git a/src/Service/ServicePricingCalculator.php b/src/Service/ServicePricingCalculator.php index eceb7de..0871a4b 100644 --- a/src/Service/ServicePricingCalculator.php +++ b/src/Service/ServicePricingCalculator.php @@ -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; diff --git a/src/Validator/Constraints/ParticipantValidator.php b/src/Validator/Constraints/ParticipantValidator.php index 5b19838..8b8e5fb 100644 --- a/src/Validator/Constraints/ParticipantValidator.php +++ b/src/Validator/Constraints/ParticipantValidator.php @@ -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 diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index bfd7665..9baa711 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -607,15 +607,6 @@ {{ form_help(form.transportationOutbound) }} {% endif %} - {% if form.transportationInbound is defined %} - {{ form_row(form.transportationInbound, { - 'attr': { - 'hx-trigger': htmx_change_trigger, - 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), - 'hx-target': '#main-content', 'hx-swap': 'innerHTML' - } - }) }} - {% endif %} {% if form.pickup is defined %} {{ form_row(form.pickup, { 'attr': { @@ -625,6 +616,39 @@ } }) }} {% endif %} + +
+ {% if form.transportationInbound is defined %} + {{ form_row(form.transportationInbound, { + 'attr': { + 'hx-trigger': htmx_change_trigger, + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }) }} + {% endif %} + {% if form.differentDropOff is defined %} +
+ {{ form_row(form.differentDropOff, { + 'attr': { + 'hx-trigger': htmx_change_trigger, + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }) }} +
+ {% endif %} + {% if form.dropOff is defined %} + {{ form_row(form.dropOff, { + 'attr': { + 'hx-trigger': htmx_change_trigger, + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }) }} + {% endif %} +
+ {% if form.licensePlate is defined %} {{ form_row(form.licensePlate) }} {% endif %} diff --git a/templates/booking/create/step_4.html.twig b/templates/booking/create/step_4.html.twig index bf64f00..de6b054 100644 --- a/templates/booking/create/step_4.html.twig +++ b/templates/booking/create/step_4.html.twig @@ -267,7 +267,7 @@ {% endif %} {# An-/Abreise Section #} - {% if participant.transportationOutbound or participant.transportationInbound or participant.pickup or participant.parking or participant.licensePlate %} + {% if participant.hasTransportationData() %}
An-/Abreise
@@ -294,7 +294,7 @@ {% endif %} {% if participant.pickup %} - Zu-/Ausstieg: {{ participant.pickup.label }} + Zustieg: {{ participant.pickup.label }} {% if participant.pickup.price is not null and participant.pickup.price != 0 %} {{ participant.pickup.price|format_currency('EUR') }} @@ -302,6 +302,16 @@ {% endif %} + {% if participant.dropOff %} + + Ausstieg: {{ participant.dropOff.label }} + + {% if participant.dropOff.price is not null and participant.dropOff.price != 0 %} + {{ participant.dropOff.price|format_currency('EUR') }} + {% endif %} + + + {% endif %} {% if participant.parking %} Parkplatz diff --git a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php index c25e448..adfe277 100644 --- a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php +++ b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php @@ -423,7 +423,7 @@ class BookingDataProcessorTest extends TestCase private function createFormDataWithoutPickups(): BookingDto { $formData = $this->createCompleteFormData(); - $formData->booking->pickupsOutbound = []; + $formData->booking->pickups = []; return $formData; } @@ -441,8 +441,8 @@ class BookingDataProcessorTest extends TestCase $booking->paymentType = 'CC'; $booking->additionalServices = []; $booking->transportationServices = []; - $booking->pickupsOutbound = []; - $booking->pickupsInbound = []; + $booking->pickups = []; + $booking->dropOffs = []; $booking->participants = [ $this->createMockPersonalData('Participant0'), $this->createMockPersonalData('Participant1'), @@ -603,8 +603,8 @@ class BookingDataProcessorTest extends TestCase $booking->paymentType = 'CC'; $booking->additionalServices = []; $booking->transportationServices = []; - $booking->pickupsOutbound = []; - $booking->pickupsInbound = []; + $booking->pickups = []; + $booking->dropOffs = []; $canceledParticipant = new PersonalData(); $canceledParticipant->firstName = ''; diff --git a/tests/Form/Service/ParticipantDropOffFieldHandlerTest.php b/tests/Form/Service/ParticipantDropOffFieldHandlerTest.php new file mode 100644 index 0000000..a944932 --- /dev/null +++ b/tests/Form/Service/ParticipantDropOffFieldHandlerTest.php @@ -0,0 +1,242 @@ +handler = new ParticipantDropOffFieldHandler(); + } + + public function testGetFieldName(): void + { + $this->assertSame('dropOff', $this->handler->getFieldName()); + } + + public function testGetDependencies(): void + { + $this->assertSame( + ['transportationOutbound', 'transportationInbound', 'pickup'], + $this->handler->getDependencies() + ); + } + + public function testShouldProcessAlwaysReturnsTrue(): void + { + $this->assertTrue($this->handler->shouldProcess([], BookingDto::MODE_CREATE, 0)); + $this->assertTrue($this->handler->shouldProcess(['some' => 'data'], BookingDto::MODE_EDIT, 5)); + } + + public function testProcessFieldWithoutParticipant(): void + { + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $submittedData = ['dropOff' => '10']; + + $this->handler->processField($submittedData, $bookingDto, 0); + + $this->expectNotToPerformAssertions(); + } + + public function testBusBusCheckboxCheckedValidSelection(): void + { + $dropOff = $this->createDropOff(10, 'Berlin Hbf'); + $bookingDto = $this->createBookingDto( + DirectionMapper::SUBTYPE_BUS_API, + DirectionMapper::SUBTYPE_BUS_API, + [$dropOff] + ); + + $submittedData = [ + 'differentDropOff' => '1', + 'dropOff' => '10', + ]; + + $this->handler->processField($submittedData, $bookingDto, 0); + + $participant = $bookingDto->getParticipant(0); + $this->assertNotNull($participant->dropOff); + $this->assertSame(10, $participant->dropOff->id); + $this->assertTrue($participant->differentDropOff); + } + + public function testBusBusCheckboxUncheckedClearsDropOff(): void + { + $dropOff = $this->createDropOff(10, 'Berlin Hbf'); + $bookingDto = $this->createBookingDto( + DirectionMapper::SUBTYPE_BUS_API, + DirectionMapper::SUBTYPE_BUS_API, + [$dropOff] + ); + + $participant = $bookingDto->getParticipant(0); + $participant->dropOff = $dropOff; + $participant->differentDropOff = true; + + $submittedData = [ + 'differentDropOff' => false, + 'dropOff' => '10', + ]; + + $this->handler->processField($submittedData, $bookingDto, 0); + + $this->assertNull($participant->dropOff); + $this->assertFalse($participant->differentDropOff); + } + + public function testPkwBusValidSelection(): void + { + $dropOff = $this->createDropOff(20, 'München ZOB'); + $bookingDto = $this->createBookingDto( + DirectionMapper::SUBTYPE_CAR_API, + DirectionMapper::SUBTYPE_BUS_API, + [$dropOff] + ); + + $submittedData = [ + 'dropOff' => '20', + ]; + + $this->handler->processField($submittedData, $bookingDto, 0); + + $participant = $bookingDto->getParticipant(0); + $this->assertNotNull($participant->dropOff); + $this->assertSame(20, $participant->dropOff->id); + $this->assertFalse($participant->differentDropOff); + } + + public function testBusPkwClearsDropOff(): void + { + $dropOff = $this->createDropOff(10, 'Berlin Hbf'); + $bookingDto = $this->createBookingDto( + DirectionMapper::SUBTYPE_BUS_API, + DirectionMapper::SUBTYPE_CAR_API, + [$dropOff] + ); + + $participant = $bookingDto->getParticipant(0); + $participant->dropOff = $dropOff; + $participant->differentDropOff = true; + + $submittedData = ['dropOff' => '10']; + + $this->handler->processField($submittedData, $bookingDto, 0); + + $this->assertNull($participant->dropOff); + $this->assertFalse($participant->differentDropOff); + } + + public function testPkwPkwClearsDropOff(): void + { + $dropOff = $this->createDropOff(10, 'Berlin Hbf'); + $bookingDto = $this->createBookingDto( + DirectionMapper::SUBTYPE_CAR_API, + DirectionMapper::SUBTYPE_CAR_API, + [$dropOff] + ); + + $participant = $bookingDto->getParticipant(0); + $participant->dropOff = $dropOff; + + $submittedData = ['dropOff' => '10']; + + $this->handler->processField($submittedData, $bookingDto, 0); + + $this->assertNull($participant->dropOff); + $this->assertFalse($participant->differentDropOff); + } + + public function testInvalidDropOffIdSetsNull(): void + { + $dropOff = $this->createDropOff(10, 'Berlin Hbf'); + $bookingDto = $this->createBookingDto( + DirectionMapper::SUBTYPE_CAR_API, + DirectionMapper::SUBTYPE_BUS_API, + [$dropOff] + ); + + $submittedData = [ + 'dropOff' => '999', + ]; + + $this->handler->processField($submittedData, $bookingDto, 0); + + $participant = $bookingDto->getParticipant(0); + $this->assertNull($participant->dropOff); + } + + public function testBusBusCheckboxCheckedNoSelection(): void + { + $dropOff = $this->createDropOff(10, 'Berlin Hbf'); + $bookingDto = $this->createBookingDto( + DirectionMapper::SUBTYPE_BUS_API, + DirectionMapper::SUBTYPE_BUS_API, + [$dropOff] + ); + + $submittedData = [ + 'differentDropOff' => '1', + ]; + + $this->handler->processField($submittedData, $bookingDto, 0); + + $participant = $bookingDto->getParticipant(0); + $this->assertNull($participant->dropOff); + $this->assertTrue($participant->differentDropOff); + } + + private function createDropOff(int $id, string $label): Pickup + { + $pickup = new Pickup(); + $pickup->id = $id; + $pickup->city = $label; + $pickup->price = 5.0; + + return $pickup; + } + + private function createTransportationService(string $subType): Service + { + $service = new Service(); + $service->id = random_int(100, 999); + $service->label = 'Transport '.$subType; + $service->subType = $subType; + + return $service; + } + + /** + * @param Pickup[] $dropOffs + */ + private function createBookingDto(string $outboundSubType, string $inboundSubType, array $dropOffs): BookingDto + { + $travel = new Travel(); + foreach ($dropOffs as $dropOff) { + $travel->dropOffs[$dropOff->id] = $dropOff; + } + + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->transportationOutbound = $this->createTransportationService($outboundSubType); + $participant->transportationInbound = $this->createTransportationService($inboundSubType); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + return $bookingDto; + } +}