feat: selectable drop-offs for inbound bus travel
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
<!-- Outbound pickups -->
|
||||
<zustiege>
|
||||
<zustieg id="3" idbuspro="4" preis="5,90" .../>
|
||||
</zustiege>
|
||||
|
||||
<!-- Inbound pickups -->
|
||||
<zustiege_rueck>
|
||||
<zustieg_rueck id="3" idbuspro="4" preis="5,90" .../>
|
||||
</zustiege_rueck>
|
||||
```
|
||||
|
||||
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*
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class PersonalDataSynchronizer
|
||||
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
|
||||
|
||||
@@ -607,15 +607,6 @@
|
||||
{{ form_help(form.transportationOutbound) }}
|
||||
</div>
|
||||
{% 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 %}
|
||||
|
||||
<div>
|
||||
{% 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 %}
|
||||
<div class="mb-4">
|
||||
{{ 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'
|
||||
}
|
||||
}) }}
|
||||
</div>
|
||||
{% 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 %}
|
||||
</div>
|
||||
|
||||
{% if form.licensePlate is defined %}
|
||||
{{ form_row(form.licensePlate) }}
|
||||
{% endif %}
|
||||
|
||||
@@ -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() %}
|
||||
<div class="px-2 py-1 text-primary-dark/70 text-sm uppercase font-semibold">
|
||||
An-/Abreise
|
||||
</div>
|
||||
@@ -294,7 +294,7 @@
|
||||
{% endif %}
|
||||
{% if participant.pickup %}
|
||||
<tr>
|
||||
<td class="px-2 pb-2 align-top">Zu-/Ausstieg: {{ participant.pickup.label }}</td>
|
||||
<td class="px-2 pb-2 align-top">Zustieg: {{ participant.pickup.label }}</td>
|
||||
<td class="px-2 pb-2 align-top w-24 text-right whitespace-nowrap">
|
||||
{% if participant.pickup.price is not null and participant.pickup.price != 0 %}
|
||||
{{ participant.pickup.price|format_currency('EUR') }}
|
||||
@@ -302,6 +302,16 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if participant.dropOff %}
|
||||
<tr>
|
||||
<td class="px-2 pb-2 align-top">Ausstieg: {{ participant.dropOff.label }}</td>
|
||||
<td class="px-2 pb-2 align-top w-24 text-right whitespace-nowrap">
|
||||
{% if participant.dropOff.price is not null and participant.dropOff.price != 0 %}
|
||||
{{ participant.dropOff.price|format_currency('EUR') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if participant.parking %}
|
||||
<tr>
|
||||
<td class="px-2 pb-2 align-top">Parkplatz</td>
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\Service;
|
||||
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Utility\DirectionMapper;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Service\ParticipantDropOffFieldHandler;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ParticipantDropOffFieldHandlerTest extends TestCase
|
||||
{
|
||||
private ParticipantDropOffFieldHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user