From 95d0ce33d432a706f185b5d64266a9eb27143993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 8 Jan 2026 16:42:42 +0100 Subject: [PATCH] feat: new service category 'VEG' --- src/BusProNet/Constants.php | 2 + .../DataProcessor/ServiceMappingCollector.php | 5 + src/Form/BookingParticipantType.php | 1 + src/Form/Model/ParticipantDto.php | 2 + src/Form/Service/CreateFieldStateProvider.php | 7 + src/Form/Service/EditFieldStateProvider.php | 5 + .../ParticipantFieldOptionsProvider.php | 46 ++++ .../Service/ParticipantVegFieldHandler.php | 132 +++++++++++ src/Service/ServicePricingCalculator.php | 18 +- templates/booking/_participant_form.html.twig | 16 +- .../ParticipantVegFieldHandlerTest.php | 215 ++++++++++++++++++ 11 files changed, 437 insertions(+), 12 deletions(-) create mode 100644 src/Form/Service/ParticipantVegFieldHandler.php create mode 100644 tests/Form/Service/ParticipantVegFieldHandlerTest.php diff --git a/src/BusProNet/Constants.php b/src/BusProNet/Constants.php index 81696ab..0614052 100644 --- a/src/BusProNet/Constants.php +++ b/src/BusProNet/Constants.php @@ -14,6 +14,7 @@ final class Constants public const TOKEN_SKI_PASS = 'SPA'; public const TOKEN_ADDITIONAL = 'SON'; public const TOKEN_BOARD = 'VPF'; + public const TOKEN_VEG = 'VEG'; public const TOKEN_RENTALS = ['VER', 'VE2', 'VE3', 'VE4', 'VE5', 'VE6', 'VE7', 'VE8']; public const TOKEN_RENTAL_INSURANCE = 'LVS'; public const TOKEN_INSURANCES = ['RRV', 'PAK', 'OHN', 'PKG']; @@ -37,6 +38,7 @@ final class Constants self::TOKEN_SKI_PASS => 'Skipässe', self::TOKEN_ADDITIONAL => 'Zusatzleistungen', self::TOKEN_BOARD => 'Verpflegung', + self::TOKEN_VEG => 'Verpflegungswunsch', self::TOKEN_RENTAL_INSURANCE => 'Leihmaterial-Versicherung', self::TOKEN_PARKING => 'Parkplatz', self::GROUP_TRANSPORTATION => 'Beförderung', diff --git a/src/BusProNet/DataProcessor/ServiceMappingCollector.php b/src/BusProNet/DataProcessor/ServiceMappingCollector.php index dd11119..48913ac 100644 --- a/src/BusProNet/DataProcessor/ServiceMappingCollector.php +++ b/src/BusProNet/DataProcessor/ServiceMappingCollector.php @@ -57,6 +57,11 @@ class ServiceMappingCollector $serviceMap[$board->id][] = $participantId; } + // Veg (vegetarian/vegan) preference + if (null !== $participant->veg) { + $serviceMap[$participant->veg->id][] = $participantId; + } + // Ski pass if (null !== $participant->skiPass) { $serviceMap[$participant->skiPass->id][] = $participantId; diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index 4d2c2f1..16244f8 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -334,6 +334,7 @@ class BookingParticipantType extends AbstractType 'courses' => ChoiceType::class, 'additionalServices' => ChoiceType::class, 'board' => ChoiceType::class, + 'veg' => ChoiceType::class, 'rentals' => ChoiceType::class, 'rentalInsurance' => CheckboxType::class, 'skiPass' => ChoiceType::class, diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 3118c09..f4e3155 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -25,6 +25,7 @@ class ParticipantDto 'courses', 'additionalServices', 'board', + 'veg', 'rentals', 'rentalInsurance', 'skiPass', @@ -89,6 +90,7 @@ class ParticipantDto public ?Service $skiPass = null; public array $board = []; + public ?Service $veg = null; public array $rentals = []; public ?Service $rentalInsurance = null; diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index 7d80f27..5d2f6aa 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -193,6 +193,13 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider ), ]; + $this->fieldStateConditions['veg'] = [ + 'hidden' => CompositeCondition::or( + CompositeCondition::not($dateOfBirthProvidedCondition), + $bookingEligibilityCondition + ), + ]; + // Bulk insurance booking conditions $bulkInsuranceBookingCondition = new BulkInsuranceBookingCondition(); diff --git a/src/Form/Service/EditFieldStateProvider.php b/src/Form/Service/EditFieldStateProvider.php index 67f1641..6a94907 100644 --- a/src/Form/Service/EditFieldStateProvider.php +++ b/src/Form/Service/EditFieldStateProvider.php @@ -144,6 +144,11 @@ class EditFieldStateProvider extends AbstractFieldStateProvider 'readonly' => $additionalServicesMutabilityCondition, ]; + $this->fieldStateConditions['veg'] = [ + 'hidden' => $hideUntilDobCondition, + 'readonly' => $additionalServicesMutabilityCondition, + ]; + // Skipass - hidden until birth date (except first participant), readonly if services not mutable $this->fieldStateConditions['skiPass'] = [ 'hidden' => $hideUntilDobCondition, diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 1303219..5fdcae0 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -211,6 +211,51 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider }, ]; + // Veg (vegetarian/vegan) field provider - provides dietary preference options as radio buttons (mutually exclusive) + $this->fieldOptionProviders['veg'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ + 'label' => 'Verpflegungswunsch', + 'multiple' => false, + 'expanded' => true, + 'required' => false, + 'choices' => $this->filterServicesByAgeConstraints( + $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_VEG, true), + $bookingDto, + $participantIndex + ), + 'choice_value' => 'id', + 'choice_label' => fn (?Service $service) => $service?->label, + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { + if (null === $service) { + return []; + } + + $attributes = []; + + // Add service description as data attribute for frontend use + if (null !== $service->description && '' !== trim($service->description)) { + $attributes['data-description'] = $service->description; + } + + // Check age restriction first (takes precedence over availability) + $ageEvaluator = new ServiceAgeEvaluator(); + if ($ageEvaluator->canEvaluate($service) + && false === $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex)) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = $this->getAgeRestrictionTooltip($service); + + return $attributes; + } + + // Make readonly if service is unavailable (intelligently handles edit mode) + if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'veg')) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + + return $attributes; + }, + ]; + // Rentals field provider - provides age-appropriate rental options filtered by selected skipass duration $this->fieldOptionProviders['rentals'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Leihmaterial', @@ -700,6 +745,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider 'courses' => $this->hasServiceById($participant->courses, $service->id), 'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id), 'board' => $this->hasServiceById($participant->board, $service->id), + 'veg' => $participant->veg?->id === $service->id, 'rentals' => $this->hasServiceById($participant->rentals, $service->id), 'skiPass' => $participant->skiPass?->id === $service->id, 'transportationOutbound' => $participant->transportationOutbound?->id === $service->id, diff --git a/src/Form/Service/ParticipantVegFieldHandler.php b/src/Form/Service/ParticipantVegFieldHandler.php new file mode 100644 index 0000000..2389545 --- /dev/null +++ b/src/Form/Service/ParticipantVegFieldHandler.php @@ -0,0 +1,132 @@ + $submittedData The submitted participant form data + * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) + * @param int $participantIndex The index of the participant being processed + * + * @return bool Always returns true for service selection fields + */ + public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool + { + return true; + } + + /** + * Processes the veg field for a specific participant. + * + * This method extracts the dietary preference selection from submitted form data, + * validates the selection against any age constraints if present, and updates the + * participant DTO with the valid selection. + * + * @param array $submittedData The submitted participant form data + * @param BookingDto $bookingDto The booking DTO to update (create or edit) + * @param int $participantIndex The index of the participant being processed + */ + public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void + { + $participant = $this->getParticipant($bookingDto, $participantIndex); + if (null === $participant) { + return; + } + + $selectedVeg = $this->getFieldValue($submittedData, $this->getFieldName()); + + $availableVegOptions = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_VEG, true); + + $validSelection = null; + if (null !== $selectedVeg) { + if ($this->isServiceValidForParticipant($selectedVeg, $availableVegOptions, $bookingDto, $participantIndex)) { + $validSelection = $this->findServiceInAvailableServices($selectedVeg, $availableVegOptions); + } + } + + $participant->veg = $validSelection; + } + + /** + * Validates if a selected veg option is still valid for the participant. + * + * This method checks age constraints if they exist for the service. + * + * @param mixed $selectedService The selected veg option to validate + * @param array $availableServices Array of available veg options + * @param BookingDto $bookingDto The booking DTO for context + * @param int $participantIndex The participant index for age evaluation + * + * @return bool True if the option is valid for the participant, false otherwise + */ + private function isServiceValidForParticipant( + mixed $selectedService, + array $availableServices, + BookingDto $bookingDto, + int $participantIndex, + ): bool { + $service = $this->findServiceInAvailableServices($selectedService, $availableServices); + + if (null === $service) { + return false; + } + + $ageEvaluator = new ServiceAgeEvaluator(); + if ($ageEvaluator->canEvaluate($service)) { + if (false === $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex)) { + return false; + } + } + + return true; + } +} diff --git a/src/Service/ServicePricingCalculator.php b/src/Service/ServicePricingCalculator.php index c4355cf..977349f 100644 --- a/src/Service/ServicePricingCalculator.php +++ b/src/Service/ServicePricingCalculator.php @@ -289,13 +289,17 @@ class ServicePricingCalculator array &$serviceAggregation, BookingDto $bookingDto, ): void { - // Handle single service selections (skiPass, rentalInsurance) + // Handle single service selections (skiPass, veg, rentalInsurance) if (null !== $participant->skiPass && null !== $participant->skiPass->price) { - $this->addToServiceAggregation($serviceAggregation, $participant->skiPass, 1); + $this->addToServiceAggregation($serviceAggregation, $participant->skiPass); + } + + if (null !== $participant->veg && null !== $participant->veg->price) { + $this->addToServiceAggregation($serviceAggregation, $participant->veg); } if (null !== $participant->rentalInsurance && null !== $participant->rentalInsurance->price) { - $this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance, 1); + $this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance); } // Resolve insurance: use price-tier-adjusted insurance for bulk assignment @@ -303,7 +307,7 @@ class ServicePricingCalculator $insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto); if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price && false === $insuranceToAggregate->isNoInsurance()) { - $this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate, 1); + $this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate); } // Handle multiple service selections @@ -318,7 +322,7 @@ class ServicePricingCalculator if (true === is_array($serviceArray)) { foreach ($serviceArray as $service) { if ($service instanceof Service && null !== $service->price) { - $this->addToServiceAggregation($serviceAggregation, $service, 1); + $this->addToServiceAggregation($serviceAggregation, $service); } } } @@ -328,7 +332,7 @@ class ServicePricingCalculator /** * Adds a service to the aggregation array, incrementing count and updating total price. */ - private function addToServiceAggregation(array &$serviceAggregation, Service $service, int $quantity): void + private function addToServiceAggregation(array &$serviceAggregation, Service $service, int $quantity = 1): void { $serviceKey = $service->id.'_'.$service->label; @@ -354,7 +358,7 @@ class ServicePricingCalculator * @param Insurance $insurance The insurance to add * @param int $quantity The quantity of the insurance */ - private function addInsuranceToServiceAggregation(array &$serviceAggregation, Insurance $insurance, int $quantity): void + private function addInsuranceToServiceAggregation(array &$serviceAggregation, Insurance $insurance, int $quantity = 1): void { $serviceKey = $insurance->id.'_'.$insurance->label; diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index eba5faa..4c2bac7 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -279,7 +279,6 @@ {# Room assignment - hidden until date of birth is provided, or shown as static text in edit mode #} {% if form.assignedRoomId is defined or is_static_text('assignedRoomId', bookingDto, participantIndex) %} -
{% set assignedRoom = bookingDto.travel.getRoomById(form.vars.data.participant.assignedRoomId) %} {% set roomLabel = assignedRoom ? assignedRoom.label : 'Kein Zimmer zugewiesen' %} @@ -298,7 +297,6 @@ {{ form_row(form.remarksRoom) }} {% endif %}
-
{% endif %} {# Service selection #} @@ -338,6 +336,17 @@ } }) }} + {# VEG field - only render when defined (hide completely when no VEG services available) #} + {% if form.veg is defined %} + {{ macros.service_field(form, 'veg', 'Verpflegungswunsch', { + 'attr': { + 'hx-trigger': htmx_change_trigger, + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }) }} + {% endif %} + {% set htmxAttr = { 'hx-trigger': htmx_change_trigger, 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), @@ -524,8 +533,6 @@ {% endif %} {% endif %} -
- {# Transportation Services Section #}

Anreise @@ -587,7 +594,6 @@ {# Voucher fields - available for all participants regardless of eligibility #} {% if form.purchaseVoucherCode is defined or form.promoVoucherCode is defined %} -

Gutscheine

diff --git a/tests/Form/Service/ParticipantVegFieldHandlerTest.php b/tests/Form/Service/ParticipantVegFieldHandlerTest.php new file mode 100644 index 0000000..f8bac71 --- /dev/null +++ b/tests/Form/Service/ParticipantVegFieldHandlerTest.php @@ -0,0 +1,215 @@ +handler = new ParticipantVegFieldHandler(); + } + + public function testGetFieldName(): void + { + $this->assertEquals('veg', $this->handler->getFieldName()); + } + + public function testGetDependencies(): void + { + $dependencies = $this->handler->getDependencies(); + + $this->assertEquals(['dateOfBirth'], $dependencies); + } + + public function testShouldProcessAlwaysReturnsTrue(): void + { + // Should always process to handle deselection cases + $this->assertTrue($this->handler->shouldProcess([], BookingDto::MODE_CREATE, 0)); + $this->assertTrue($this->handler->shouldProcess([], BookingDto::MODE_EDIT, 0)); + $this->assertTrue($this->handler->shouldProcess(['veg' => '123'], BookingDto::MODE_CREATE, 0)); + } + + public function testProcessFieldDoesNothingWhenNoParticipant(): void + { + $bookingDto = $this->createMockBookingDto(); + $bookingDto->method('getParticipants')->willReturn([]); + + $this->handler->processField(['veg' => '123'], $bookingDto, 0); + + // No assertions needed - just ensure no exceptions are thrown + $this->addToAssertionCount(1); + } + + public function testProcessFieldClearsVegWhenNullSelection(): void + { + $participant = new ParticipantDto(); + $participant->veg = $this->createService(123); + + $travel = $this->createMockTravel([]); + $bookingDto = $this->createMockBookingDto($travel); + $bookingDto->method('getParticipants')->willReturn([$participant]); + + $this->handler->processField(['veg' => null], $bookingDto, 0); + + $this->assertNull($participant->veg); + } + + public function testProcessFieldClearsVegWhenEmptyStringSelection(): void + { + $participant = new ParticipantDto(); + $participant->veg = $this->createService(123); + + $travel = $this->createMockTravel([]); + $bookingDto = $this->createMockBookingDto($travel); + $bookingDto->method('getParticipants')->willReturn([$participant]); + + $this->handler->processField(['veg' => ''], $bookingDto, 0); + + $this->assertNull($participant->veg); + } + + public function testProcessFieldClearsVegWhenMissingFromData(): void + { + $participant = new ParticipantDto(); + $participant->veg = $this->createService(123); + + $travel = $this->createMockTravel([]); + $bookingDto = $this->createMockBookingDto($travel); + $bookingDto->method('getParticipants')->willReturn([$participant]); + + $this->handler->processField([], $bookingDto, 0); + + $this->assertNull($participant->veg); + } + + public function testProcessFieldClearsVegWhenNotFoundInAvailableServices(): void + { + $participant = new ParticipantDto(); + $otherService = $this->createService(456); + + $travel = $this->createMockTravel([$otherService]); + $bookingDto = $this->createMockBookingDto($travel); + $bookingDto->method('getParticipants')->willReturn([$participant]); + + $this->handler->processField(['veg' => '123'], $bookingDto, 0); + + $this->assertNull($participant->veg); + } + + public function testProcessFieldSetsVegWhenAvailable(): void + { + $vegService = $this->createService(123); + $participant = new ParticipantDto(); + + $travel = $this->createMockTravel([$vegService]); + $bookingDto = $this->createMockBookingDto($travel); + $bookingDto->method('getParticipants')->willReturn([$participant]); + + $this->handler->processField(['veg' => '123'], $bookingDto, 0); + + $this->assertSame($vegService, $participant->veg); + } + + public function testProcessFieldWorksWithStringAndIntegerIds(): void + { + $vegService = $this->createService(123); // Integer ID + $participant = new ParticipantDto(); + + $travel = $this->createMockTravel([$vegService]); + $bookingDto = $this->createMockBookingDto($travel); + $bookingDto->method('getParticipants')->willReturn([$participant]); + + $this->handler->processField(['veg' => '123'], $bookingDto, 0); // String selection + + $this->assertSame($vegService, $participant->veg); + } + + public function testProcessFieldHandlesEmptyServicesArray(): void + { + $participant = new ParticipantDto(); + + $travel = $this->createMockTravel([]); + $bookingDto = $this->createMockBookingDto($travel); + $bookingDto->method('getParticipants')->willReturn([$participant]); + + $this->handler->processField(['veg' => '123'], $bookingDto, 0); + + $this->assertNull($participant->veg); + } + + public function testProcessFieldSelectsFromMultipleVegOptions(): void + { + $vegetarian = $this->createService(1, 'Vegetarisch'); + $vegan = $this->createService(2, 'Vegan'); + $participant = new ParticipantDto(); + + $travel = $this->createMockTravel([$vegetarian, $vegan]); + $bookingDto = $this->createMockBookingDto($travel); + $bookingDto->method('getParticipants')->willReturn([$participant]); + + $this->handler->processField(['veg' => '2'], $bookingDto, 0); + + $this->assertSame($vegan, $participant->veg); + } + + public function testGetFieldStateModificationsReturnsEmptyArray(): void + { + $bookingDto = $this->createMockBookingDto(); + + $result = $this->handler->getFieldStateModifications([], $bookingDto, 0); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testGetAffectedFieldNamesReturnsEmptyArray(): void + { + $result = $this->handler->getAffectedFieldNames(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + private function createService(int $id, string $label = 'Test Service'): Service + { + $service = new Service(); + $service->id = $id; + $service->label = $label; + $service->subType = Constants::TOKEN_VEG; + + return $service; + } + + private function createMockTravel(array $vegServices): Travel + { + $travel = $this->createMock(Travel::class); + $travel->method('getAdditionalServicesBySubTypes') + ->with(Constants::TOKEN_VEG, true) + ->willReturn($vegServices); + + return $travel; + } + + private function createMockBookingDto(?Travel $travel = null): BookingDto + { + $bookingDto = $this->createMock(BookingDto::class); + + if (null !== $travel) { + $bookingDto->travel = $travel; + } + + return $bookingDto; + } +}