wip: show pricing

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 6bd483d768
commit bd5c6359fe
19 changed files with 602 additions and 183 deletions
@@ -70,6 +70,7 @@ class CreateStep1Controller extends AbstractController
'bookingCreateDto' => $bookingCreateDto,
'roomSummary' => $summary['selectedRooms'],
'participantCount' => $summary['participantCount'],
'pricingData' => $summary['pricing'],
'form' => $form->createView(),
'groupedRooms' => $groupedRooms,
'groupedSelectedRooms' => $groupedSelectedRooms,
@@ -98,6 +99,7 @@ class CreateStep1Controller extends AbstractController
'bookingCreateDto' => $bookingCreateDto,
'roomSummary' => $summary['selectedRooms'],
'participantCount' => $summary['participantCount'],
'pricingData' => $summary['pricing'],
'groupedSelectedRooms' => $groupedSelectedRooms,
]);
}
@@ -62,6 +62,7 @@ class CreateStep2Controller extends AbstractController
return $this->redirectToRoute('app_booking_create_step_3');
}
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
$roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($bookingCreateDto);
$availableRooms = $bookingCreateDto->travel->getAvailableRooms();
$groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($bookingCreateDto->getSelectedRooms(), $availableRooms);
@@ -69,6 +70,7 @@ class CreateStep2Controller extends AbstractController
return $this->render('booking/create_step_2.html.twig', [
'bookingCreateDto' => $bookingCreateDto,
'participantsCount' => $participantsCount,
'pricingData' => $summary['pricing'],
'assignmentCounts' => $roomAssignmentCounts,
'form' => $form->createView(),
'groupedSelectedRooms' => $groupedSelectedRooms,
@@ -94,6 +96,7 @@ class CreateStep2Controller extends AbstractController
$form->handleRequest($request);
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
$roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($bookingCreateDto);
$availableRooms = $bookingCreateDto->travel->getAvailableRooms();
$groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($bookingCreateDto->getSelectedRooms(), $availableRooms);
@@ -107,6 +110,7 @@ class CreateStep2Controller extends AbstractController
'form' => $form->createView(),
'bookingCreateDto' => $bookingCreateDto,
'participantsCount' => $participantsCount,
'pricingData' => $summary['pricing'],
'assignmentCounts' => $roomAssignmentCounts,
'groupedSelectedRooms' => $groupedSelectedRooms,
]
+92 -107
View File
@@ -30,54 +30,18 @@ class BookingCreateParticipantType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('firstName', TextType::class, [
'label' => 'Vorname',
'clean_xss' => true,
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
'clean_xss' => true,
])
->add('dateOfBirth', BirthdayType::class, [
'label' => 'Geburtsdatum',
'html5' => true,
'widget' => 'single_text',
'input' => 'datetime_immutable',
])
->add('gender', ChoiceType::class, [
'label' => 'Geschlecht',
'required' => false,
'placeholder' => 'keine Angabe',
'choices' => [
'männlich' => 'M',
'weiblich' => 'W',
'divers' => 'D',
],
])
->add('nationality', CountryType::class, [
'label' => 'Nationalität',
'property' => 'nationality',
'preferred_choices' => ['D', 'A', 'CH'],
])
->add('email', EmailType::class, [
'label' => 'E-Mail',
'required' => false,
'clean_xss' => true,
])
->add('mobile', TextType::class, [
'label' => 'Telefon (mobil)',
'required' => false,
'clean_xss' => true,
])
->add('bodyDimensions', BodyDimensionsType::class)
->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'onPreSetData'])
->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onPreSubmit']);
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$this->onPreSetData($event);
})
->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
$this->onPreSubmit($event);
});
}
/**
* Adds dynamic fields to the form based on participant data.
*/
public function onPreSetData(FormEvent $event): void
private function onPreSetData(FormEvent $event): void
{
/** @var ParticipantDto|null $participantData */
$participantData = $event->getData();
@@ -94,14 +58,17 @@ class BookingCreateParticipantType extends AbstractType
return;
}
// Add base fields with states applied
$this->addBaseFields($form, $bookingDto, $participantData->index);
// Add dynamic fields
$this->addDynamicFields($form, $bookingDto, $participantData->index);
$this->applyFieldStates($form, $bookingDto, $participantData->index);
}
/**
* Handles form pre-submit events to update field states based on submitted data.
*/
public function onPreSubmit(FormEvent $event): void
private function onPreSubmit(FormEvent $event): void
{
$submittedData = $event->getData();
$form = $event->getForm();
@@ -123,59 +90,71 @@ class BookingCreateParticipantType extends AbstractType
return;
}
// Apply updated field states based on submitted data
$this->applyFieldStates($form, $bookingDto, $participantData->index, $submittedData);
// Rebuild all fields with updated states based on submitted data
$this->rebuildFieldsWithStates($form, $bookingDto, $participantData->index, $submittedData);
}
/**
* Applies dynamic field states to all form fields.
*
* This method checks each form field for state conditions and applies
* the appropriate state modifications (readonly, disabled, etc.).
* Fields that should be hidden are removed from the form entirely.
*
* @param FormInterface $form The form to modify
* @param BookingDtoInterface $bookingDto The booking data for context (create or edit)
* @param int $participantIndex The participant index
* @param array<string, mixed> $formData Optional submitted form data for state calculation
* Adds base fields to the form with field states applied.
*/
private function applyFieldStates(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): void
private function addBaseFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex): void
{
// First, remove fields that should be excluded entirely
$this->removeExcludedFields($form, $bookingDto, $participantIndex, $formData);
// Get field states for base fields
$allFieldStates = $this->fieldStateProvider->getAllFieldStates($bookingDto, $participantIndex);
// Get all fields that have state conditions (excluding hidden fields)
$allFieldStates = $this->fieldStateProvider->getAllFieldStates($bookingDto, $participantIndex, $formData);
// Helper to get field state or empty array
$getFieldState = fn (string $fieldName) => $allFieldStates[$fieldName] ?? [];
// Handle body dimension fields separately as they are nested in bodyDimensions form
$bodyDimensionFields = ['height', 'weight', 'shoeSize'];
$bodyDimensionStates = [];
$form
->add('firstName', TextType::class, $this->mergeFieldState([
'label' => 'Vorname',
'clean_xss' => true,
], $getFieldState('firstName')))
->add('lastName', TextType::class, $this->mergeFieldState([
'label' => 'Nachname',
'clean_xss' => true,
], $getFieldState('lastName')))
->add('dateOfBirth', BirthdayType::class, $this->mergeFieldState([
'label' => 'Geburtsdatum',
'html5' => true,
'widget' => 'single_text',
'input' => 'datetime_immutable',
], $getFieldState('dateOfBirth')))
->add('gender', ChoiceType::class, $this->mergeFieldState([
'label' => 'Geschlecht',
'required' => false,
'placeholder' => 'keine Angabe',
'choices' => [
'männlich' => 'M',
'weiblich' => 'W',
'divers' => 'D',
],
], $getFieldState('gender')))
->add('nationality', CountryType::class, $this->mergeFieldState([
'label' => 'Nationalität',
'property' => 'nationality',
'preferred_choices' => ['D', 'A', 'CH'],
], $getFieldState('nationality')))
->add('email', EmailType::class, $this->mergeFieldState([
'label' => 'E-Mail',
'required' => false,
'clean_xss' => true,
], $getFieldState('email')))
->add('mobile', TextType::class, $this->mergeFieldState([
'label' => 'Telefon (mobil)',
'required' => false,
'clean_xss' => true,
], $getFieldState('mobile')));
foreach ($allFieldStates as $fieldName => $fieldState) {
if (in_array($fieldName, $bodyDimensionFields, true)) {
// Collect body dimension field states for later processing
$bodyDimensionStates[$fieldName] = $fieldState;
continue;
}
if ($form->has($fieldName)) {
$field = $form->get($fieldName);
$currentOptions = $field->getConfig()->getOptions();
// Merge state into current options
$updatedOptions = $this->mergeFieldState($currentOptions, $fieldState);
// Remove and re-add the field with updated options
$fieldType = $field->getConfig()->getType()->getInnerType();
$form->remove($fieldName);
$form->add($fieldName, $fieldType::class, $updatedOptions);
// Add body dimensions with state handling
$bodyDimensionStates = ['height' => $getFieldState('height'), 'weight' => $getFieldState('weight'), 'shoeSize' => $getFieldState('shoeSize')];
$bodyDimensionOptions = [];
foreach ($bodyDimensionStates as $fieldName => $fieldState) {
if (isset($fieldState['required']) && true === $fieldState['required']) {
$bodyDimensionOptions[$fieldName.'_required'] = true;
}
}
// Apply body dimension field states to the nested bodyDimensions form
if (!empty($bodyDimensionStates) && $form->has('bodyDimensions')) {
$this->applyBodyDimensionStates($form, $bodyDimensionStates);
}
$form->add('bodyDimensions', BodyDimensionsType::class, $bodyDimensionOptions);
}
/**
@@ -209,30 +188,36 @@ class BookingCreateParticipantType extends AbstractType
}
/**
* Applies field states to body dimension fields in the nested bodyDimensions form.
*
* This method handles the special case of body dimension fields which are embedded
* in a nested form type. It converts field state requirements into options that
* can be passed to the BodyDimensionsType.
*
* @param FormInterface $form The parent form containing bodyDimensions
* @param array<string, array<string, mixed>> $bodyDimensionStates Field states for body dimension fields
* Rebuilds all fields with updated states based on submitted data.
*/
private function applyBodyDimensionStates(FormInterface $form, array $bodyDimensionStates): void
private function rebuildFieldsWithStates(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $submittedData): void
{
$bodyDimensionsField = $form->get('bodyDimensions');
$currentOptions = $bodyDimensionsField->getConfig()->getOptions();
// First, remove fields that should be excluded entirely
$this->removeExcludedFields($form, $bookingDto, $participantIndex, $submittedData);
// Convert field states to BodyDimensionsType options
foreach ($bodyDimensionStates as $fieldName => $fieldState) {
if (isset($fieldState['required']) && true === $fieldState['required']) {
$currentOptions[$fieldName.'_required'] = true;
// Clear the form and rebuild from scratch with updated states
// Rebuild base fields with updated states
$baseFields = ['firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile'];
foreach ($baseFields as $fieldName) {
if ($form->has($fieldName)) {
$form->remove($fieldName);
}
}
// Remove and re-add the bodyDimensions field with updated options
$form->remove('bodyDimensions');
$form->add('bodyDimensions', BodyDimensionsType::class, $currentOptions);
// Re-add base fields with updated states
$this->addBaseFields($form, $bookingDto, $participantIndex);
// Rebuild dynamic fields
$dynamicFields = ['assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'skiPass'];
foreach ($dynamicFields as $fieldName) {
if ($form->has($fieldName)) {
$form->remove($fieldName);
}
}
// Re-add dynamic fields
$this->addDynamicFields($form, $bookingDto, $participantIndex);
}
/**
@@ -261,7 +246,7 @@ class BookingCreateParticipantType extends AbstractType
$fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingDto, $participantIndex);
// Skip choice fields without any choices
if ($fieldType === ChoiceType::class && false === $this->hasValidFieldOptions($fieldOptions)) {
if (ChoiceType::class === $fieldType && false === $this->hasValidFieldOptions($fieldOptions)) {
continue;
}
+1
View File
@@ -14,6 +14,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class BookingCreateStep2Type extends AbstractType
{
public function __construct(
private readonly ParticipantFieldHandlerRegistry $participantFieldHandlerRegistry,
) {
+18 -9
View File
@@ -20,9 +20,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class BookingEditParticipantType extends AbstractType
{
public function __construct(
private readonly EditFieldStateProvider $fieldStateProvider,
) {
public function __construct(private readonly EditFieldStateProvider $fieldStateProvider)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -299,14 +298,24 @@ class BookingEditParticipantType extends AbstractType
$personalDataFields = [
'firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile',
];
// Re-apply field states by rebuilding fields with updated state
// Note: We need to store the field types and rebuild them since we cannot modify existing field configs
$fieldDefinitions = [
'firstName' => [TextType::class, ['label' => 'Vorname', 'clean_xss' => true]],
'lastName' => [TextType::class, ['label' => 'Nachname', 'clean_xss' => true]],
'dateOfBirth' => [BirthdayType::class, ['label' => 'Geburtsdatum', 'html5' => true, 'widget' => 'single_text', 'input' => 'datetime_immutable']],
'gender' => [ChoiceType::class, ['label' => 'Geschlecht', 'required' => false, 'placeholder' => 'keine Angabe', 'choices' => ['männlich' => 'M', 'weiblich' => 'W', 'divers' => 'D']]],
'nationality' => [CountryType::class, ['label' => 'Nationalität', 'property' => 'nationality', 'preferred_choices' => ['D', 'A', 'CH']]],
'email' => [EmailType::class, ['label' => 'E-Mail', 'required' => false, 'clean_xss' => true]],
'mobile' => [TextType::class, ['label' => 'Telefon (mobil)', 'required' => false, 'clean_xss' => true]],
];
foreach ($personalDataFields as $field) {
if ($form->has($field)) {
$fieldConfig = $form->get($field)->getConfig();
$currentOptions = $fieldConfig->getOptions();
$updatedOptions = $this->mergeFieldState($currentOptions, $getState($field));
$fieldType = $fieldConfig->getType()->getInnerType();
if ($form->has($field) && isset($fieldDefinitions[$field])) {
[$fieldType, $baseOptions] = $fieldDefinitions[$field];
$updatedOptions = $this->mergeFieldState($baseOptions, $getState($field));
$form->remove($field);
$form->add($field, $fieldType::class, $updatedOptions);
$form->add($field, $fieldType, $updatedOptions);
}
}
+10 -2
View File
@@ -17,12 +17,19 @@ class RoomSelectType extends AbstractType
{
$builder
->add('roomId', HiddenType::class)
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) {
$data = $event->getData();
$form = $event->getForm();
// Build label with pricing
$label = 'Anzahl ' . $data->roomLabel;
if (null !== $options['room_price']) {
$formattedPrice = number_format((float) $options['room_price'], 2, ',', '.');
$label .= sprintf(' (€%s pro Nacht)', $formattedPrice);
}
$form->add('quantity', ChoiceType::class, [
'label' => 'Anzahl '.$data->roomLabel,
'label' => $label,
'required' => true,
'choices' => ['-' => 0] + array_combine(range(1, $data->maxQuantity), range(1, $data->maxQuantity)),
]);
@@ -33,6 +40,7 @@ class RoomSelectType extends AbstractType
{
$resolver->setDefaults([
'data_class' => RoomSelectionDto::class,
'room_price' => null,
]);
}
}
@@ -39,10 +39,11 @@ abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInter
* @param string $fieldName The name of the field to configure
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior
*
* @return array<string, mixed> Symfony form field options, or empty array if field not supported
*/
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $options = []): array
{
// Check if we have a provider for this field
if (false === isset($this->fieldOptionProviders[$fieldName])) {
@@ -50,7 +51,7 @@ abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInter
}
// Execute the provider with current context to generate dynamic options
return $this->fieldOptionProviders[$fieldName]($bookingDto, $participantIndex);
return $this->fieldOptionProviders[$fieldName]($bookingDto, $participantIndex, $options);
}
/**
@@ -44,10 +44,11 @@ interface FieldOptionsProviderInterface
* @param string $fieldName The name of the field to configure
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior
*
* @return array<string, mixed> Symfony form field options, or empty array if field not supported
*/
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array;
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $options = []): array;
/**
* Checks whether a field has option provider support.
@@ -137,7 +137,11 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
foreach ($selectedServices as $selectedService) {
if ($this->isServiceValidForParticipant($selectedService, $availableServices, $bookingDto, $participantIndex)) {
$validSelections[] = $selectedService;
// Convert the selected service ID/data back to the actual Service object
$serviceObject = $this->findServiceInAvailableServices($selectedService, $availableServices);
if (null !== $serviceObject) {
$validSelections[] = $serviceObject;
}
}
}
@@ -79,7 +79,11 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler
foreach ($selectedServices as $selectedService) {
if ($this->isServiceValidForParticipant($selectedService, $availableServices, $bookingDto, $participantIndex)) {
$validSelections[] = $selectedService;
// Convert the selected service ID/data back to the actual Service object
$serviceObject = $this->findServiceInAvailableServices($selectedService, $availableServices);
if (null !== $serviceObject) {
$validSelections[] = $serviceObject;
}
}
}
@@ -126,7 +126,11 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
foreach ($selectedServices as $selectedService) {
if ($this->isServiceValidForParticipant($selectedService, $availableServices, $bookingDto, $participantIndex)) {
$validSelections[] = $selectedService;
// Convert the selected service ID/data back to the actual Service object
$serviceObject = $this->findServiceInAvailableServices($selectedService, $availableServices);
if (null !== $serviceObject) {
$validSelections[] = $serviceObject;
}
}
}
@@ -6,6 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Contract\ParticipantFieldHandlerInterface;
/**
@@ -17,7 +18,7 @@ use App\Form\Service\Contract\ParticipantFieldHandlerInterface;
*/
class ParticipantFieldHandlerRegistry
{
/** @var ParticipantFieldHandlerInterface[] Registered handlers indexed by field name */
/** @var array<string, ParticipantFieldHandlerInterface> Registered handlers indexed by field name */
private array $handlers = [];
/** @var string[]|null Cached array of handler names sorted by dependency order */
@@ -274,11 +275,11 @@ class ParticipantFieldHandlerRegistry
* been processed by checking against registered handlers.
*
* @param array<string, mixed> $participantData The submitted participant data
* @param object $participant The cleaned participant DTO
* @param ParticipantDto $participant The cleaned participant DTO
*
* @return array<string, mixed> Updated participant data with synchronized field values
*/
private function syncParticipantData(array $participantData, object $participant): array
private function syncParticipantData(array $participantData, ParticipantDto $participant): array
{
// Sync fields for all registered handlers
foreach ($this->handlers as $fieldName => $handler) {
@@ -69,7 +69,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
protected function registerFieldOptionProviders(): void
{
// Room assignment field provider (only available for create workflow)
$this->fieldOptionProviders['assignedRoomId'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$this->fieldOptionProviders['assignedRoomId'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Zimmer',
'placeholder' => 'Bitte wählen',
// Use factory to create context-aware choice loader that:
@@ -86,7 +86,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
// Courses field provider - provides age-appropriate courses from travel data
$this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Kurse',
'multiple' => true,
'expanded' => true,
@@ -97,11 +97,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
];
// Additional services field provider - provides age-appropriate additional services with mandatory pre-selection
$this->fieldOptionProviders['additionalServices'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$this->fieldOptionProviders['additionalServices'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Zusatzleistungen',
'multiple' => true,
'expanded' => true,
@@ -112,7 +112,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
'choice_attr' => function (?Service $service) {
if (null === $service) {
return [];
@@ -133,7 +133,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
// Board field provider - provides age-appropriate board options from travel data
$this->fieldOptionProviders['board'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$this->fieldOptionProviders['board'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Verpflegung',
'multiple' => true,
'expanded' => true,
@@ -144,11 +144,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
];
// Rentals field provider - provides age-appropriate rental options from travel data filtered by date range
$this->fieldOptionProviders['rentals'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$this->fieldOptionProviders['rentals'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Leihmaterial',
'multiple' => true,
'expanded' => true,
@@ -159,11 +159,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
];
// Skipass field provider - provides age-appropriate skipass options from travel data filtered by date range
$this->fieldOptionProviders['skiPass'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$this->fieldOptionProviders['skiPass'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Skipass',
'multiple' => false,
'expanded' => true,
@@ -174,11 +174,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
];
// Room remarks field provider - provides textarea for room-specific remarks (only for 'mbz' rooms)
$this->fieldOptionProviders['remarksRoom'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$this->fieldOptionProviders['remarksRoom'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Wünsche oder Anmerkungen zum Zimmer',
'required' => false,
'clean_xss' => true,
@@ -199,6 +199,26 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// ];
}
/**
* Formats service label with pricing information.
*
* @param Service|null $service The service to format
*
* @return string The formatted label
*/
private function formatServiceLabelWithPrice(?Service $service): string
{
if (null === $service) {
return '';
}
if (null === $service->price || 0.0 === $service->price) {
return $service->label;
}
return sprintf('%s (€%s)', $service->label, number_format($service->price, 2, ',', '.'));
}
/**
* Filters services based on participant's age constraints.
*
@@ -79,7 +79,11 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler
foreach ($selectedServices as $selectedService) {
if ($this->isServiceValidForParticipant($selectedService, $availableServices, $bookingDto, $participantIndex)) {
$validSelections[] = $selectedService;
// Convert the selected service ID/data back to the actual Service object
$serviceObject = $this->findServiceInAvailableServices($selectedService, $availableServices);
if (null !== $serviceObject) {
$validSelections[] = $serviceObject;
}
}
}
@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\ParticipantDto;
/**
* Calculates pricing for booking components including rooms and services.
*
* This service provides comprehensive pricing calculations for the booking system,
* handling room pricing based on quantities and service pricing per participant.
* It returns structured pricing data for display in forms and summaries.
*/
class BookingPriceCalculatorService
{
/**
* Calculates comprehensive pricing breakdown for a booking.
*
* @param BookingDtoInterface $bookingDto The booking data to calculate pricing for
*
* @return array{rooms: array, services: array, grandTotal: float} Complete pricing breakdown
*/
public function getPricingBreakdown(BookingDtoInterface $bookingDto): array
{
$roomPricing = $this->calculateRoomPricing($bookingDto);
$servicePricing = $this->calculateServicePricing($bookingDto);
$grandTotal = $this->calculateGrandTotal($bookingDto);
return [
'rooms' => $roomPricing,
'services' => $servicePricing,
'grandTotal' => $grandTotal,
];
}
/**
* Calculates pricing for all selected rooms.
*
* @param BookingDtoInterface $bookingDto The booking data containing room selections
*
* @return array Array of room pricing data with labels, quantities, and totals
*/
public function calculateRoomPricing(BookingDtoInterface $bookingDto): array
{
$roomPricing = [];
if (false === $bookingDto instanceof BookingCreateDto) {
return $roomPricing;
}
$selectedRooms = $bookingDto->getSelectedRooms();
if (true === empty($selectedRooms)) {
return $roomPricing;
}
foreach ($selectedRooms as $roomSelection) {
$room = $this->getRoomById($bookingDto, $roomSelection->roomId);
if (null === $room || null === $room->price) {
continue;
}
$totalPrice = $roomSelection->quantity * $room->price;
$roomPricing[] = [
'roomId' => $room->id,
'label' => $room->label,
'quantity' => $roomSelection->quantity,
'unitPrice' => $room->price,
'totalPrice' => $totalPrice,
];
}
return $roomPricing;
}
/**
* Calculates pricing for all selected services across all participants, grouped by subtype.
*
* @param BookingDtoInterface $bookingDto The booking data containing participants and their service selections
*
* @return array Array of service groups with each group containing services of the same subtype
*/
public function calculateServicePricing(BookingDtoInterface $bookingDto): array
{
$participants = $bookingDto->getParticipants();
if (true === empty($participants)) {
return [];
}
// Aggregate service selections across all participants
$serviceAggregation = [];
foreach ($participants as $participant) {
$this->aggregateParticipantServices($participant, $serviceAggregation);
}
// Group services by subtype and convert to pricing format
return $this->groupServicesBySubtype($serviceAggregation);
}
/**
* Calculates the grand total for the entire booking.
*
* @param BookingDtoInterface $bookingDto The booking data to calculate total for
*
* @return float The grand total price
*/
public function calculateGrandTotal(BookingDtoInterface $bookingDto): float
{
$roomTotal = $this->calculateRoomTotal($bookingDto);
$serviceTotal = $this->calculateServiceTotal($bookingDto);
return $roomTotal + $serviceTotal;
}
/**
* Calculates total price for all rooms.
*/
public function calculateRoomTotal(BookingDtoInterface $bookingDto): float
{
$roomPricing = $this->calculateRoomPricing($bookingDto);
return array_sum(array_column($roomPricing, 'totalPrice'));
}
/**
* Calculates total price for all services.
*/
public function calculateServiceTotal(BookingDtoInterface $bookingDto): float
{
$servicePricing = $this->calculateServicePricing($bookingDto);
return array_sum(array_column($servicePricing, 'groupTotal'));
}
/**
* Formats a price value for display with proper German formatting.
*
* @param float $price The price to format
*
* @return string Formatted price string (e.g., "123,45")
*/
public function formatPrice(float $price): string
{
return number_format($price, 2, ',', '.');
}
/**
* Formats a price with Euro symbol for display.
*
* @param float $price The price to format
*
* @return string Formatted price string with Euro symbol (e.g., "€123,45")
*/
public function formatPriceWithSymbol(float $price): string
{
return '€'.$this->formatPrice($price);
}
/**
* Groups services by their subtypes for display.
*
* @param array $serviceAggregation Aggregated service data
*
* @return array Grouped services by subtype
*/
private function groupServicesBySubtype(array $serviceAggregation): array
{
$groupedServices = [];
foreach ($serviceAggregation as $serviceData) {
if ($serviceData['totalPrice'] <= 0) {
continue;
}
$subType = $serviceData['subType'] ?? 'other';
$groupName = $this->getGroupNameForSubtype($subType);
if (false === isset($groupedServices[$groupName])) {
$groupedServices[$groupName] = [
'groupName' => $groupName,
'services' => [],
'groupTotal' => 0.0,
];
}
$groupedServices[$groupName]['services'][] = $serviceData;
$groupedServices[$groupName]['groupTotal'] += $serviceData['totalPrice'];
}
return array_values($groupedServices);
}
/**
* Maps service subtypes to user-friendly group names.
*/
private function getGroupNameForSubtype(string $subType): string
{
$groupMapping = [
Constants::TOKEN_COURSES => 'Kurse',
Constants::TOKEN_SKI_PASS => 'Skipässe',
Constants::TOKEN_ADDITIONAL => 'Zusatzleistungen',
Constants::TOKEN_BOARD => 'Verpflegung',
];
// Handle rentals array
if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) {
return 'Leihmaterial';
}
return $groupMapping[$subType] ?? 'Sonstige Leistungen';
}
/**
* Aggregates service selections from a single participant into the service aggregation array.
*/
private function aggregateParticipantServices(ParticipantDto $participant, array &$serviceAggregation): void
{
// Handle single service selections (skiPass)
if (null !== $participant->skiPass && null !== $participant->skiPass->price) {
$this->addToServiceAggregation($serviceAggregation, $participant->skiPass, 1);
}
// Handle multiple service selections
$multipleServiceArrays = [
'courses' => $participant->courses,
'additionalServices' => $participant->additionalServices,
'board' => $participant->board,
'rentals' => $participant->rentals,
];
foreach ($multipleServiceArrays as $serviceArray) {
if (true === is_array($serviceArray)) {
foreach ($serviceArray as $service) {
if ($service instanceof Service && null !== $service->price) {
$this->addToServiceAggregation($serviceAggregation, $service, 1);
}
}
}
}
}
/**
* Adds a service to the aggregation array, incrementing count and updating total price.
*/
private function addToServiceAggregation(array &$serviceAggregation, Service $service, int $quantity): void
{
$serviceKey = $service->id.'_'.$service->label;
if (false === isset($serviceAggregation[$serviceKey])) {
$serviceAggregation[$serviceKey] = [
'serviceId' => $service->id,
'label' => $service->label,
'unitPrice' => $service->price,
'participantCount' => 0,
'totalPrice' => 0.0,
'subType' => $service->subType,
];
}
$serviceAggregation[$serviceKey]['participantCount'] += $quantity;
$serviceAggregation[$serviceKey]['totalPrice'] += $service->price * $quantity;
}
/**
* Retrieves a room by ID from the booking's travel data.
*/
private function getRoomById(BookingCreateDto $bookingDto, ?int $roomId): ?Room
{
if (null === $roomId) {
return null;
}
foreach ($bookingDto->travel->rooms as $room) {
if ($room->id === $roomId) {
return $room;
}
}
return null;
}
}
+5 -2
View File
@@ -16,6 +16,7 @@ class BookingService
public function __construct(
private readonly TravelDataService $travelDataService,
private readonly BookingPriceCalculatorService $priceCalculator,
) {
}
@@ -156,18 +157,20 @@ class BookingService
}
/**
* Returns a summary of selected rooms and the resulting participant count for a booking.
* Returns a summary of selected rooms, participant count, and pricing information for a booking.
*
* @return array{selectedRooms: array, participantCount: int}
* @return array{selectedRooms: array, participantCount: int, pricing: array}
*/
public function getRoomSummaryAndParticipantCount(BookingCreateDto $bookingCreateDto): array
{
$selectedRooms = $bookingCreateDto->getSelectedRooms();
$participantCount = $this->getParticipantsCount($selectedRooms, $bookingCreateDto->travel);
$pricing = $this->priceCalculator->getPricingBreakdown($bookingCreateDto);
return [
'selectedRooms' => $selectedRooms,
'participantCount' => $participantCount,
'pricing' => $pricing,
];
}
+72 -32
View File
@@ -1,32 +1,72 @@
<h3>Zusammenfassung</h3>
<p>Reise: {{ bookingCreateDto.travel.label }}</p>
<p>Datum: {{ bookingCreateDto.travel.dateFrom|date('d.m.Y') }} - {{ bookingCreateDto.travel.dateTo|date('d.m.Y') }}</p>
<p>Hotel: {{ bookingCreateDto.travel.hotel.name }}</p>
{% if groupedSelectedRooms.by_room is not empty %}
<h4>Zimmer</h4>
<ul class="list-disc pl-4 pb-4">
{% for roomSelection in groupedSelectedRooms.by_room %}
<li>
{{ roomSelection.quantity }} x {{ roomSelection.roomLabel }}
{% if assignmentCounts is defined and assignmentCounts[roomSelection.roomId] is defined %}
<span class="block text-sm">{{ assignmentCounts[roomSelection.roomId] }} belegt</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% if groupedSelectedRooms.by_pax is not empty %}
<h4>Betten</h4>
<ul class="list-disc pl-4 pb-4">
{% for roomSelection in groupedSelectedRooms.by_pax %}
<li>
{{ roomSelection.quantity }} x {{ roomSelection.roomLabel }}
{% if assignmentCounts is defined and assignmentCounts[roomSelection.roomId] is defined %}
<span class="block text-sm">{{ assignmentCounts[roomSelection.roomId] }} belegt</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
<h4>Anzahl Teilnehmer</h4>
<p>{{ participantCount }}</p>
<div class="booking-summary p-8 bg-gray-50 rounded-lg border sticky top-4">
<h3 class="font-bold text-xl mb-6 text-gray-800">Buchungsübersicht</h3>
{# Travel Information #}
<div class="mb-6 pb-4 border-b border-gray-200">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Reiseinformationen</h4>
<div class="space-y-2 text-sm text-gray-600">
<div><span class="font-medium">Reise:</span> {{ bookingCreateDto.travel.label }}</div>
<div><span class="font-medium">Zeitraum:</span> {{ bookingCreateDto.travel.dateFrom|date('d.m.Y') }} - {{ bookingCreateDto.travel.dateTo|date('d.m.Y') }}</div>
<div><span class="font-medium">Hotel:</span> {{ bookingCreateDto.travel.hotel.name }}</div>
<div><span class="font-medium">Teilnehmer:</span> {{ participantCount }}</div>
</div>
</div>
{# Rooms Section #}
{% if pricingData.rooms is not empty %}
<div class="mb-6 pb-4 border-b border-gray-200">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Unterkunft</h4>
<div class="space-y-2">
{% for roomPricing in pricingData.rooms %}
<div class="flex justify-between items-center">
<div class="text-sm text-gray-600">
<span class="font-medium">{{ roomPricing.quantity }}x {{ roomPricing.label }}</span>
{% if assignmentCounts is defined and assignmentCounts[roomPricing.roomId] is defined %}
<span class="block text-xs text-gray-500">{{ assignmentCounts[roomPricing.roomId] }} belegt</span>
{% endif %}
</div>
<span class="text-gray-900">
{{ roomPricing.totalPrice|number_format(2, ',', '.') }}
</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# Services Section #}
{% if pricingData.services is not empty %}
<div class="mb-6">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Leistungen</h4>
{% for serviceGroup in pricingData.services %}
<div class="mb-4 last:mb-0">
<h5 class="font-medium text-sm text-gray-700 mb-2 uppercase tracking-wide">{{ serviceGroup.groupName }}</h5>
<div class="space-y-1 ml-4">
{% for servicePricing in serviceGroup.services %}
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600">
{{ servicePricing.participantCount }}x {{ servicePricing.label }}
</span>
<span class="font-medium text-gray-900">
{{ servicePricing.totalPrice|number_format(2, ',', '.') }}
</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{# Total Section #}
{% if pricingData.grandTotal is defined and pricingData.grandTotal > 0 %}
<div class="pt-4 border-t-2 border-gray-300">
<div class="flex justify-between items-center">
<span class="font-bold text-lg text-gray-800">Gesamtpreis:</span>
<span class="font-bold text-xl text-gray-900">
{{ pricingData.grandTotal|number_format(2, ',', '.') }}
</span>
</div>
</div>
{% endif %}
</div>
+37 -7
View File
@@ -27,9 +27,9 @@
<div class="grid grid-cols-2 gap-4 pb-4">
{{ form_row(participant.firstName) }}
{{ form_row(participant.lastName) }}
{# hx-swap="none" tells HTMX not to do a normal swap, as OOB will handle it #}
{{ form_row(participant.dateOfBirth, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_refresh'),
'hx-swap': 'none'
}
@@ -47,9 +47,9 @@
{{ form_row(participant.bodyDimensions.weight) }}
</div>
<div class="grid grid-cols-2 gap-4">
{# hx-swap="none" tells HTMX not to do a normal swap, as OOB will handle it #}
{{ form_row(participant.assignedRoomId, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_refresh'),
'hx-swap': 'none'
}
@@ -60,19 +60,49 @@
</div>
<div class="grid grid-cols-2 gap-4">
{% if participant.skiPass is defined %}
{{ form_row(participant.skiPass) }}
{{ form_row(participant.skiPass, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_refresh'),
'hx-swap': 'none'
}
}) }}
{% endif %}
{% if participant.courses is defined %}
{{ form_row(participant.courses) }}
{{ form_row(participant.courses, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_refresh'),
'hx-swap': 'none'
}
}) }}
{% endif %}
{% if participant.additionalServices is defined %}
{{ form_row(participant.additionalServices) }}
{{ form_row(participant.additionalServices, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_refresh'),
'hx-swap': 'none'
}
}) }}
{% endif %}
{% if participant.rentals is defined %}
{{ form_row(participant.rentals) }}
{{ form_row(participant.rentals, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_refresh'),
'hx-swap': 'none'
}
}) }}
{% endif %}
{% if participant.board is defined %}
{{ form_row(participant.board) }}
{{ form_row(participant.board, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_refresh'),
'hx-swap': 'none'
}
}) }}
{% endif %}
</div>
</div>
+10 -2
View File
@@ -111,7 +111,7 @@
{{ form_widget(form) }}
</div>
<div class="{{ html_classes('ml-2 leading-6', { 'text-red-700': errors|length }) }}">
{{ form.vars.label | raw }}
{{ form.vars.label | raw }}
{{- form_errors(form) -}}
</div>
</label>
@@ -137,7 +137,15 @@
{%- block choice_widget_expanded -%}
{%- for child in form %}
{{- form_row(child) -}}
{%- set child_attr = {} -%}
{%- if attr['hx-trigger'] is defined -%}
{%- set child_attr = child_attr|merge({
'hx-trigger': attr['hx-trigger'],
'hx-post': attr['hx-post'],
'hx-swap': attr['hx-swap']
}) -%}
{%- endif -%}
{{- form_row(child, { 'attr': child_attr }) -}}
{% endfor -%}
{%- endblock choice_widget_expanded -%}