diff --git a/docs/TRANSPORTATION_SERVICES_IMPLEMENTATION_PLAN.md b/docs/TRANSPORTATION_SERVICES_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..e98563b --- /dev/null +++ b/docs/TRANSPORTATION_SERVICES_IMPLEMENTATION_PLAN.md @@ -0,0 +1,872 @@ +# Transportation Services Implementation Plan + +## Overview + +Implement comprehensive transportation services for the MyEP Next Booking system, supporting bus and self-organized (car/PKW) transportation with directional pickup selection, discount handling, and optional parking services. This implementation addresses BusProNet's inconsistent direction naming conventions while following established architectural patterns. + +## Current State Analysis + +### Existing Infrastructure ✅ + +**Transportation Data Model:** +- `Service` model has `direction`, `subType`, `price` properties +- `Travel` model has `transportationServices[]`, `pickupsTo[]`, `pickupsFro[]` +- `Booking` model supports transportation service mapping +- XML parsing handles transportation services and pickups +- Data processing includes transportation service management + +**Form System Integration:** +- `ParticipantDto` has transportation and pickup properties +- Field handler registry supports service processing +- Conditional field state system available +- HTMX integration for real-time updates +- Pricing integration system in place + +### Direction Naming Inconsistencies 🔍 + +**Problem Identified:** BusProNet uses inconsistent direction codes across different contexts: + +1. **Travel Data Context:** `'HIN'` and `'RUECK'` (full German words) +2. **Booking Data Context:** `'H'` and `'R'` (single letter abbreviations) +3. **Internal Properties:** `To`/`Fro` (archaic English) + +**Evidence:** +- Comment in `BookingEditDto.php`: `"Different keys for direction used in booking data (H <=> HIN, R <=> RUECK)!"` +- `Travel->getTransportationServicesByDirection('HIN'/'RUECK')` +- `Booking->getTransportationServiceForParticipantAndDirection($index, 'H'/'R')` + +### Missing Components 🚧 + +- Direction mapping utility for consistency +- Transportation field handlers and options providers +- Conditional pickup field logic (only show when bus selected) +- Parking service integration (subtype PAR) +- Modern English property naming (Outbound/Inbound) + +## Implementation Strategy + +### Phase 1: Foundation - Direction Mapping & Naming 🎯 + +#### 1.1 Create Direction Mapping Utility + +**File:** `src/BusProNet/Utility/DirectionMapper.php` + +```php + self::OUTBOUND_BOOKING, + self::INBOUND_TRAVEL => self::INBOUND_BOOKING, + default => throw new \InvalidArgumentException("Unknown travel direction: $travelDirection") + }; + } + + /** + * Maps booking direction code to travel direction code. + */ + public static function bookingToTravel(string $bookingDirection): string + { + return match($bookingDirection) { + self::OUTBOUND_BOOKING => self::OUTBOUND_TRAVEL, + self::INBOUND_BOOKING => self::INBOUND_TRAVEL, + default => throw new \InvalidArgumentException("Unknown booking direction: $bookingDirection") + }; + } + + /** + * Maps direction code to English name. + */ + public static function toEnglish(string $direction): string + { + return match($direction) { + self::OUTBOUND_TRAVEL, self::OUTBOUND_BOOKING => self::OUTBOUND, + self::INBOUND_TRAVEL, self::INBOUND_BOOKING => self::INBOUND, + default => throw new \InvalidArgumentException("Unknown direction: $direction") + }; + } + + /** + * Gets all outbound direction codes. + */ + public static function getOutboundCodes(): array + { + return [self::OUTBOUND_TRAVEL, self::OUTBOUND_BOOKING]; + } + + /** + * Gets all inbound direction codes. + */ + public static function getInboundCodes(): array + { + return [self::INBOUND_TRAVEL, self::INBOUND_BOOKING]; + } +} +``` + +#### 1.2 Update ParticipantDto Properties + +**Current Properties (archaic naming):** +```php +public ?Service $transportationServiceTo = null; +public ?Service $transportationServiceFro = null; +public ?Pickup $pickup = null; +``` + +**Updated Properties (modern English):** +```php +public ?Service $transportationOutbound = null; // Maps to 'HIN'/'H' +public ?Service $transportationInbound = null; // Maps to 'RUECK'/'R' +public ?Pickup $pickupOutbound = null; // Maps to pickupsTo +public ?Pickup $pickupInbound = null; // Maps to pickupsFro +public ?Service $parking = null; // New parking service +``` + +#### 1.3 Update BookingEditDto Direction Mapping + +**Current Implementation:** +```php +// Different keys for direction used in booking data (H <=> HIN, R <=> RUECK)! +$participantData->transportationServiceTo = $booking + ->getTransportationServiceForParticipantAndDirection($index, 'H'); +$participantData->transportationServiceFro = $booking + ->getTransportationServiceForParticipantAndDirection($index, 'R'); +``` + +**Updated Implementation:** +```php +use App\BusProNet\Utility\DirectionMapper; + +$participantData->transportationOutbound = $booking + ->getTransportationServiceForParticipantAndDirection($index, DirectionMapper::OUTBOUND_BOOKING); +$participantData->transportationInbound = $booking + ->getTransportationServiceForParticipantAndDirection($index, DirectionMapper::INBOUND_BOOKING); +``` + +### Phase 2: Transportation Field Implementation 🚀 + +#### 2.1 Transportation Service Field Handlers + +**A. Outbound Transportation Handler** +**File:** `src/Form/Service/ParticipantTransportationOutboundFieldHandler.php` + +```php +getParticipant($bookingDto, $participantIndex); + if (null === $participant) { + return; + } + + $selectedTransportation = $this->getFieldValue($submittedData, $this->getFieldName()); + + // Get available outbound transportation services + $availableServices = $bookingDto->travel->getTransportationServicesByDirection( + DirectionMapper::OUTBOUND_TRAVEL, + true // filter available + ); + + // Validate and convert selection to Service object + $validSelection = null; + if (null !== $selectedTransportation) { + if ($this->isServiceValidForParticipant($selectedTransportation, $availableServices, $bookingDto, $participantIndex)) { + $validSelection = $this->findServiceInAvailableServices($selectedTransportation, $availableServices); + } + } + + // Update participant with validated selection + $participant->transportationOutbound = $validSelection; + } + + // ... validation methods similar to existing handlers +} +``` + +**B. Inbound Transportation Handler** +**File:** `src/Form/Service/ParticipantTransportationInboundFieldHandler.php` +- Similar structure for inbound (RUECK) transportation +- Field name: `transportationInbound` +- Uses `DirectionMapper::INBOUND_TRAVEL` + +#### 2.2 Pickup Field Handlers + +**A. Outbound Pickup Handler** +**File:** `src/Form/Service/ParticipantPickupOutboundFieldHandler.php` + +```php +getParticipant($bookingDto, $participantIndex); + if (null === $participant) { + return; + } + + // Only process pickup if outbound transportation is bus + if (null === $participant->transportationOutbound || 'BUS' !== $participant->transportationOutbound->subType) { + $participant->pickupOutbound = null; // Clear pickup for non-bus transport + return; + } + + $selectedPickup = $this->getFieldValue($submittedData, $this->getFieldName()); + + // Validate pickup selection against available outbound pickups + $validSelection = null; + if (null !== $selectedPickup) { + $availablePickups = $bookingDto->travel->pickupsTo; + $validSelection = $this->findPickupInAvailable($selectedPickup, $availablePickups); + } + + $participant->pickupOutbound = $validSelection; + } + + // ... pickup validation methods +} +``` + +**B. Inbound Pickup Handler** +**File:** `src/Form/Service/ParticipantPickupInboundFieldHandler.php` +- Similar structure for inbound pickup +- Field name: `pickupInbound` +- Depends on `transportationInbound` +- Uses `travel->pickupsFro` + +#### 2.3 Parking Service Handler + +**File:** `src/Form/Service/ParticipantParkingFieldHandler.php` + +```php +getParticipant($bookingDto, $participantIndex); + if (null === $participant) { + return; + } + + // Check if parking is applicable (at least one PKW direction) + if (!$this->isParkingApplicable($participant)) { + $participant->parking = null; // Clear parking for bus-only transport + return; + } + + $selectedParking = $this->getFieldValue($submittedData, $this->getFieldName()); + + // Get available parking services (subtype PAR) + $availableParkingServices = $bookingDto->travel->getAdditionalServicesBySubTypes('PAR', true); + + $validSelection = null; + if (null !== $selectedParking) { + $validSelection = $this->findServiceInAvailableServices($selectedParking, $availableParkingServices); + } + + $participant->parking = $validSelection; + } + + private function isParkingApplicable($participant): bool + { + $outboundIsPkw = $participant->transportationOutbound?->subType === 'PKW'; + $inboundIsPkw = $participant->transportationInbound?->subType === 'PKW'; + + return $outboundIsPkw || $inboundIsPkw; + } +} +``` + +#### 2.4 Field Options Provider Integration + +**Update:** `src/Form/Service/ParticipantFieldOptionsProvider.php` + +```php +protected function registerFieldOptionProviders(): void +{ + // ... existing providers + + // Outbound Transportation + $this->fieldOptionProviders['transportationOutbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + 'label' => 'Hinfahrt', + 'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL), + 'choice_label' => fn(Service $service) => $this->formatTransportationServiceLabel($service), + 'choice_value' => 'id', + 'expanded' => true, + 'multiple' => false, + 'required' => true, + 'attr' => [ + 'hx-post' => $this->urlGenerator->generate('booking_create_step_2_refresh'), + 'hx-target' => '#booking-summary', + 'hx-trigger' => 'change', + ], + ]; + + // Inbound Transportation + $this->fieldOptionProviders['transportationInbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + 'label' => 'Rückfahrt', + 'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL), + 'choice_label' => fn(Service $service) => $this->formatTransportationServiceLabel($service), + 'choice_value' => 'id', + 'expanded' => true, + 'multiple' => false, + 'required' => true, + 'attr' => [ + 'hx-post' => $this->urlGenerator->generate('booking_create_step_2_refresh'), + 'hx-target' => '#booking-summary', + 'hx-trigger' => 'change', + ], + ]; + + // Outbound Pickup (conditional) + $this->fieldOptionProviders['pickupOutbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + 'label' => 'Zustieg Hinfahrt', + 'choices' => $bookingDto->travel->pickupsTo, + 'choice_label' => 'label', + 'choice_value' => 'id', + 'expanded' => false, // Dropdown for pickups + 'multiple' => false, + 'required' => true, + 'placeholder' => 'Zustieg auswählen', + ]; + + // Inbound Pickup (conditional) + $this->fieldOptionProviders['pickupInbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + 'label' => 'Zustieg Rückfahrt', + 'choices' => $bookingDto->travel->pickupsFro, + 'choice_label' => 'label', + 'choice_value' => 'id', + 'expanded' => false, + 'multiple' => false, + 'required' => true, + 'placeholder' => 'Zustieg auswählen', + ]; + + // Parking (conditional) + $this->fieldOptionProviders['parking'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + 'label' => 'Parkplatz', + 'choices' => $bookingDto->travel->getAdditionalServicesBySubTypes('PAR', true), + 'choice_label' => fn(Service $service) => $this->formatServiceLabelWithPrice($service), + 'choice_value' => 'id', + 'expanded' => true, + 'multiple' => false, + 'required' => false, + ]; +} + +/** + * Format transportation service labels with type and pricing. + */ +private function formatTransportationServiceLabel(Service $service): string +{ + $label = $service->label; + + // Add transportation type indicator + $typeIndicator = match($service->subType) { + 'BUS' => '🚌', + 'PKW' => '🚗', + default => '' + }; + + if ($typeIndicator) { + $label = $typeIndicator . ' ' . $label; + } + + // Add pricing with discount indication + if ($service->price > 0) { + $label .= sprintf(' (+€%.2f)', $service->price); + } elseif ($service->price < 0) { + $label .= sprintf(' (-€%.2f Discount)', abs($service->price)); + } + + // Add availability warning if limited + if (null !== $service->available && $service->available <= 5) { + $label .= sprintf(' (nur %d verfügbar)', $service->available); + } + + return $label; +} +``` + +### Phase 3: Conditional Field States & UX 🎨 + +#### 3.1 Transportation Type Condition + +**File:** `src/Form/Service/Condition/TransportationTypeCondition.php` + +```php +getParticipant($participantIndex); + if (null === $participant) { + return false; + } + + $transportationService = match($this->direction) { + 'outbound' => $participant->transportationOutbound, + 'inbound' => $participant->transportationInbound, + default => null, + }; + + if (null === $transportationService) { + return false; + } + + return $this->expectedType === $transportationService->subType; + } + + public function getDependentFields(): array + { + return match($this->direction) { + 'outbound' => ['transportationOutbound'], + 'inbound' => ['transportationInbound'], + default => [], + }; + } + + public function getDescription(): string + { + return sprintf('%s transportation is %s', ucfirst($this->direction), $this->expectedType); + } +} +``` + +#### 3.2 Update Field State Provider + +**Update:** `src/Form/Service/CreateFieldStateProvider.php` + +```php +use App\Form\Service\Condition\TransportationTypeCondition; +use App\Form\Service\Condition\CompositeCondition; + +protected function registerFieldStateConditions(): void +{ + // ... existing conditions + + // Show outbound pickup only when outbound transportation is BUS + $this->fieldStateConditions['pickupOutbound'] = [ + 'hidden' => CompositeCondition::not( + new TransportationTypeCondition('outbound', 'BUS') + ), + ]; + + // Show inbound pickup only when inbound transportation is BUS + $this->fieldStateConditions['pickupInbound'] = [ + 'hidden' => CompositeCondition::not( + new TransportationTypeCondition('inbound', 'BUS') + ), + ]; + + // Show parking only when at least one direction is PKW + $this->fieldStateConditions['parking'] = [ + 'hidden' => CompositeCondition::not( + CompositeCondition::or( + new TransportationTypeCondition('outbound', 'PKW'), + new TransportationTypeCondition('inbound', 'PKW') + ) + ), + ]; +} +``` + +### Phase 4: Form Integration & Service Configuration ⚙️ + +#### 4.1 Update Form Type + +**Update:** `src/Form/BookingCreateParticipantType.php` + +```php +// Add transportation fields to dynamic fields list +$dynamicFields = [ + 'assignedRoomId', + 'courses', + 'additionalServices', + 'board', + 'rentals', + 'skiPass', + 'transportationOutbound', // New + 'transportationInbound', // New + 'pickupOutbound', // New + 'pickupInbound', // New + 'parking', // New +]; +``` + +#### 4.2 Service Registration + +**Update:** `config/services.yaml` + +```yaml + # Transportation field handlers + App\Form\Service\ParticipantTransportationOutboundFieldHandler: + tags: + - { name: 'app.participant_field_handler', field: 'transportationOutbound' } + + App\Form\Service\ParticipantTransportationInboundFieldHandler: + tags: + - { name: 'app.participant_field_handler', field: 'transportationInbound' } + + App\Form\Service\ParticipantPickupOutboundFieldHandler: + tags: + - { name: 'app.participant_field_handler', field: 'pickupOutbound' } + + App\Form\Service\ParticipantPickupInboundFieldHandler: + tags: + - { name: 'app.participant_field_handler', field: 'pickupInbound' } + + App\Form\Service\ParticipantParkingFieldHandler: + tags: + - { name: 'app.participant_field_handler', field: 'parking' } +``` + +#### 4.3 Constants Update + +**Update:** `src/BusProNet/Constants.php` + +```php +// Add parking token if not already defined +public const TOKEN_PARKING = 'PAR'; + +// Transportation subtypes +public const SUBTYPE_BUS = 'BUS'; +public const SUBTYPE_PKW = 'PKW'; +``` + +## UX Design & User Experience 🎯 + +### Section Organization + +**Transportation will be organized in logical sections:** + +```html + +