wip: refactor to cards with individual participant forms

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 33e1aea80c
commit de8cbf6178
22 changed files with 866 additions and 212 deletions
+289 -179
View File
@@ -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). 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 ## 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 | | **Room Assignment** | Keep in participant form | Existing behavior maintained |
| **Migration Strategy** | Separate controllers (-v2 routes) | Safe parallel development, easy comparison | | **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 | | **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**: **Why This Simplification**:
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. 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**: **Architecture Benefits**:
-Clean separation: `BookingDto` = full booking flow state, `ParticipantFormDto` = single participant editing context -No wrapper forms needed
- ✅ No pollution of BookingDto with editing-specific properties - ✅ No extra DTOs needed
-Type-safe and explicit relationships -No parent form needed
-Participant has full booking context for conditional logic -Explicit data flow via options
- ✅ Field state providers work unchanged - ✅ 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 ```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 * Field handlers are executed in PRE_SUBMIT to clean and transform data
* booking context needed for field state evaluation, pricing calculations, * before Symfony binds it to the form. This matches the pattern used in
* and conditional logic. * the old BookingCreateStep2Type parent form.
*/ */
class ParticipantFormDto private function processFieldHandlers(FormEvent $event, BookingDto $bookingContext): void
{ {
public function __construct( $form = $event->getForm();
public BookingDto $bookingContext, $submittedData = $event->getData();
public ParticipantDto $participant,
) {} 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**: **Usage in Controllers**:
- `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**:
```php ```php
class ParticipantFormType extends AbstractType // Card flow: Pass booking context explicitly
{ $form = $this->createForm(BookingParticipantType::class, $participant, [
public function __construct( 'booking_context' => $bookingDto,
private readonly ParticipantFieldHandlerRegistry $participantFieldHandlerRegistry, 'edit_mode' => false,
) {} ]);
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');
}
}
``` ```
**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**: **Todos**:
- [ ] Create form type with DI for field handler registry - [ ] Add `ParticipantFieldHandlerRegistry` to constructor (DI)
- [ ] Implement buildForm with participant field - [ ] Add `booking_context` option to `configureOptions()`
- [ ] Implement onPreSubmit event listener - [ ] Update `buildForm()` to capture `booking_context` and add PRE_SUBMIT listener
- [ ] Add proper PHPDoc - [ ] Add `processFieldHandlers()` private method to call registry
- [ ] Test field handler execution - [ ] Update `onPreSetData()` to accept and use `$bookingContext` parameter
- [ ] Test validation behavior - [ ] Update `onPreSubmit()` to accept and use `$bookingContext` parameter
- [ ] Test with card flow (booking_context provided)
--- - [ ] Verify field handlers execute automatically
### 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
- [ ] Verify field state providers work correctly - [ ] Verify field state providers work correctly
--- ---
### 1.6 Create `ParticipantCardFlowTrait` ### 1.4 Create `ParticipantCardFlowTrait`
**File**: `src/Controller/Booking/ParticipantCardFlowTrait.php` **File**: `src/Controller/Booking/ParticipantCardFlowTrait.php`
@@ -415,23 +505,35 @@ Implementation steps:
Implementation steps: Implementation steps:
1. Load BookingDto from session 1. Load BookingDto from session
2. Validate participant index 2. Validate participant index
3. Create form for `$bookingDto->participants[$index]` using `BookingParticipantType` 3. Create form for `$bookingDto->participants[$index]` using `BookingParticipantType` with `booking_context` option:
4. Set HTMX attributes for refresh endpoint ```php
5. `$form->handleRequest($request)` $form = $this->createForm(BookingParticipantType::class, $participant, [
6. If submitted and valid: 'booking_context' => $bookingDto,
- Extract participant data from submitted form 'edit_mode' => false,
- Process field handlers via `processFieldsForParticipant()` '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 - Save BookingDto to session
- HTMX redirect to cards view - HTMX redirect to cards view
7. Calculate summary data for sidebar 6. Calculate summary data for sidebar
8. Render `_participant_form_standalone.html.twig` with form and summary 7. Render `_participant_form_standalone.html.twig` with form and summary
**Action: `refreshParticipantForm(int $index, Request $request): Response`** **Action: `refreshParticipantForm(int $index, Request $request): Response`**
Implementation steps: Implementation steps:
1. Load BookingDto from session 1. Load BookingDto from session
2. Enrich with fresh availability data 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)` 4. `$form->handleRequest($request)`
5. Extract participant data from submitted form 5. Extract participant data from submitted form
6. Process field handlers via `processFieldsForParticipant()` 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 public function editParticipant(int $index, Request $request): Response
{ {
$bookingDto = $this->loadBookingDtoOrFail($request, BookingDto::MODE_CREATE); $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'], 'validation_groups' => ['booking_create_step_2'],
]); ]);
@@ -36,14 +36,15 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle
* *
* Default implementation checks if the field exists in the submitted data. * Default implementation checks if the field exists in the submitted data.
* Override this method for more complex processing conditions (e.g., only * 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<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 * @param int $participantIndex The index of the participant being processed
* *
* @return bool True if the handler should process this field, false otherwise * @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()]); return isset($submittedData[$this->getFieldName()]);
} }
@@ -67,6 +68,35 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle
return $participants[$participantIndex] ?? null; 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. * Safely extracts a field value from submitted participant data.
* *
@@ -39,8 +39,12 @@ interface ParticipantFieldHandlerInterface
/** /**
* Determines if this handler should process the field based on submitted participant data. * Determines if this handler should process the field based on submitted participant data.
*
* @param array<string, mixed> $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. * Returns field state modifications that should be applied after processing.
@@ -59,11 +59,12 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
* services are selected. * services are selected.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 * @param int $participantIndex The index of the participant being processed
* *
* @return bool Always returns true for service selection fields * @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 return true; // Always process to handle deselection cases
} }
@@ -84,6 +85,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
* 5. Updates participant with filtered valid selections * 5. Updates participant with filtered valid selections
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed * @param int $participantIndex The index of the participant being processed
*/ */
@@ -40,11 +40,12 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler
* services are selected. * services are selected.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 * @param int $participantIndex The index of the participant being processed
* *
* @return bool Always returns true for service selection fields * @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 return true; // Always process to handle deselection cases
} }
@@ -39,11 +39,12 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl
* the bulkInsuranceBooking checkbox - their insurance is controlled by the handler. * the bulkInsuranceBooking checkbox - their insurance is controlled by the handler.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 * @param int $participantIndex The index of the participant being processed
* *
* @return bool True if this is the applicant, false otherwise * @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 return 0 === $participantIndex; // Only process for applicant
} }
@@ -59,11 +59,12 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
* services are selected. * services are selected.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 * @param int $participantIndex The index of the participant being processed
* *
* @return bool Always returns true for service selection fields * @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 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. * no longer appropriate for the participant's age are automatically removed.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed * @param int $participantIndex The index of the participant being processed
*/ */
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form\Service; namespace App\Form\Service;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service; use App\BusProNet\Model\Service;
@@ -132,13 +133,42 @@ class ParticipantFieldHandlerRegistry
} }
// Let each handler decide if it should process this participant's data // 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); $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<string, mixed> $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. * 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 // Handle Address objects -> convert to array of properties
if ($value instanceof \App\BusProNet\Model\Address) { if ($value instanceof Address) {
return [ return [
'street' => $value->street, 'street' => $value->street,
'postCode' => $value->postCode, 'postCode' => $value->postCode,
@@ -76,27 +76,34 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
/** /**
* Determines if this handler should process the field based on submitted data. * Determines if this handler should process the field based on submitted data.
* *
* For insurance selection fields, we always need to process to handle cases * Insurance handler should NOT process in edit mode because the BPN API does not
* where the selection is cleared (field not present in data). This ensures * return insurance data. In edit mode, insurance data must be preserved as-is
* the participant DTO is updated with null when no insurance is selected. * and passed through to the update endpoint unchanged.
* *
* However, when bulk insurance booking is active for a dependent participant, * In create mode, we always need to process to handle cases where the selection
* we skip processing to prevent overwriting the insurance assigned by the * is cleared (field not present in data). However, when bulk insurance booking
* bulk insurance handler. * is active for a dependent participant, we skip processing to prevent overwriting
* the insurance assigned by the bulk insurance handler.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 * @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 // Skip processing for dependent participants when bulk insurance booking is active
if ($participantIndex > 0 && $this->isBulkInsuranceBookingActive($submittedData)) { if ($participantIndex > 0 && $this->isBulkInsuranceBookingActive($submittedData)) {
return false; return false;
} }
return true; // Always process to handle deselection cases return true; // Always process in create mode to handle deselection cases
} }
/** /**
@@ -52,11 +52,12 @@ class ParticipantLicensePlateFieldHandler extends AbstractParticipantFieldHandle
* the participant DTO is updated correctly when parking availability changes. * the participant DTO is updated correctly when parking availability changes.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 * @param int $participantIndex The index of the participant being processed
* *
* @return bool Always returns true for license plate fields * @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 return true; // Always process to handle deselection cases
} }
@@ -37,11 +37,12 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler
* participant DTO is updated correctly when transportation switches to bus-only. * participant DTO is updated correctly when transportation switches to bus-only.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 * @param int $participantIndex The index of the participant being processed
* *
* @return bool Always returns true for parking selection fields * @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 return true; // Always process for state changes
} }
@@ -30,7 +30,7 @@ class ParticipantPickupFieldHandler extends AbstractParticipantFieldHandler
return ['transportationOutbound', 'transportationInbound']; 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 return true; // Always process to handle clearing pickup
} }
@@ -56,11 +56,12 @@ class ParticipantRentalInsuranceFieldHandler extends AbstractParticipantFieldHan
* rental insurance is selected. * rental insurance is selected.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 * @param int $participantIndex The index of the participant being processed
* *
* @return bool Always returns true for service selection fields * @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 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. * is no longer appropriate for the participant's age, it is automatically cleared.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed * @param int $participantIndex The index of the participant being processed
*/ */
@@ -48,11 +48,12 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler
* services are selected. * services are selected.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 * @param int $participantIndex The index of the participant being processed
* *
* @return bool Always returns true for service selection fields * @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 return true; // Always process to handle deselection cases
} }
@@ -60,11 +60,12 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
* skipass is selected. * skipass is selected.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 * @param int $participantIndex The index of the participant being processed
* *
* @return bool Always returns true for service selection fields * @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 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. * age or exceeds the travel date range, it is automatically cleared.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed * @param int $participantIndex The index of the participant being processed
*/ */
@@ -32,11 +32,12 @@ class ParticipantTransportationInboundFieldHandler extends AbstractParticipantFi
* participant DTO is updated with null when no transportation is selected. * participant DTO is updated with null when no transportation is selected.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 * @param int $participantIndex The index of the participant being processed
* *
* @return bool Always returns true for transportation selection fields * @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 return true; // Always process to handle deselection cases
} }
@@ -32,11 +32,12 @@ class ParticipantTransportationOutboundFieldHandler extends AbstractParticipantF
* participant DTO is updated with null when no transportation is selected. * participant DTO is updated with null when no transportation is selected.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @param array<string, mixed> $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 * @param int $participantIndex The index of the participant being processed
* *
* @return bool Always returns true for transportation selection fields * @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 return true; // Always process to handle deselection cases
} }
@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormInterface;
* This trait contains common form navigation logic used across different * This trait contains common form navigation logic used across different
* form services and types. It centralizes the logic for finding root forms * form services and types. It centralizes the logic for finding root forms
* and extracting booking DTOs from form hierarchies. * 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 trait FormTraversalTrait
{ {
@@ -20,8 +23,8 @@ trait FormTraversalTrait
* Gets the BookingDto from the root of the form tree. * Gets the BookingDto from the root of the form tree.
* *
* This helper method traverses up the form tree to find the root form * This helper method traverses up the form tree to find the root form
* and extracts the BookingDto data. This is shared logic used * and extracts the BookingDto data. This is used as a fallback when
* by both create and edit participant forms. * BookingDto is not passed explicitly via form options.
* *
* @param FormInterface $form The form to start traversing from * @param FormInterface $form The form to start traversing from
* *
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Form\Model\BookingDto;
/**
* Extracts card display data for participants in card-based booking flows.
*
* Provides participant name, room assignment, and individual pricing for
* display in the card overview UI.
*/
class ParticipantCardDataService
{
public function __construct(
private readonly BookingPriceCalculatorService $priceCalculator,
) {
}
/**
* Get card data for a single participant.
*
* @return array{name: string, roomName: string, price: string}
*/
public function getCardData(BookingDto $bookingDto, int $index): array
{
$participant = $bookingDto->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<int, array{name: string, roomName: string, price: string}>
*/
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, ',', '.') . ' €';
}
}
@@ -32,16 +32,37 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase
{ {
$dependencies = $this->handler->getDependencies(); $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); $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 public function testProcessFieldSetsInsuranceToNullWhenNoParticipant(): void
{ {
$bookingDto = $this->createMockBookingDto(); $bookingDto = $this->createMockBookingDto();
@@ -31,8 +31,8 @@ class ParticipantLicensePlateFieldHandlerTest extends TestCase
public function testShouldProcessAlwaysReturnsTrue(): void public function testShouldProcessAlwaysReturnsTrue(): void
{ {
$this->assertTrue($this->handler->shouldProcess([], 0)); $this->assertTrue($this->handler->shouldProcess([], BookingDto::MODE_CREATE, 0));
$this->assertTrue($this->handler->shouldProcess(['some' => 'data'], 5)); $this->assertTrue($this->handler->shouldProcess(['some' => 'data'], BookingDto::MODE_EDIT, 5));
} }
public function testProcessFieldWithoutParticipant(): void public function testProcessFieldWithoutParticipant(): void
@@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Service\BookingPriceCalculatorService;
use App\Service\ParticipantCardDataService;
use PHPUnit\Framework\TestCase;
class ParticipantCardDataServiceTest extends TestCase
{
private ParticipantCardDataService $service;
private BookingPriceCalculatorService $priceCalculator;
protected function setUp(): void
{
$this->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']);
}
}