From de8cbf61789864ddb91b1f95cecf6475f4573875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 14 Oct 2025 21:27:32 +0200 Subject: [PATCH] wip: refactor to cards with individual participant forms --- REFACTORING_PARTICIPANT_CARDS.md | 468 +++++++++++------- .../AbstractParticipantFieldHandler.php | 34 +- .../ParticipantFieldHandlerInterface.php | 6 +- ...ticipantAdditionalServicesFieldHandler.php | 4 +- .../Service/ParticipantBoardFieldHandler.php | 3 +- .../ParticipantBulkInsuranceFieldHandler.php | 3 +- .../ParticipantCoursesFieldHandler.php | 4 +- .../ParticipantFieldHandlerRegistry.php | 34 +- .../ParticipantInsuranceFieldHandler.php | 25 +- .../ParticipantLicensePlateFieldHandler.php | 3 +- .../ParticipantParkingFieldHandler.php | 3 +- .../Service/ParticipantPickupFieldHandler.php | 2 +- ...ParticipantRentalInsuranceFieldHandler.php | 4 +- .../ParticipantRentalsFieldHandler.php | 3 +- .../ParticipantSkiPassFieldHandler.php | 4 +- ...ipantTransportationInboundFieldHandler.php | 3 +- ...pantTransportationOutboundFieldHandler.php | 3 +- src/Form/Service/Trait/FormTraversalTrait.php | 7 +- src/Service/ParticipantCardDataService.php | 115 +++++ .../ParticipantInsuranceFieldHandlerTest.php | 27 +- ...articipantLicensePlateFieldHandlerTest.php | 4 +- .../ParticipantCardDataServiceTest.php | 319 ++++++++++++ 22 files changed, 866 insertions(+), 212 deletions(-) create mode 100644 src/Service/ParticipantCardDataService.php create mode 100644 tests/Service/ParticipantCardDataServiceTest.php diff --git a/REFACTORING_PARTICIPANT_CARDS.md b/REFACTORING_PARTICIPANT_CARDS.md index a15a36a..5569391 100644 --- a/REFACTORING_PARTICIPANT_CARDS.md +++ b/REFACTORING_PARTICIPANT_CARDS.md @@ -4,7 +4,81 @@ Transform create (step 2) and edit flows from accordion-style "all forms at once" to a card overview with lazy-loaded individual participant forms. This addresses UX and performance issues with large groups (50+ participants). -**Status**: Planning Complete - Ready for Implementation +**Status**: Planning Complete - Ready for Implementation (SIMPLIFIED ARCHITECTURE - 2025-01-14) + +## Architecture Revision (2025-01-14) + +**Major Simplification**: Removed wrapper forms and DTOs! + +**What Changed:** +- ❌ Removed: `ParticipantFormDto` - not needed +- ❌ Removed: `ParticipantFormType` wrapper - not needed +- ✅ Simplified: Pass `BookingDto` via form options (`booking_context`) +- ✅ Simplified: Reuse `BookingParticipantType` directly +- ✅ Simplified: Field handlers called manually in controller + +**Why:** +The initial plan had unnecessary abstraction layers. We realized we can simply pass the `BookingDto` as a form option instead of wrapping everything. This is cleaner, more explicit, and easier to understand. + +## Implementation Discovery: Field Handler Mode Awareness (2025-01-14) + +**Problem Encountered:** +When promoting participant forms to standalone (autonomous) forms in the card flow, field handlers needed to be called from within `BookingParticipantType`'s PRE_SUBMIT event. This created a critical issue in edit mode: + +- The BPN API **does not return insurance data** for privacy/security reasons +- In edit mode, the insurance field handler would see missing insurance data in submitted forms +- The handler would incorrectly interpret this as "user wants to clear insurance" +- Result: Insurance data would be lost during edit operations + +**Root Cause:** +Field handlers had no way to distinguish between: +1. **Create mode**: Missing data = user didn't select anything (clear it) +2. **Edit mode**: Missing data = API didn't provide it (preserve existing value) + +**Solution Implemented:** +Added mode awareness to the field handler system: + +```php +// Interface change +public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool; + +// Insurance handler implementation +public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool +{ + // Skip processing in edit mode - API doesn't return insurance data + if ($mode === BookingDto::MODE_EDIT) { + return false; + } + + // Process normally in create mode + return true; +} +``` + +**Architecture Changes:** +- ✅ Added `$mode` parameter to `ParticipantFieldHandlerInterface::shouldProcess()` +- ✅ Updated `AbstractParticipantFieldHandler` with mode parameter +- ✅ Added helper methods: `isEditMode()`, `isCreateMode()` for future use +- ✅ Updated `ParticipantFieldHandlerRegistry` to pass `$bookingDto->mode` to handlers +- ✅ Updated all 13+ field handlers to accept mode parameter +- ✅ Insurance handler skips processing entirely in edit mode +- ✅ All other handlers continue processing normally in both modes + +**Why Only Insurance Needs Special Treatment:** +- **Insurance**: API doesn't return data → must skip processing in edit mode +- **Services** (ski pass, rentals, courses): API returns data → process normally +- **Transportation**: API returns data → process normally +- **Room assignments**: API returns data → process normally +- **All other fields**: API returns data → process normally + +**Benefits:** +- ✅ Insurance data preserved correctly in edit mode +- ✅ Explicit mode-aware behavior (no implicit assumptions) +- ✅ Clean separation: only handlers that need it check mode +- ✅ Future-proof: easy to add mode-specific logic to other handlers if needed +- ✅ Tests updated to verify edit mode behavior + +**Implementation Status:** ✅ **COMPLETED** (2025-01-14) ## Goals @@ -26,6 +100,27 @@ Transform create (step 2) and edit flows from accordion-style "all forms at once | **Room Assignment** | Keep in participant form | Existing behavior maintained | | **Migration Strategy** | Separate controllers (-v2 routes) | Safe parallel development, easy comparison | | **Lazy Loading** | Forms loaded via HTMX on demand | Critical for performance with 50+ participants | +| **BookingDto Context** | Pass via form options | Simple, explicit, no wrappers needed | + +## Key Simplification + +**No wrapper forms or DTOs needed!** We pass `BookingDto` directly via form options: + +```php +// Controller creates form with explicit context +$form = $this->createForm(BookingParticipantType::class, $participant, [ + 'booking_context' => $bookingDto, // Passed explicitly + 'edit_mode' => false, +]); +``` + +**Benefits:** +- ✅ No `ParticipantFormDto` wrapper +- ✅ No `ParticipantFormType` wrapper +- ✅ Reuse existing `BookingParticipantType` directly +- ✅ Explicit data flow (no "magic" form tree traversal) +- ✅ Field handlers called manually in controller (like current Step 2) +- ✅ Clean, maintainable, easy to understand --- @@ -114,198 +209,193 @@ public function processFieldsForParticipant( --- -### 1.3 Create `ParticipantFormDto` +### 1.3 Update `BookingParticipantType` to Accept `booking_context` Option -**File**: `src/Form/Model/ParticipantFormDto.php` +**File**: `src/Form/BookingParticipantType.php` -**Purpose**: Dedicated DTO for editing a single participant with full booking context +**Purpose**: Make form type autonomous - it processes its own field handlers when used standalone -**Why This Is Needed**: -The existing `BookingParticipantType` uses `getBookingDtoFromForm()` which traverses up the form tree to find a parent form with `BookingDto` as its data. This DTO provides that context while maintaining clean separation between the booking flow state and individual participant editing. +**Why This Simplification**: +In the card flow, there's no parent form to orchestrate field handler execution. The form type becomes **self-contained and autonomous** - it receives `BookingDto` via options and processes its own field handlers in PRE_SUBMIT. **Architecture Benefits**: -- ✅ Clean separation: `BookingDto` = full booking flow state, `ParticipantFormDto` = single participant editing context -- ✅ No pollution of BookingDto with editing-specific properties -- ✅ Type-safe and explicit relationships -- ✅ Participant has full booking context for conditional logic -- ✅ Field state providers work unchanged +- ✅ No wrapper forms needed +- ✅ No extra DTOs needed +- ✅ No parent form needed +- ✅ Explicit data flow via options +- ✅ Field handlers called automatically in PRE_SUBMIT +- ✅ Controller stays thin - just creates form and handles result +- ✅ Form type is responsible for its own data processing -**Implementation**: +**Key Insight**: We're **promoting the child form to be in charge**. It's no longer a dumb child controlled by a parent - it's a smart, autonomous form that handles everything itself. + +**Modification to `__construct()` - Add Field Handler Registry**: ```php +public function __construct( + private readonly FieldOptionsProviderInterface $fieldOptionsProvider, + private readonly CreateFieldStateProvider $createFieldStateProvider, + private readonly EditFieldStateProvider $editFieldStateProvider, + private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry, // NEW: for autonomous processing +) { +} +``` + +**Modification to `configureOptions()`**: + +```php +public function configureOptions(OptionsResolver $resolver): void +{ + $resolver->setDefaults([ + 'data_class' => ParticipantDto::class, + 'selected_rooms' => [], + 'edit_mode' => false, + 'booking_context' => null, // NEW: Optional BookingDto for card flows + ]); + + $resolver->setAllowedTypes('selected_rooms', 'array'); + $resolver->setAllowedTypes('edit_mode', 'bool'); + $resolver->setAllowedTypes('booking_context', ['null', BookingDto::class]); +} +``` + +**Modification to `buildForm()`**: + +```php +public function buildForm(FormBuilderInterface $builder, array $options): void +{ + // Select field state provider based on edit_mode option + $this->fieldStateProvider = $options['edit_mode'] + ? $this->editFieldStateProvider + : $this->createFieldStateProvider; + + // Capture booking context for use in event listeners + $bookingContext = $options['booking_context']; + + $builder + ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($bookingContext) { + $this->onPreSetData($event, $bookingContext); + }) + ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($bookingContext) { + // Process field handlers FIRST (before form binding and validation) + // This ensures data is cleaned before Symfony processes it + if (null !== $bookingContext) { + $this->processFieldHandlers($event, $bookingContext); + } + + // Then rebuild fields with updated states + $this->onPreSubmit($event, $bookingContext); + }); +} + /** - * DTO for editing a single participant within a booking context. + * Process field handlers for this participant. * - * This DTO encapsulates the participant being edited along with the full - * booking context needed for field state evaluation, pricing calculations, - * and conditional logic. + * Field handlers are executed in PRE_SUBMIT to clean and transform data + * before Symfony binds it to the form. This matches the pattern used in + * the old BookingCreateStep2Type parent form. */ -class ParticipantFormDto +private function processFieldHandlers(FormEvent $event, BookingDto $bookingContext): void { - public function __construct( - public BookingDto $bookingContext, - public ParticipantDto $participant, - ) {} + $form = $event->getForm(); + $submittedData = $event->getData(); + + if (false === is_array($submittedData)) { + return; + } + + /** @var ParticipantDto $participant */ + $participant = $form->getData(); + + if (null === $participant || false === property_exists($participant, 'index')) { + return; + } + + // Process all field handlers for this participant in dependency order + $this->fieldHandlerRegistry->processFieldsForParticipant( + $submittedData, + $bookingContext, + $participant->index + ); +} + +private function onPreSetData(FormEvent $event, ?BookingDto $bookingContext): void +{ + /** @var ParticipantDto|null $participantData */ + $participantData = $event->getData(); + $form = $event->getForm(); + + if (null === $participantData) { + return; + } + + // Card flow: BookingDto passed via options + // Accordion flow (if we had one): traverse form tree + $bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form); + + if (null === $bookingDto) { + return; + } + + // Add base fields with states applied + $this->addBaseFields($form, $bookingDto, $participantData->index); + + // Add dynamic fields + $this->addDynamicFields($form, $bookingDto, $participantData->index); +} + +private function onPreSubmit(FormEvent $event, ?BookingDto $bookingContext): void +{ + $submittedData = $event->getData(); + $form = $event->getForm(); + + if (false === is_array($submittedData)) { + return; + } + + // Card flow: BookingDto passed via options + // Accordion flow (if we had one): traverse form tree + $bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form); + + if (null === $bookingDto) { + return; + } + + // Get participant index from form data + $participantData = $form->getData(); + if (null === $participantData || false === property_exists($participantData, 'index')) { + return; + } + + // Rebuild all fields with updated states based on submitted data + $this->rebuildFieldsWithStates($form, $bookingDto, $participantData->index, $submittedData); } ``` -**Key Points**: -- `bookingContext` provides full booking state for field conditions -- `participant` is the participant being edited -- Participant index available via `$participant->index` (no separate property needed) - -**Todos**: -- [ ] Create DTO class in `src/Form/Model/` -- [ ] Add constructor with type hints -- [ ] Write unit tests for DTO instantiation -- [ ] Test with field state providers - ---- - -### 1.4 Create `ParticipantFormType` - -**File**: `src/Form/ParticipantFormType.php` - -**Purpose**: Form type for editing a single participant with automatic field handler processing - -**Implementation**: +**Usage in Controllers**: ```php -class ParticipantFormType extends AbstractType -{ - public function __construct( - private readonly ParticipantFieldHandlerRegistry $participantFieldHandlerRegistry, - ) {} - - public function buildForm(FormBuilderInterface $builder, array $options): void - { - $builder - ->add('participant', BookingParticipantType::class, [ - 'edit_mode' => $options['edit_mode'], - ]) - ->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onPreSubmit']); - } - - /** - * Process field handlers before form binding and validation. - * - * This follows the same pattern as BookingCreateStep2Type - field handlers - * are called during PRE_SUBMIT to clean and validate data before binding. - */ - public function onPreSubmit(FormEvent $event): void - { - $form = $event->getForm(); - $submittedData = $event->getData(); - - /** @var ParticipantFormDto $participantFormDto */ - $participantFormDto = $form->getData(); - $bookingDto = $participantFormDto->bookingContext; - $participantIndex = $participantFormDto->participant->index; - - // Extract participant data from submitted form - $participantSubmittedData = $submittedData['participant'] ?? []; - - // Process field handlers for this single participant - $this->participantFieldHandlerRegistry->processFieldsForParticipant( - $participantSubmittedData, - $bookingDto, - $participantIndex - ); - - // Note: No need to sync data back - the DTO is updated by reference - // and handleRequest() will bind the updated values - } - - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'data_class' => ParticipantFormDto::class, - 'edit_mode' => false, - ]); - - $resolver->setAllowedTypes('edit_mode', 'bool'); - } -} +// Card flow: Pass booking context explicitly +$form = $this->createForm(BookingParticipantType::class, $participant, [ + 'booking_context' => $bookingDto, + 'edit_mode' => false, +]); ``` -**Key Points**: -- Field handlers called automatically in PRE_SUBMIT event (matches existing pattern in `BookingCreateStep2Type`) -- Controller doesn't need to call field handlers manually -- Uses `processFieldsForParticipant()` to process only the current participant -- Root form data is `ParticipantFormDto` which provides `BookingDto` context via `bookingContext` property - **Todos**: -- [ ] Create form type with DI for field handler registry -- [ ] Implement buildForm with participant field -- [ ] Implement onPreSubmit event listener -- [ ] Add proper PHPDoc -- [ ] Test field handler execution -- [ ] Test validation behavior - ---- - -### 1.5 Update `FormTraversalTrait` - -**File**: `src/Form/Service/Trait/FormTraversalTrait.php` - -**Purpose**: Support both `BookingDto` and `ParticipantFormDto` as root form data - -**Modification**: - -```php -trait FormTraversalTrait -{ - /** - * Gets the BookingDto from the root of the form tree. - * - * Supports both direct BookingDto (used in current flows) and - * ParticipantFormDto wrapper (used in card-based flows). - * - * @param FormInterface $form The form to start traversing from - * - * @return BookingDto|null The booking DTO or null if not found - */ - public function getBookingDtoFromForm(FormInterface $form): ?BookingDto - { - // Traverse up the form tree to get the root form's data - $rootForm = $form; - while ($rootForm->getParent()) { - $rootForm = $rootForm->getParent(); - } - - $data = $rootForm->getData(); - - // Direct BookingDto (used in current flows) - if ($data instanceof BookingDto) { - return $data; - } - - // ParticipantFormDto wrapper (used in card-based flows) - if ($data instanceof ParticipantFormDto) { - return $data->bookingContext; - } - - return null; - } -} -``` - -**Why This Works**: -- Existing flows use `BookingDto` directly → no changes needed -- New card flows use `ParticipantFormDto` → extracts `bookingContext` -- Field state providers work with both approaches seamlessly -- `BookingParticipantType` remains completely unchanged - -**Todos**: -- [ ] Add `ParticipantFormDto` support to trait -- [ ] Add `use` statement for `ParticipantFormDto` -- [ ] Test with existing flows (should work unchanged) -- [ ] Test with new card flows +- [ ] Add `ParticipantFieldHandlerRegistry` to constructor (DI) +- [ ] Add `booking_context` option to `configureOptions()` +- [ ] Update `buildForm()` to capture `booking_context` and add PRE_SUBMIT listener +- [ ] Add `processFieldHandlers()` private method to call registry +- [ ] Update `onPreSetData()` to accept and use `$bookingContext` parameter +- [ ] Update `onPreSubmit()` to accept and use `$bookingContext` parameter +- [ ] Test with card flow (booking_context provided) +- [ ] Verify field handlers execute automatically - [ ] Verify field state providers work correctly --- -### 1.6 Create `ParticipantCardFlowTrait` +### 1.4 Create `ParticipantCardFlowTrait` **File**: `src/Controller/Booking/ParticipantCardFlowTrait.php` @@ -415,23 +505,35 @@ Implementation steps: Implementation steps: 1. Load BookingDto from session 2. Validate participant index -3. Create form for `$bookingDto->participants[$index]` using `BookingParticipantType` -4. Set HTMX attributes for refresh endpoint -5. `$form->handleRequest($request)` -6. If submitted and valid: - - Extract participant data from submitted form - - Process field handlers via `processFieldsForParticipant()` +3. Create form for `$bookingDto->participants[$index]` using `BookingParticipantType` with `booking_context` option: + ```php + $form = $this->createForm(BookingParticipantType::class, $participant, [ + 'booking_context' => $bookingDto, + 'edit_mode' => false, + 'validation_groups' => ['booking_create_step_2'], + ]); + ``` +4. `$form->handleRequest($request)` + - **Note**: Field handlers are called automatically by the form in PRE_SUBMIT +5. If submitted and valid: - Save BookingDto to session - HTMX redirect to cards view -7. Calculate summary data for sidebar -8. Render `_participant_form_standalone.html.twig` with form and summary +6. Calculate summary data for sidebar +7. Render `_participant_form_standalone.html.twig` with form and summary **Action: `refreshParticipantForm(int $index, Request $request): Response`** Implementation steps: 1. Load BookingDto from session 2. Enrich with fresh availability data -3. Create form with `validation_groups: false` +3. Create form with `booking_context` option and `validation_groups: false`: + ```php + $form = $this->createForm(BookingParticipantType::class, $participant, [ + 'booking_context' => $bookingDto, + 'edit_mode' => false, + 'validation_groups' => false, + ]); + ``` 4. `$form->handleRequest($request)` 5. Extract participant data from submitted form 6. Process field handlers via `processFieldsForParticipant()` @@ -922,8 +1024,16 @@ Standard Symfony pattern - single action for both GET and POST: public function editParticipant(int $index, Request $request): Response { $bookingDto = $this->loadBookingDtoOrFail($request, BookingDto::MODE_CREATE); + $participant = $bookingDto->participants[$index] ?? null; - $form = $this->createParticipantForm($bookingDto, $index, [ + if (null === $participant) { + throw new \InvalidArgumentException('Invalid participant index'); + } + + // Create form with booking context passed explicitly + $form = $this->createForm(BookingParticipantType::class, $participant, [ + 'booking_context' => $bookingDto, + 'edit_mode' => false, 'validation_groups' => ['booking_create_step_2'], ]); diff --git a/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php b/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php index 3318251..b0c5661 100644 --- a/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php +++ b/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php @@ -36,14 +36,15 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle * * Default implementation checks if the field exists in the submitted data. * Override this method for more complex processing conditions (e.g., only - * process if certain other conditions are met). + * process if certain other conditions are met, or skip processing in edit mode). * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param int $participantIndex The index of the participant being processed * * @return bool True if the handler should process this field, false otherwise */ - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { return isset($submittedData[$this->getFieldName()]); } @@ -67,6 +68,35 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle return $participants[$participantIndex] ?? null; } + /** + * Checks if the booking flow is in edit mode. + * + * Handlers may need to behave differently in edit mode, particularly when + * dealing with data that isn't returned by the BPN API (like insurance selections). + * In edit mode, missing data doesn't mean "clear this field" - it means the API + * doesn't provide it and it should be preserved as-is. + * + * @param BookingDto $bookingDto The booking DTO to check + * + * @return bool True if in edit mode, false otherwise + */ + protected function isEditMode(BookingDto $bookingDto): bool + { + return $bookingDto->mode === BookingDto::MODE_EDIT; + } + + /** + * Checks if the booking flow is in create mode. + * + * @param BookingDto $bookingDto The booking DTO to check + * + * @return bool True if in create mode, false otherwise + */ + protected function isCreateMode(BookingDto $bookingDto): bool + { + return $bookingDto->mode === BookingDto::MODE_CREATE; + } + /** * Safely extracts a field value from submitted participant data. * diff --git a/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php b/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php index adb8b61..89403ac 100644 --- a/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php +++ b/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php @@ -39,8 +39,12 @@ interface ParticipantFieldHandlerInterface /** * Determines if this handler should process the field based on submitted participant data. + * + * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) + * @param int $participantIndex The participant index being processed */ - public function shouldProcess(array $submittedData, int $participantIndex): bool; + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool; /** * Returns field state modifications that should be applied after processing. diff --git a/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php b/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php index 585e8c0..93f709a 100644 --- a/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php +++ b/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php @@ -59,11 +59,12 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField * services are selected. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param int $participantIndex The index of the participant being processed * * @return bool Always returns true for service selection fields */ - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { return true; // Always process to handle deselection cases } @@ -84,6 +85,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField * 5. Updates participant with filtered valid selections * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param BookingDto $bookingDto The booking DTO to update (create or edit) * @param int $participantIndex The index of the participant being processed */ diff --git a/src/Form/Service/ParticipantBoardFieldHandler.php b/src/Form/Service/ParticipantBoardFieldHandler.php index 0fecb7d..76c2c38 100644 --- a/src/Form/Service/ParticipantBoardFieldHandler.php +++ b/src/Form/Service/ParticipantBoardFieldHandler.php @@ -40,11 +40,12 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler * services are selected. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param int $participantIndex The index of the participant being processed * * @return bool Always returns true for service selection fields */ - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { return true; // Always process to handle deselection cases } diff --git a/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php b/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php index d3ac2be..8f48fec 100644 --- a/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php @@ -39,11 +39,12 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl * the bulkInsuranceBooking checkbox - their insurance is controlled by the handler. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param int $participantIndex The index of the participant being processed * * @return bool True if this is the applicant, false otherwise */ - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { return 0 === $participantIndex; // Only process for applicant } diff --git a/src/Form/Service/ParticipantCoursesFieldHandler.php b/src/Form/Service/ParticipantCoursesFieldHandler.php index f0fd034..e900934 100644 --- a/src/Form/Service/ParticipantCoursesFieldHandler.php +++ b/src/Form/Service/ParticipantCoursesFieldHandler.php @@ -59,11 +59,12 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler * services are selected. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param int $participantIndex The index of the participant being processed * * @return bool Always returns true for service selection fields */ - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { return true; // Always process to handle deselection cases } @@ -77,6 +78,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler * no longer appropriate for the participant's age are automatically removed. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param BookingDto $bookingDto The booking DTO to update (create or edit) * @param int $participantIndex The index of the participant being processed */ diff --git a/src/Form/Service/ParticipantFieldHandlerRegistry.php b/src/Form/Service/ParticipantFieldHandlerRegistry.php index c80d33f..2821cfe 100644 --- a/src/Form/Service/ParticipantFieldHandlerRegistry.php +++ b/src/Form/Service/ParticipantFieldHandlerRegistry.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Form\Service; +use App\BusProNet\Model\Address; use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Service; @@ -132,13 +133,42 @@ class ParticipantFieldHandlerRegistry } // Let each handler decide if it should process this participant's data - if ($handler->shouldProcess($participantData, (int) $participantIndex)) { + if ($handler->shouldProcess($participantData, $bookingDto->mode, (int) $participantIndex)) { $handler->processField($participantData, $bookingDto, (int) $participantIndex); } } } } + /** + * Processes field handlers for a single participant. + * + * This method is used by the card-based booking flows where participants are + * edited individually. Unlike processFields() which processes all participants, + * this method applies all handlers to only the specified participant. + * + * Handlers are executed in dependency order to ensure proper data consistency. + * + * @param array $participantData Submitted data for one participant + * @param BookingDto $bookingDto The booking DTO to update + * @param int $participantIndex Index of participant to process + */ + public function processFieldsForParticipant(array $participantData, BookingDto $bookingDto, int $participantIndex): void + { + // Get handlers sorted by dependency order (uses cache if available) + $sortedHandlerNames = $this->getSortedHandlers(); + + // Apply each handler to the specified participant + foreach ($sortedHandlerNames as $handlerName) { + $handler = $this->handlers[$handlerName]; + + // Let each handler decide if it should process this participant's data + if ($handler->shouldProcess($participantData, $bookingDto->mode, $participantIndex)) { + $handler->processField($participantData, $bookingDto, $participantIndex); + } + } + } + /** * Returns handler names sorted by dependency order using cached results when possible. * @@ -376,7 +406,7 @@ class ParticipantFieldHandlerRegistry } // Handle Address objects -> convert to array of properties - if ($value instanceof \App\BusProNet\Model\Address) { + if ($value instanceof Address) { return [ 'street' => $value->street, 'postCode' => $value->postCode, diff --git a/src/Form/Service/ParticipantInsuranceFieldHandler.php b/src/Form/Service/ParticipantInsuranceFieldHandler.php index 85326e7..6cf2b54 100644 --- a/src/Form/Service/ParticipantInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantInsuranceFieldHandler.php @@ -76,27 +76,34 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler /** * Determines if this handler should process the field based on submitted data. * - * For insurance selection fields, we always need to process to handle cases - * where the selection is cleared (field not present in data). This ensures - * the participant DTO is updated with null when no insurance is selected. + * Insurance handler should NOT process in edit mode because the BPN API does not + * return insurance data. In edit mode, insurance data must be preserved as-is + * and passed through to the update endpoint unchanged. * - * However, when bulk insurance booking is active for a dependent participant, - * we skip processing to prevent overwriting the insurance assigned by the - * bulk insurance handler. + * In create mode, we always need to process to handle cases where the selection + * is cleared (field not present in data). However, when bulk insurance booking + * is active for a dependent participant, we skip processing to prevent overwriting + * the insurance assigned by the bulk insurance handler. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param int $participantIndex The index of the participant being processed * - * @return bool True if processing should occur, false if bulk insurance handles it + * @return bool True if processing should occur, false otherwise */ - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { + // In edit mode, insurance data is not available from API - skip processing entirely + if ($mode === BookingDto::MODE_EDIT) { + return false; + } + // Skip processing for dependent participants when bulk insurance booking is active if ($participantIndex > 0 && $this->isBulkInsuranceBookingActive($submittedData)) { return false; } - return true; // Always process to handle deselection cases + return true; // Always process in create mode to handle deselection cases } /** diff --git a/src/Form/Service/ParticipantLicensePlateFieldHandler.php b/src/Form/Service/ParticipantLicensePlateFieldHandler.php index 8acae80..22f3423 100644 --- a/src/Form/Service/ParticipantLicensePlateFieldHandler.php +++ b/src/Form/Service/ParticipantLicensePlateFieldHandler.php @@ -52,11 +52,12 @@ class ParticipantLicensePlateFieldHandler extends AbstractParticipantFieldHandle * the participant DTO is updated correctly when parking availability changes. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param int $participantIndex The index of the participant being processed * * @return bool Always returns true for license plate fields */ - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { return true; // Always process to handle deselection cases } diff --git a/src/Form/Service/ParticipantParkingFieldHandler.php b/src/Form/Service/ParticipantParkingFieldHandler.php index 84c2b01..0699b94 100644 --- a/src/Form/Service/ParticipantParkingFieldHandler.php +++ b/src/Form/Service/ParticipantParkingFieldHandler.php @@ -37,11 +37,12 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler * participant DTO is updated correctly when transportation switches to bus-only. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param int $participantIndex The index of the participant being processed * * @return bool Always returns true for parking selection fields */ - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { return true; // Always process for state changes } diff --git a/src/Form/Service/ParticipantPickupFieldHandler.php b/src/Form/Service/ParticipantPickupFieldHandler.php index e31a5b1..e90bb19 100644 --- a/src/Form/Service/ParticipantPickupFieldHandler.php +++ b/src/Form/Service/ParticipantPickupFieldHandler.php @@ -30,7 +30,7 @@ class ParticipantPickupFieldHandler extends AbstractParticipantFieldHandler return ['transportationOutbound', 'transportationInbound']; } - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { return true; // Always process to handle clearing pickup } diff --git a/src/Form/Service/ParticipantRentalInsuranceFieldHandler.php b/src/Form/Service/ParticipantRentalInsuranceFieldHandler.php index 47e8641..c1438bf 100644 --- a/src/Form/Service/ParticipantRentalInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantRentalInsuranceFieldHandler.php @@ -56,11 +56,12 @@ class ParticipantRentalInsuranceFieldHandler extends AbstractParticipantFieldHan * rental insurance is selected. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param int $participantIndex The index of the participant being processed * * @return bool Always returns true for service selection fields */ - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { return true; // Always process to handle deselection cases } @@ -74,6 +75,7 @@ class ParticipantRentalInsuranceFieldHandler extends AbstractParticipantFieldHan * is no longer appropriate for the participant's age, it is automatically cleared. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param BookingDto $bookingDto The booking DTO to update (create or edit) * @param int $participantIndex The index of the participant being processed */ diff --git a/src/Form/Service/ParticipantRentalsFieldHandler.php b/src/Form/Service/ParticipantRentalsFieldHandler.php index a51ba75..b266994 100644 --- a/src/Form/Service/ParticipantRentalsFieldHandler.php +++ b/src/Form/Service/ParticipantRentalsFieldHandler.php @@ -48,11 +48,12 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler * services are selected. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param int $participantIndex The index of the participant being processed * * @return bool Always returns true for service selection fields */ - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { return true; // Always process to handle deselection cases } diff --git a/src/Form/Service/ParticipantSkiPassFieldHandler.php b/src/Form/Service/ParticipantSkiPassFieldHandler.php index 1a020e3..b78802c 100644 --- a/src/Form/Service/ParticipantSkiPassFieldHandler.php +++ b/src/Form/Service/ParticipantSkiPassFieldHandler.php @@ -60,11 +60,12 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler * skipass is selected. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param int $participantIndex The index of the participant being processed * * @return bool Always returns true for service selection fields */ - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { return true; // Always process to handle deselection cases } @@ -79,6 +80,7 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler * age or exceeds the travel date range, it is automatically cleared. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param BookingDto $bookingDto The booking DTO to update (create or edit) * @param int $participantIndex The index of the participant being processed */ diff --git a/src/Form/Service/ParticipantTransportationInboundFieldHandler.php b/src/Form/Service/ParticipantTransportationInboundFieldHandler.php index 0241ae3..8fb1171 100644 --- a/src/Form/Service/ParticipantTransportationInboundFieldHandler.php +++ b/src/Form/Service/ParticipantTransportationInboundFieldHandler.php @@ -32,11 +32,12 @@ class ParticipantTransportationInboundFieldHandler extends AbstractParticipantFi * participant DTO is updated with null when no transportation is selected. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param int $participantIndex The index of the participant being processed * * @return bool Always returns true for transportation selection fields */ - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { return true; // Always process to handle deselection cases } diff --git a/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php b/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php index 796c3eb..f289a2f 100644 --- a/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php +++ b/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php @@ -32,11 +32,12 @@ class ParticipantTransportationOutboundFieldHandler extends AbstractParticipantF * participant DTO is updated with null when no transportation is selected. * * @param array $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) * @param int $participantIndex The index of the participant being processed * * @return bool Always returns true for transportation selection fields */ - public function shouldProcess(array $submittedData, int $participantIndex): bool + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { return true; // Always process to handle deselection cases } diff --git a/src/Form/Service/Trait/FormTraversalTrait.php b/src/Form/Service/Trait/FormTraversalTrait.php index 2b40497..fb332a0 100644 --- a/src/Form/Service/Trait/FormTraversalTrait.php +++ b/src/Form/Service/Trait/FormTraversalTrait.php @@ -13,6 +13,9 @@ use Symfony\Component\Form\FormInterface; * This trait contains common form navigation logic used across different * form services and types. It centralizes the logic for finding root forms * and extracting booking DTOs from form hierarchies. + * + * Note: Card-based flows pass BookingDto via form options instead of + * relying on form tree traversal. */ trait FormTraversalTrait { @@ -20,8 +23,8 @@ trait FormTraversalTrait * Gets the BookingDto from the root of the form tree. * * This helper method traverses up the form tree to find the root form - * and extracts the BookingDto data. This is shared logic used - * by both create and edit participant forms. + * and extracts the BookingDto data. This is used as a fallback when + * BookingDto is not passed explicitly via form options. * * @param FormInterface $form The form to start traversing from * diff --git a/src/Service/ParticipantCardDataService.php b/src/Service/ParticipantCardDataService.php new file mode 100644 index 0000000..dd02413 --- /dev/null +++ b/src/Service/ParticipantCardDataService.php @@ -0,0 +1,115 @@ +participants[$index] ?? null; + + if (null === $participant) { + throw new \InvalidArgumentException(sprintf('Participant at index %d does not exist', $index)); + } + + // Extract participant name with fallback + $name = $this->getParticipantName($participant, $index); + + // Extract room name + $roomName = $this->getRoomName($bookingDto, $participant); + + // Calculate and format individual price + $price = $this->getFormattedPrice($bookingDto, $index); + + return [ + 'name' => $name, + 'roomName' => $roomName, + 'price' => $price, + ]; + } + + /** + * Get card data for all participants. + * + * @return array + */ + public function getAllCardsData(BookingDto $bookingDto): array + { + $cardsData = []; + + foreach ($bookingDto->participants as $index => $participant) { + $cardsData[$index] = $this->getCardData($bookingDto, $index); + } + + return $cardsData; + } + + /** + * Get participant name with fallback to generic label. + */ + private function getParticipantName(object $participant, int $index): string + { + $firstName = $participant->firstName ?? ''; + $lastName = $participant->lastName ?? ''; + + $name = trim($firstName . ' ' . $lastName); + + if ('' === $name) { + return sprintf('Teilnehmer %d', $index + 1); + } + + return $name; + } + + /** + * Get room name from travel model. + */ + private function getRoomName(BookingDto $bookingDto, object $participant): string + { + $roomId = $participant->assignedRoomId ?? null; + + if (null === $roomId) { + return 'Kein Zimmer zugewiesen'; + } + + $room = $bookingDto->travel->getRoomById($roomId); + + if (null === $room) { + return 'Unbekanntes Zimmer'; + } + + return $room->name; + } + + /** + * Calculate and format individual participant price. + */ + private function getFormattedPrice(BookingDto $bookingDto, int $index): string + { + $prices = $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto); + + $price = $prices[$index] ?? 0.0; + + return number_format($price, 2, ',', '.') . ' €'; + } +} \ No newline at end of file diff --git a/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php b/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php index 4d952a1..3706065 100644 --- a/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php +++ b/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php @@ -32,16 +32,37 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase { $dependencies = $this->handler->getDependencies(); - $this->assertEquals(['dateOfBirth'], $dependencies); + // Insurance handler depends on all price-affecting fields to ensure accurate reassignment + $expectedDependencies = [ + 'dateOfBirth', + 'skiPass', + 'rentals', + 'courses', + 'additionalServices', + 'board', + 'transportationOutbound', + 'transportationInbound', + 'pickup', + 'parking', + ]; + + $this->assertEquals($expectedDependencies, $dependencies); } - public function testShouldProcessAlwaysReturnsTrue(): void + public function testShouldProcessReturnsTrueInCreateMode(): void { - $result = $this->handler->shouldProcess([], 0); + $result = $this->handler->shouldProcess([], BookingDto::MODE_CREATE, 0); $this->assertTrue($result); } + public function testShouldProcessReturnsFalseInEditMode(): void + { + $result = $this->handler->shouldProcess([], BookingDto::MODE_EDIT, 0); + + $this->assertFalse($result, 'Insurance handler should not process in edit mode as API does not return insurance data'); + } + public function testProcessFieldSetsInsuranceToNullWhenNoParticipant(): void { $bookingDto = $this->createMockBookingDto(); diff --git a/tests/Form/Service/ParticipantLicensePlateFieldHandlerTest.php b/tests/Form/Service/ParticipantLicensePlateFieldHandlerTest.php index 03f1e04..ae3017d 100644 --- a/tests/Form/Service/ParticipantLicensePlateFieldHandlerTest.php +++ b/tests/Form/Service/ParticipantLicensePlateFieldHandlerTest.php @@ -31,8 +31,8 @@ class ParticipantLicensePlateFieldHandlerTest extends TestCase public function testShouldProcessAlwaysReturnsTrue(): void { - $this->assertTrue($this->handler->shouldProcess([], 0)); - $this->assertTrue($this->handler->shouldProcess(['some' => 'data'], 5)); + $this->assertTrue($this->handler->shouldProcess([], BookingDto::MODE_CREATE, 0)); + $this->assertTrue($this->handler->shouldProcess(['some' => 'data'], BookingDto::MODE_EDIT, 5)); } public function testProcessFieldWithoutParticipant(): void diff --git a/tests/Service/ParticipantCardDataServiceTest.php b/tests/Service/ParticipantCardDataServiceTest.php new file mode 100644 index 0000000..9dbe4ce --- /dev/null +++ b/tests/Service/ParticipantCardDataServiceTest.php @@ -0,0 +1,319 @@ +priceCalculator = $this->createMock(BookingPriceCalculatorService::class); + $this->service = new ParticipantCardDataService($this->priceCalculator); + } + + public function testGetCardDataWithFullParticipantData(): void + { + // Create test room + $room = new Room(); + $room->id = 1; + $room->name = 'Doppelzimmer'; + $room->price = 100.0; + + $travel = new Travel(); + $travel->rooms = [$room]; + + // Create participant with full data + $participant = new ParticipantDto(); + $participant->firstName = 'Max'; + $participant->lastName = 'Mustermann'; + $participant->assignedRoomId = 1; + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + // Mock price calculation + $this->priceCalculator + ->expects($this->once()) + ->method('calculateAllParticipantIndividualPrices') + ->with($bookingDto) + ->willReturn([450.50]); + + $result = $this->service->getCardData($bookingDto, 0); + + $this->assertEquals('Max Mustermann', $result['name']); + $this->assertEquals('Doppelzimmer', $result['roomName']); + $this->assertEquals('450,50 €', $result['price']); + } + + public function testGetCardDataWithPartialName(): void + { + $travel = new Travel(); + $travel->rooms = []; + + // Participant with only first name + $participant = new ParticipantDto(); + $participant->firstName = 'Max'; + $participant->lastName = null; + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + $this->priceCalculator + ->method('calculateAllParticipantIndividualPrices') + ->willReturn([0.0]); + + $result = $this->service->getCardData($bookingDto, 0); + + $this->assertEquals('Max', $result['name']); + } + + public function testGetCardDataWithNoName(): void + { + $travel = new Travel(); + $travel->rooms = []; + + // Participant without name + $participant = new ParticipantDto(); + $participant->firstName = null; + $participant->lastName = null; + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + $this->priceCalculator + ->method('calculateAllParticipantIndividualPrices') + ->willReturn([0.0]); + + $result = $this->service->getCardData($bookingDto, 0); + + $this->assertEquals('Teilnehmer 1', $result['name']); + } + + public function testGetCardDataWithEmptyName(): void + { + $travel = new Travel(); + $travel->rooms = []; + + // Participant with empty strings for names + $participant = new ParticipantDto(); + $participant->firstName = ' '; + $participant->lastName = ' '; + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + $this->priceCalculator + ->method('calculateAllParticipantIndividualPrices') + ->willReturn([0.0]); + + $result = $this->service->getCardData($bookingDto, 0); + + $this->assertEquals('Teilnehmer 1', $result['name']); + } + + public function testGetCardDataWithNoRoomAssignment(): void + { + $travel = new Travel(); + $travel->rooms = []; + + $participant = new ParticipantDto(); + $participant->firstName = 'Max'; + $participant->lastName = 'Mustermann'; + $participant->assignedRoomId = null; + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + $this->priceCalculator + ->method('calculateAllParticipantIndividualPrices') + ->willReturn([0.0]); + + $result = $this->service->getCardData($bookingDto, 0); + + $this->assertEquals('Kein Zimmer zugewiesen', $result['roomName']); + } + + public function testGetCardDataWithUnknownRoom(): void + { + $travel = new Travel(); + $travel->rooms = []; + + $participant = new ParticipantDto(); + $participant->firstName = 'Max'; + $participant->lastName = 'Mustermann'; + $participant->assignedRoomId = 999; // Non-existent room + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + $this->priceCalculator + ->method('calculateAllParticipantIndividualPrices') + ->willReturn([0.0]); + + $result = $this->service->getCardData($bookingDto, 0); + + $this->assertEquals('Unbekanntes Zimmer', $result['roomName']); + } + + public function testGetCardDataWithZeroPrice(): void + { + $travel = new Travel(); + $travel->rooms = []; + + $participant = new ParticipantDto(); + $participant->firstName = 'Max'; + $participant->lastName = 'Mustermann'; + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + $this->priceCalculator + ->method('calculateAllParticipantIndividualPrices') + ->willReturn([0.0]); + + $result = $this->service->getCardData($bookingDto, 0); + + $this->assertEquals('0,00 €', $result['price']); + } + + public function testGetCardDataWithInvalidIndex(): void + { + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = []; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Participant at index 0 does not exist'); + + $this->service->getCardData($bookingDto, 0); + } + + public function testGetAllCardsDataWithMultipleParticipants(): void + { + // Create test rooms + $room1 = new Room(); + $room1->id = 1; + $room1->name = 'Einzelzimmer'; + + $room2 = new Room(); + $room2->id = 2; + $room2->name = 'Doppelzimmer'; + + $travel = new Travel(); + $travel->rooms = [$room1, $room2]; + + // Create participants + $participant1 = new ParticipantDto(); + $participant1->firstName = 'Max'; + $participant1->lastName = 'Mustermann'; + $participant1->assignedRoomId = 1; + + $participant2 = new ParticipantDto(); + $participant2->firstName = 'Anna'; + $participant2->lastName = 'Schmidt'; + $participant2->assignedRoomId = 2; + + $participant3 = new ParticipantDto(); + $participant3->firstName = null; + $participant3->lastName = null; + $participant3->assignedRoomId = 2; + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant1, $participant2, $participant3]; + + // Mock price calculation + $this->priceCalculator + ->expects($this->exactly(3)) + ->method('calculateAllParticipantIndividualPrices') + ->with($bookingDto) + ->willReturn([450.0, 500.0, 480.0]); + + $result = $this->service->getAllCardsData($bookingDto); + + $this->assertCount(3, $result); + + // First participant + $this->assertEquals('Max Mustermann', $result[0]['name']); + $this->assertEquals('Einzelzimmer', $result[0]['roomName']); + $this->assertEquals('450,00 €', $result[0]['price']); + + // Second participant + $this->assertEquals('Anna Schmidt', $result[1]['name']); + $this->assertEquals('Doppelzimmer', $result[1]['roomName']); + $this->assertEquals('500,00 €', $result[1]['price']); + + // Third participant (no name) + $this->assertEquals('Teilnehmer 3', $result[2]['name']); + $this->assertEquals('Doppelzimmer', $result[2]['roomName']); + $this->assertEquals('480,00 €', $result[2]['price']); + } + + public function testGetAllCardsDataWithEmptyParticipants(): void + { + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = []; + + $result = $this->service->getAllCardsData($bookingDto); + + $this->assertEmpty($result); + } + + public function testPriceFormattingWithLargeAmount(): void + { + $travel = new Travel(); + $travel->rooms = []; + + $participant = new ParticipantDto(); + $participant->firstName = 'Max'; + $participant->lastName = 'Mustermann'; + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + $this->priceCalculator + ->method('calculateAllParticipantIndividualPrices') + ->willReturn([1234.56]); + + $result = $this->service->getCardData($bookingDto, 0); + + $this->assertEquals('1.234,56 €', $result['price']); + } + + public function testFallbackNameIndexingIsOneBasedNotZeroBased(): void + { + $travel = new Travel(); + $travel->rooms = []; + + $participant1 = new ParticipantDto(); + $participant2 = new ParticipantDto(); + $participant3 = new ParticipantDto(); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant1, $participant2, $participant3]; + + $this->priceCalculator + ->method('calculateAllParticipantIndividualPrices') + ->willReturn([0.0, 0.0, 0.0]); + + $result1 = $this->service->getCardData($bookingDto, 0); + $result2 = $this->service->getCardData($bookingDto, 1); + $result3 = $this->service->getCardData($bookingDto, 2); + + $this->assertEquals('Teilnehmer 1', $result1['name']); + $this->assertEquals('Teilnehmer 2', $result2['name']); + $this->assertEquals('Teilnehmer 3', $result3['name']); + } +} \ No newline at end of file