# 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 = DirectionMapper::SUBTYPE_CAR_API === $participant->transportationOutbound?->subType; $inboundIsPkw = DirectionMapper::SUBTYPE_CAR_API === $participant->transportationInbound?->subType; 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) { DirectionMapper::SUBTYPE_BUS_API => '🚌', DirectionMapper::SUBTYPE_CAR_API => '🚗', 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 Service Sub-Type Condition **File:** `src/Form/Service/Condition/ServiceSubTypeCondition.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\BusProNet\Utility\DirectionMapper; use App\Form\Service\Condition\ServiceSubTypeCondition; protected function registerFieldStateConditions(): void { // ... existing conditions // Transportation-related field conditions // Hide outbound pickup when transportation is not BUS $this->fieldStateConditions['pickupOutbound'] = [ 'hidden' => ServiceSubTypeCondition::notEquals('transportationOutbound', DirectionMapper::SUBTYPE_BUS_API), ]; // Hide inbound pickup when transportation is not BUS $this->fieldStateConditions['pickupInbound'] = [ 'hidden' => ServiceSubTypeCondition::notEquals('transportationInbound', DirectionMapper::SUBTYPE_BUS_API), ]; // Hide parking when outbound transportation is not PKW (car) // Parking is offered at holiday destination for those arriving by car $this->fieldStateConditions['parking'] = [ 'hidden' => ServiceSubTypeCondition::notEquals('transportationOutbound', DirectionMapper::SUBTYPE_CAR_API), ]; } ``` ### 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/Utility/DirectionMapper.php` ```php // Transportation service sub-types (API format - German abbreviations) public const SUBTYPE_BUS_API = 'BUS'; public const SUBTYPE_CAR_API = 'PKW'; // Transportation service sub-types (internal format - English) public const SUBTYPE_BUS = 'BUS'; public const SUBTYPE_CAR = 'CAR'; /** * Maps API transportation sub-type to internal sub-type. */ public static function apiToInternal(string $apiSubType): string { return match ($apiSubType) { self::SUBTYPE_BUS_API => self::SUBTYPE_BUS, self::SUBTYPE_CAR_API => self::SUBTYPE_CAR, default => throw new \InvalidArgumentException("Unknown API sub-type: $apiSubType"), }; } /** * Maps internal transportation sub-type to API sub-type. */ public static function internalToApi(string $internalSubType): string { return match ($internalSubType) { self::SUBTYPE_BUS => self::SUBTYPE_BUS_API, self::SUBTYPE_CAR => self::SUBTYPE_CAR_API, default => throw new \InvalidArgumentException("Unknown internal sub-type: $internalSubType"), }; } ``` ## UX Design & User Experience 🎯 ### Section Organization **Transportation will be organized in logical sections:** ```html