wip: finalize implementation

This commit is contained in:
Björn Fromme
2026-03-16 11:59:11 +01:00
parent b882175f36
commit 5ef3bb9cbf
20 changed files with 112 additions and 224 deletions
+1 -6
View File
@@ -17,7 +17,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
class Insurance class Insurance
{ {
#[Groups(['api:single', 'api:list'])] #[Groups(['api:single', 'api:list'])]
public string|int|null $id = null; public string|null $id = null;
#[Groups(['api:single', 'api:list'])] #[Groups(['api:single', 'api:list'])]
public ?string $code = null; public ?string $code = null;
@@ -105,11 +105,6 @@ class Insurance
*/ */
public array $individualPrice = []; public array $individualPrice = [];
public function __toString(): string
{
return (string) $this->id;
}
/** /**
* Returns the subType, with virtual 'PKG' subType for insurance packages. * Returns the subType, with virtual 'PKG' subType for insurance packages.
* *
+1 -1
View File
@@ -34,7 +34,7 @@ trait SortByPriceTrait
*/ */
protected function sortByPrice(array $items): array protected function sortByPrice(array $items): array
{ {
usort($items, function (object $a, object $b) { uasort($items, function (object $a, object $b) {
$priceA = $a->price ?? 0; $priceA = $a->price ?? 0;
$priceB = $b->price ?? 0; $priceB = $b->price ?? 0;
+3 -4
View File
@@ -33,7 +33,6 @@ class InsuranceParser extends AbstractParser
$individualInsurances = []; $individualInsurances = [];
$xmlContent->filterXPath('//versicherungen/versicherung') $xmlContent->filterXPath('//versicherungen/versicherung')
->each(function (Crawler $node) use (&$individualInsurances, &$insurances) { ->each(function (Crawler $node) use (&$individualInsurances, &$insurances) {
$id = (int) $node->attr('idbuspro'); // Individual insurances have int IDs
$isComplementary = $this->getBoolAttributeValue($node->attr('zusatzversicherung')); $isComplementary = $this->getBoolAttributeValue($node->attr('zusatzversicherung'));
// Parse all individual insurances for reference lookup // Parse all individual insurances for reference lookup
@@ -110,7 +109,7 @@ class InsuranceParser extends AbstractParser
// Basic identifiers - packages have string IDs with 'P' prefix, individual insurances have int IDs // Basic identifiers - packages have string IDs with 'P' prefix, individual insurances have int IDs
$idValue = $node->attr('idbuspro'); $idValue = $node->attr('idbuspro');
$insurance->id = $isPackage ? $idValue : (int) $idValue; $insurance->id = $idValue;
$insurance->code = $node->attr('code'); $insurance->code = $node->attr('code');
$insurance->label = $this->normalizeInsuranceLabel($node->attr('bezeichnung')); $insurance->label = $this->normalizeInsuranceLabel($node->attr('bezeichnung'));
$insurance->subType = $node->attr('unterart'); $insurance->subType = $node->attr('unterart');
@@ -169,7 +168,7 @@ class InsuranceParser extends AbstractParser
->each(function (Crawler $packageNode) use (&$referencedIds) { ->each(function (Crawler $packageNode) use (&$referencedIds) {
$packageNode->filterXPath('.//enthalteneversicherung') $packageNode->filterXPath('.//enthalteneversicherung')
->each(function (Crawler $refNode) use (&$referencedIds) { ->each(function (Crawler $refNode) use (&$referencedIds) {
$referencedIds[] = (int) $refNode->attr('idbuspro'); // Referenced insurances are always int IDs $referencedIds[] = $refNode->attr('idbuspro');
}); });
}); });
@@ -188,7 +187,7 @@ class InsuranceParser extends AbstractParser
$ids = []; $ids = [];
$node->filterXPath('.//enthalteneversicherungen/enthalteneversicherung') $node->filterXPath('.//enthalteneversicherungen/enthalteneversicherung')
->each(function (Crawler $insuranceNode) use (&$ids) { ->each(function (Crawler $insuranceNode) use (&$ids) {
$ids[] = (int) $insuranceNode->attr('idbuspro'); // Individual insurance IDs are always int $ids[] = $insuranceNode->attr('idbuspro'); // Individual insurance IDs are always int
}); });
return $ids; return $ids;
@@ -65,7 +65,7 @@ class Step1Controller extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if (true === $form->isSubmitted() && true === $form->isValid()) {
if ($this->bookingService->hasRoomSelectionChanged($oldRoomSelectionSnapshot, $bookingCreateDto)) { if ($this->bookingService->hasRoomSelectionChanged($oldRoomSelectionSnapshot, $bookingCreateDto)) {
$this->bookingService->resetParticipantAssignments($bookingCreateDto); $this->bookingService->resetParticipantAssignments($bookingCreateDto);
} }
@@ -99,7 +99,7 @@ class Step1Controller extends AbstractController
* without validation and returning freshly rendered blocks. * without validation and returning freshly rendered blocks.
*/ */
#[Route('/bookings/create/refresh', name: 'app_booking_create_step_1_refresh', methods: ['POST'])] #[Route('/bookings/create/refresh', name: 'app_booking_create_step_1_refresh', methods: ['POST'])]
public function refreshRoomSelection(Request $request): Response public function refresh(Request $request): Response
{ {
$result = $this->getOrCreateBookingCreateDtoForHtmx($this->bookingService, $request); $result = $this->getOrCreateBookingCreateDtoForHtmx($this->bookingService, $request);
if ($result instanceof Response) { if ($result instanceof Response) {
@@ -11,6 +11,7 @@ use App\Controller\Booking\Traits\ParticipantValidationTrait;
use App\Form\BookingCreateStep2Type; use App\Form\BookingCreateStep2Type;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantFieldOptionsProvider;
use App\Htmx\HxTrait; use App\Htmx\HxTrait;
use App\Service\BookingPriceCalculatorService; use App\Service\BookingPriceCalculatorService;
use App\Service\BookingService; use App\Service\BookingService;
@@ -42,6 +43,7 @@ class Step2Controller extends AbstractController
private readonly TravelDataService $travelDataService, private readonly TravelDataService $travelDataService,
private readonly RoomAssignmentService $roomAssignmentService, private readonly RoomAssignmentService $roomAssignmentService,
private readonly ParticipantCardDataService $participantCardService, private readonly ParticipantCardDataService $participantCardService,
private readonly ParticipantFieldOptionsProvider $fieldOptionsProvider,
) { ) {
} }
@@ -58,14 +60,14 @@ class Step2Controller extends AbstractController
} }
$bookingCreateDto = $result; $bookingCreateDto = $result;
// Enrich with fresh availability data
$this->enrichWithFreshAvailabilities($bookingCreateDto);
// Validate step access // Validate step access
if ($redirect = $this->validateStepAccess($bookingCreateDto, 2)) { if ($redirect = $this->validateStepAccess($bookingCreateDto, 2)) {
return $redirect; return $redirect;
} }
// Enrich with fresh availability data
$this->enrichWithFreshAvailabilities($bookingCreateDto);
// Ensure correct number of participants // Ensure correct number of participants
$this->ensureCorrectNumberOfParticipants($bookingCreateDto); $this->ensureCorrectNumberOfParticipants($bookingCreateDto);
@@ -84,8 +86,8 @@ class Step2Controller extends AbstractController
]); ]);
$form->handleRequest($request); $form->handleRequest($request);
// Handle form submission (clicking "Weiter") // Handle form submission
if ($form->isSubmitted() && $form->isValid()) { if (true === $form->isSubmitted() && true === $form->isValid()) {
// All participants validated successfully, update current step // All participants validated successfully, update current step
$bookingCreateDto->currentStep = 3; $bookingCreateDto->currentStep = 3;
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
@@ -96,7 +98,7 @@ class Step2Controller extends AbstractController
// Extract participant indices with validation errors // Extract participant indices with validation errors
$participantErrors = []; $participantErrors = [];
if ($form->isSubmitted() && false === $form->isValid()) { if (true === $form->isSubmitted() && false === $form->isValid()) {
$participantErrors = $this->extractParticipantErrorIndices($form); $participantErrors = $this->extractParticipantErrorIndices($form);
} }
@@ -118,7 +120,7 @@ class Step2Controller extends AbstractController
'participantErrors' => $participantErrors, 'participantErrors' => $participantErrors,
]; ];
// If HTMX request, render only blocks to avoid layout duplication // HTMX request: render blocks only
if ($this->isHxRequest($request)) { if ($this->isHxRequest($request)) {
return $this->htmxOobResponse( return $this->htmxOobResponse(
'booking/create/step_2.html.twig', 'booking/create/step_2.html.twig',
@@ -148,6 +150,9 @@ class Step2Controller extends AbstractController
throw $this->createNotFoundException(sprintf('Participant at index %d does not exist', $index)); throw $this->createNotFoundException(sprintf('Participant at index %d does not exist', $index));
} }
// Enrich with fresh availability data
$this->enrichWithFreshAvailabilities($bookingDto);
// Create form with booking_context option // Create form with booking_context option
$form = $this->createParticipantForm($bookingDto, $index, [ $form = $this->createParticipantForm($bookingDto, $index, [
'validation_groups' => ['booking_create_step_2'], 'validation_groups' => ['booking_create_step_2'],
@@ -155,7 +160,7 @@ class Step2Controller extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if (true === $form->isSubmitted() && true === $form->isValid()) {
// Save BookingDto to session // Save BookingDto to session
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
@@ -199,14 +204,14 @@ class Step2Controller extends AbstractController
{ {
$bookingDto = $this->loadBookingDtoOrFail($request, BookingDto::MODE_CREATE); $bookingDto = $this->loadBookingDtoOrFail($request, BookingDto::MODE_CREATE);
// Enrich with fresh availability data
$this->enrichWithFreshAvailabilities($bookingDto);
// Validate participant index // Validate participant index
if (false === isset($bookingDto->participants[$index])) { if (false === isset($bookingDto->participants[$index])) {
throw $this->createNotFoundException(sprintf('Participant at index %d does not exist', $index)); throw $this->createNotFoundException(sprintf('Participant at index %d does not exist', $index));
} }
// Enrich with fresh availability data
$this->enrichWithFreshAvailabilities($bookingDto);
// Use trait method for refresh handling // Use trait method for refresh handling
return $this->handleParticipantRefresh( return $this->handleParticipantRefresh(
$request, $request,
@@ -64,13 +64,14 @@ class Step3Controller extends AbstractController
]); ]);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if (true === $form->isSubmitted() && true === $form->isValid()) {
try { try {
// Validate booking data with API (inquiry) // Validate booking data with API (inquiry)
$bookingCreateDto->bookingStatus = 'A';
$inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto); $inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto);
if ($inquiryResponse instanceof Notification) { if ($inquiryResponse instanceof Notification) {
return $this->handleInquiryError( return $this->handleApiError(
'Booking inquiry failed', 'Booking inquiry failed',
['message' => $inquiryResponse->message], ['message' => $inquiryResponse->message],
'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.', 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.',
@@ -80,7 +81,7 @@ class Step3Controller extends AbstractController
} }
if (false === $inquiryResponse->isInquiryValid()) { if (false === $inquiryResponse->isInquiryValid()) {
return $this->handleInquiryError( return $this->handleApiError(
'Booking inquiry validation failed', 'Booking inquiry validation failed',
['status' => $inquiryResponse->status], ['status' => $inquiryResponse->status],
'Buchung konnte nicht validiert werden.', 'Buchung konnte nicht validiert werden.',
@@ -94,7 +95,7 @@ class Step3Controller extends AbstractController
$calculatedTotal = $this->priceCalculator->calculateGrandTotal($bookingCreateDto); $calculatedTotal = $this->priceCalculator->calculateGrandTotal($bookingCreateDto);
if ($apiTotal !== $calculatedTotal) { if ($apiTotal !== $calculatedTotal) {
return $this->handleInquiryError( return $this->handleApiError(
'Price mismatch detected - payload incomplete', 'Price mismatch detected - payload incomplete',
[ [
'apiTotal' => $apiTotal, 'apiTotal' => $apiTotal,
@@ -111,9 +112,13 @@ class Step3Controller extends AbstractController
$bookingCreateDto->currentStep = 4; $bookingCreateDto->currentStep = 4;
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
if ($inquiryResponse->message) {
$this->addFlash('info', $inquiryResponse->message);
}
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_4')); return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_4'));
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->handleInquiryError( return $this->handleApiError(
'Booking inquiry exception', 'Booking inquiry exception',
[ [
'exception' => $e->getMessage(), 'exception' => $e->getMessage(),
@@ -151,22 +156,6 @@ class Step3Controller extends AbstractController
return $this->renderStepForm($bookingCreateDto, $form); return $this->renderStepForm($bookingCreateDto, $form);
} }
/**
* Handles inquiry errors by logging, adding flash message, and rendering the form.
*/
private function handleInquiryError(
string $logMessage,
array $context,
string $flashMessage,
BookingDto $bookingCreateDto,
FormInterface $form,
): Response {
$this->logger->error($logMessage, $context);
$this->addFlash('error', $flashMessage);
return $this->renderStepForm($bookingCreateDto, $form);
}
/** /**
* Renders the step 3 form with standard template variables. * Renders the step 3 form with standard template variables.
*/ */
@@ -62,13 +62,13 @@ class Step4Controller extends AbstractController
]); ]);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if (true === $form->isSubmitted() && true === $form->isValid()) {
try { try {
// Submit final booking (already validated in Step 3) // Submit final booking (already validated in Step 3)
$bookingResponse = $this->apiClient->createBooking($bookingCreateDto); $bookingResponse = $this->apiClient->createBooking($bookingCreateDto);
if ($bookingResponse instanceof Notification) { if ($bookingResponse instanceof Notification) {
return $this->handleBookingError( return $this->handleApiError(
'Booking creation failed - API notification', 'Booking creation failed - API notification',
['message' => $bookingResponse->message], ['message' => $bookingResponse->message],
$bookingResponse->message, $bookingResponse->message,
@@ -78,7 +78,7 @@ class Step4Controller extends AbstractController
} }
if (false === $bookingResponse->isBookingSuccessful()) { if (false === $bookingResponse->isBookingSuccessful()) {
return $this->handleBookingError( return $this->handleApiError(
'Booking creation unsuccessful', 'Booking creation unsuccessful',
['status' => $bookingResponse->status], ['status' => $bookingResponse->status],
'Buchung konnte nicht erstellt werden.', 'Buchung konnte nicht erstellt werden.',
@@ -93,7 +93,7 @@ class Step4Controller extends AbstractController
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_success')); return $this->hxRedirect($request, $this->generateUrl('app_booking_create_success'));
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->handleBookingError( return $this->handleApiError(
'Booking creation exception', 'Booking creation exception',
[ [
'exception' => $e->getMessage(), 'exception' => $e->getMessage(),
@@ -109,22 +109,6 @@ class Step4Controller extends AbstractController
return $this->renderStepForm($bookingCreateDto, $form); return $this->renderStepForm($bookingCreateDto, $form);
} }
/**
* Handles booking errors by logging, adding flash message, and rendering the form.
*/
private function handleBookingError(
string $logMessage,
array $context,
string $flashMessage,
BookingDto $bookingCreateDto,
FormInterface $form,
): Response {
$this->logger->error($logMessage, $context);
$this->addFlash('error', $flashMessage);
return $this->renderStepForm($bookingCreateDto, $form);
}
/** /**
* Renders the step 4 form with standard template variables. * Renders the step 4 form with standard template variables.
*/ */
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Controller\Booking\Traits; namespace App\Controller\Booking\Traits;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
/** /**
* Provides common functionality for booking creation controllers. * Provides common functionality for booking creation controllers.
@@ -78,4 +80,20 @@ trait BookingCreateTrait
'groupedSelectedRooms' => $groupedSelectedRooms, 'groupedSelectedRooms' => $groupedSelectedRooms,
]; ];
} }
/**
* Handles API errors by logging, adding flash message, and rendering the form.
*/
private function handleApiError(
string $logMessage,
array $context,
string $flashMessage,
BookingDto $bookingCreateDto,
FormInterface $form,
): Response {
$this->logger->error($logMessage, $context);
$this->addFlash('error', $flashMessage);
return $this->renderStepForm($bookingCreateDto, $form);
}
} }
@@ -67,7 +67,6 @@ trait ParticipantCardFlowTrait
// Merge default options with provided options // Merge default options with provided options
$formOptions = array_merge([ $formOptions = array_merge([
'booking_context' => $bookingDto, 'booking_context' => $bookingDto,
'edit_mode' => BookingDto::MODE_EDIT === $bookingDto->getMode(),
], $options); ], $options);
return $this->createForm(BookingParticipantType::class, $participant, $formOptions); return $this->createForm(BookingParticipantType::class, $participant, $formOptions);
+24 -30
View File
@@ -37,26 +37,21 @@ class BookingParticipantType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
// Capture booking context for use in event listeners
/** @var BookingDto $bookingContext */
$bookingContext = $options['booking_context'];
// Select field state provider based on edit_mode option // Select field state provider based on edit_mode option
$this->fieldStateProvider = $options['edit_mode'] $this->fieldStateProvider = BookingDto::MODE_EDIT === $bookingContext->getMode()
? $this->editFieldStateProvider ? $this->editFieldStateProvider
: $this->createFieldStateProvider; : $this->createFieldStateProvider;
// Capture booking context for use in event listeners
$bookingContext = $options['booking_context'];
$builder $builder
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($bookingContext) { ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($bookingContext) {
$this->onPreSetData($event, $bookingContext); $this->onPreSetData($event, $bookingContext);
}) })
->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($bookingContext) { ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($bookingContext) {
// Process field handlers FIRST (before form binding and validation) $this->processFieldHandlers($event, $bookingContext);
// 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); $this->onPreSubmit($event, $bookingContext);
}); });
} }
@@ -99,12 +94,13 @@ class BookingParticipantType extends AbstractType
{ {
/** @var ParticipantDto|null $participantData */ /** @var ParticipantDto|null $participantData */
$participantData = $event->getData(); $participantData = $event->getData();
$form = $event->getForm();
if (null === $participantData) { if (null === $participantData) {
return; return;
} }
$form = $event->getForm();
// Card flow: BookingDto passed via options // Card flow: BookingDto passed via options
// Accordion flow (if we had one): traverse form tree // Accordion flow (if we had one): traverse form tree
$bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form); $bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form);
@@ -128,10 +124,6 @@ class BookingParticipantType extends AbstractType
$submittedData = $event->getData(); $submittedData = $event->getData();
$form = $event->getForm(); $form = $event->getForm();
if (false === is_array($submittedData)) {
return;
}
// Card flow: BookingDto passed via options // Card flow: BookingDto passed via options
// Accordion flow (if we had one): traverse form tree // Accordion flow (if we had one): traverse form tree
$bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form); $bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form);
@@ -148,6 +140,8 @@ class BookingParticipantType extends AbstractType
// Rebuild all fields with updated states based on submitted data // Rebuild all fields with updated states based on submitted data
$this->rebuildFieldsWithStates($form, $bookingDto, $participantData->index, $submittedData); $this->rebuildFieldsWithStates($form, $bookingDto, $participantData->index, $submittedData);
$event->setData($submittedData);
} }
/** /**
@@ -219,13 +213,17 @@ class BookingParticipantType extends AbstractType
* @param FormInterface $form The form to modify * @param FormInterface $form The form to modify
* @param BookingDto $bookingDto The booking data for context * @param BookingDto $bookingDto The booking data for context
* @param int $participantIndex The participant index * @param int $participantIndex The participant index
* @param array<string, mixed> $formData Submitted form data for state calculation * @param array<string, mixed> $submittedData Submitted form data for state calculation
*/ */
private function removeExcludedFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array $formData = []): void private function removeExcludedFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array &$submittedData = []): void
{ {
foreach (ParticipantDto::DYNAMIC_FIELDS as $fieldName) { foreach (ParticipantDto::DYNAMIC_FIELDS as $fieldName) {
if ($form->has($fieldName) && !$this->fieldStateProvider->shouldIncludeField($fieldName, $bookingDto, $participantIndex, $formData)) { if (
true === $form->has($fieldName)
&& false === $this->fieldStateProvider->shouldIncludeField($fieldName, $bookingDto, $participantIndex, $submittedData)
) {
$form->remove($fieldName); $form->remove($fieldName);
unset($submittedData[$fieldName]);
} }
} }
} }
@@ -233,13 +231,11 @@ class BookingParticipantType extends AbstractType
/** /**
* Rebuilds all fields with updated states based on submitted data. * Rebuilds all fields with updated states based on submitted data.
*/ */
private function rebuildFieldsWithStates(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array $submittedData): void private function rebuildFieldsWithStates(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array &$submittedData): void
{ {
// First, remove fields that should be excluded entirely // First, remove fields that should be excluded entirely
$this->removeExcludedFields($form, $bookingDto, $participantIndex, $submittedData); $this->removeExcludedFields($form, $bookingDto, $participantIndex, $submittedData);
// Clear the form and rebuild from scratch with updated states
// Rebuild base fields with updated states // Rebuild base fields with updated states
$baseFields = ['firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile', 'address', 'bodyDimensions']; $baseFields = ['firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile', 'address', 'bodyDimensions'];
foreach ($baseFields as $fieldName) { foreach ($baseFields as $fieldName) {
@@ -286,16 +282,16 @@ class BookingParticipantType extends AbstractType
// Insurance fields only available in create mode (API limitation) // Insurance fields only available in create mode (API limitation)
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
$dynamicFields['bulkInsuranceBooking'] = CheckboxType::class; $dynamicFields['bulkInsuranceBooking'] = CheckboxType::class;
$dynamicFields['insurance'] = InsuranceChoiceType::class; $dynamicFields['insurance'] = ChoiceType::class;
} }
foreach ($dynamicFields as $fieldName => $fieldType) { foreach ($dynamicFields as $fieldName => $fieldType) {
if ($this->fieldOptionsProvider->hasFieldOptions($fieldName)) { // Check if field should be included in the form at all
// Check if field should be included in the form at all if (false === $this->fieldStateProvider->shouldIncludeField($fieldName, $bookingDto, $participantIndex)) {
if (false === $this->fieldStateProvider->shouldIncludeField($fieldName, $bookingDto, $participantIndex)) { continue;
continue; }
}
if ($this->fieldOptionsProvider->hasFieldOptions($fieldName)) {
// Get base field options // Get base field options
$fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingDto, $participantIndex); $fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingDto, $participantIndex);
@@ -377,12 +373,10 @@ class BookingParticipantType extends AbstractType
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => ParticipantDto::class, 'data_class' => ParticipantDto::class,
'selected_rooms' => [], 'selected_rooms' => [],
'edit_mode' => false,
'booking_context' => null, 'booking_context' => null,
]); ]);
$resolver->setAllowedTypes('selected_rooms', 'array'); $resolver->setAllowedTypes('selected_rooms', 'array');
$resolver->setAllowedTypes('edit_mode', 'bool');
$resolver->setAllowedTypes('booking_context', ['null', BookingDto::class]); $resolver->setAllowedTypes('booking_context', ['null', BookingDto::class]);
} }
} }
-86
View File
@@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\BusProNet\Model\Insurance;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form type for insurance selection that provides full Insurance objects to templates.
*
* This type extends ChoiceType to pass complete Insurance model data to the template layer,
* enabling flexible rendering of insurance details including URLs, pricing, and coverage information.
*/
class InsuranceChoiceType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired('insurances');
$resolver->setAllowedTypes('insurances', 'array');
$resolver->setDefault('choices', function (Options $options) {
// Prepend "no insurance" option to eligible insurances
return array_merge(
[0 => null],
$options['insurances']
);
});
$resolver->setDefault('choice_value', function ($insurance) {
// Handle "no insurance" option (null value at index 0)
// instanceof check required because closures in configureOptions receive mixed types
if ($insurance instanceof Insurance) {
return (string) $insurance->id;
}
return '';
});
$resolver->setDefault('choice_label', function ($insurance) {
// Handle "no insurance" option (null value at index 0)
// instanceof check required because closures in configureOptions receive mixed types
if (!$insurance instanceof Insurance) {
return 'Keine Versicherung';
}
$label = $insurance->label;
if (null !== $insurance->price && $insurance->price > 0) {
$label .= sprintf(' (€%s)', number_format($insurance->price, 2, ',', '.'));
}
return $label;
});
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
// Index insurances by ID for template lookup
$insurances = [];
foreach ($options['insurances'] as $insurance) {
if ($insurance instanceof Insurance) {
$insurances[(string) $insurance->id] = $insurance;
}
}
// Pass Insurance objects to template indexed by choice value
$view->vars['insurances'] = $insurances;
}
public function getParent(): string
{
return ChoiceType::class;
}
public function getBlockPrefix(): string
{
return 'insurance_choice';
}
}
+1 -1
View File
@@ -186,7 +186,7 @@ class ParticipantDto
* *
* @return bool True if an insurance is selected * @return bool True if an insurance is selected
*/ */
public function hasInsurance(): bool public function hasInsuranceSelected(): bool
{ {
return null !== $this->insurance; return null !== $this->insurance;
} }
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Form\Service; namespace App\Form\Service;
use App\BusProNet\Constants; use App\BusProNet\Constants;
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;
use App\BusProNet\Utility\DirectionMapper; use App\BusProNet\Utility\DirectionMapper;
@@ -45,13 +46,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
* @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders * @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders
* @param ServiceAvailabilityCalculator $serviceAvailabilityCalculator Service for calculating dynamic availability * @param ServiceAvailabilityCalculator $serviceAvailabilityCalculator Service for calculating dynamic availability
* @param InsuranceMatchingService $insuranceMatchingService Service for matching insurances to participants * @param InsuranceMatchingService $insuranceMatchingService Service for matching insurances to participants
* @param UrlGeneratorInterface $urlGenerator URL generator for HTMX endpoints
*/ */
public function __construct( public function __construct(
private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory, private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory,
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator, private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
private readonly InsuranceMatchingService $insuranceMatchingService, private readonly InsuranceMatchingService $insuranceMatchingService,
private readonly UrlGeneratorInterface $urlGenerator,
) { ) {
parent::__construct(); parent::__construct();
} }
@@ -435,7 +434,18 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'multiple' => false, 'multiple' => false,
'expanded' => true, 'expanded' => true,
'required' => false, 'required' => false,
'insurances' => $this->getEligibleInsurances($bookingDto, $participantIndex), 'choices' => $this->getEligibleInsurances($bookingDto, $participantIndex),
'choice_value' => 'id',
'choice_label' => function (?Insurance $insurance) {
$label = $insurance->label;
if (null !== $insurance->price && $insurance->price > 0) {
$label .= sprintf(' (€%s)', number_format($insurance->price, 2, ',', '.'));
}
return $label;
},
'placeholder' => 'Keine Versicherung gewünscht',
]; ];
// Future field providers would be added here, for example: // Future field providers would be added here, for example:
@@ -76,7 +76,7 @@ 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.
* *
* Insurance handler should NOT process in edit mode because the BPN API does not * Insurance handler should NOT procefss in edit mode because the BPN API does not
* return insurance data. In edit mode, insurance data must be preserved as-is * return insurance data. In edit mode, insurance data must be preserved as-is
* and passed through to the update endpoint unchanged. * and passed through to the update endpoint unchanged.
* *
@@ -172,8 +172,6 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
// Insurance not found - clear selection // Insurance not found - clear selection
$participant->insurance = null; $participant->insurance = null;
} }
return;
} }
// Handle form resubmission with existing insurance (automatic reassignment check) // Handle form resubmission with existing insurance (automatic reassignment check)
@@ -241,15 +239,15 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
/** /**
* Finds an insurance by ID from the available insurances array. * Finds an insurance by ID from the available insurances array.
* *
* @param array<Insurance> $insurances Array of available insurances * @param array<Insurance> $insurances Array of available insurances
* @param string|int $insuranceId The insurance ID to find * @param string $insuranceId The insurance ID to find
* *
* @return Insurance|null The found insurance or null if not found * @return Insurance|null The found insurance or null if not found
*/ */
private function findInsuranceById(array $insurances, string|int $insuranceId): ?Insurance private function findInsuranceById(array $insurances, string $insuranceId): ?Insurance
{ {
foreach ($insurances as $insurance) { foreach ($insurances as $insurance) {
if ($insurance->id === $insuranceId || (string) $insurance->id === (string) $insuranceId) { if ($insurance->id === $insuranceId) {
return $insurance; return $insurance;
} }
} }
@@ -268,7 +266,7 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
private function isInsuranceInList(Insurance $targetInsurance, array $insuranceList): bool private function isInsuranceInList(Insurance $targetInsurance, array $insuranceList): bool
{ {
foreach ($insuranceList as $insurance) { foreach ($insuranceList as $insurance) {
if ($insurance->id === $targetInsurance->id || (string) $insurance->id === (string) $targetInsurance->id) { if ($insurance->id === $targetInsurance->id) {
return true; return true;
} }
} }
@@ -282,14 +280,14 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
* This is used to distinguish between a new user selection and a form resubmission * This is used to distinguish between a new user selection and a form resubmission
* with the existing insurance selection (e.g., when user adds rentals that change travel price). * with the existing insurance selection (e.g., when user adds rentals that change travel price).
* *
* @param string|int $selectedInsuranceId The insurance ID from form submission * @param string $selectedInsuranceId The insurance ID from form submission
* @param Insurance $currentInsurance The currently assigned insurance from DTO * @param Insurance $currentInsurance The currently assigned insurance from DTO
* *
* @return bool True if they represent the same insurance * @return bool True if they represent the same insurance
*/ */
private function isSameInsurance(string|int $selectedInsuranceId, Insurance $currentInsurance): bool private function isSameInsurance(string $selectedInsuranceId, Insurance $currentInsurance): bool
{ {
return (string) $currentInsurance->id === (string) $selectedInsuranceId; return $currentInsurance->id === $selectedInsuranceId;
} }
/** /**
@@ -79,10 +79,9 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
* selection. If the skipass is no longer appropriate for the participant's * selection. If the skipass is no longer appropriate for the participant's
* 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
*/ */
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{ {
@@ -68,9 +68,6 @@ class ParticipantTransportationInboundFieldHandler extends AbstractParticipantFi
// Update participant with validated selection // Update participant with validated selection
$participant->transportationInbound = $validSelection; $participant->transportationInbound = $validSelection;
// Backward compatibility: also update deprecated property
$participant->transportationServiceFro = $validSelection;
} }
/** /**
@@ -68,9 +68,6 @@ class ParticipantTransportationOutboundFieldHandler extends AbstractParticipantF
// Update participant with validated selection // Update participant with validated selection
$participant->transportationOutbound = $validSelection; $participant->transportationOutbound = $validSelection;
// Backward compatibility: also update deprecated property
$participant->transportationServiceTo = $validSelection;
} }
/** /**
+1 -1
View File
@@ -75,7 +75,7 @@ class ParticipantCardDataService
$name = trim($firstName.' '.$lastName); $name = trim($firstName.' '.$lastName);
if ('' === $name) { if ('' === $name) {
return sprintf('Teilnehmer %d', $index + 1); return 0 === $index ? 'Anmelder:in' : sprintf('Teilnehmer:in %d', $index);
} }
return $name; return $name;
-14
View File
@@ -5,20 +5,6 @@
{% block content %} {% block content %}
<h1>Neue Buchung</h1> <h1>Neue Buchung</h1>
{% if bookingCreateDto.bookingStatus == 'A' %}
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<div class="flex items-start">
<svg class="w-5 h-5 text-amber-600 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<div>
<h3 class="text-amber-900 font-semibold">Achtung: Buchung auf Anfrage</h3>
<p class="text-amber-800 text-sm mt-1">Diese Buchung erfolgt auf Anfrage. Nach der Absendung wird deine Anfrage geprüft. Du erhältst anschließend eine verbindliche Buchungsbestätigung oder eine Absage per E-Mail.</p>
</div>
</div>
</div>
{% endif %}
<div id="form-wrapper"> <div id="form-wrapper">
{% include '_partials/_flashes.html.twig' %} {% include '_partials/_flashes.html.twig' %}
@@ -16,6 +16,7 @@ use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use App\Service\InsuranceMatchingService;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -30,7 +31,10 @@ class BookingDataProcessorTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->processor = new BookingDataProcessor(); // Create a mock InsuranceMatchingService
$insuranceMatchingService = $this->createMock(InsuranceMatchingService::class);
$this->processor = new BookingDataProcessor($insuranceMatchingService);
} }
public function testCreateUpdateRequestPayloadWithCompleteData(): void public function testCreateUpdateRequestPayloadWithCompleteData(): void