From 331c88c38eb237ccceafcf43cd4d01add5f196c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 4 Aug 2026 10:24:29 +0200 Subject: [PATCH] feat: exclusion groups for additional services --- .../service_exclusion_controller.js | 33 +++++ migrations/Version20260804120000.php | 28 ++++ src/Controller/Groups/Step2Controller.php | 1 + src/Entity/Groups/AdditionalService.php | 30 ++++ src/Form/AccommodationStep2Type.php | 21 ++- .../Admin/Groups/AdditionalServiceType.php | 10 ++ .../AdditionalServiceExclusionResolver.php | 91 +++++++++++++ .../admin/additional_service/_form.html.twig | 4 + templates/groups/booking/step_2.html.twig | 16 +++ tests/Form/AccommodationStep2TypeTest.php | 112 ++++++++++++--- ...AdditionalServiceExclusionResolverTest.php | 128 ++++++++++++++++++ 11 files changed, 457 insertions(+), 17 deletions(-) create mode 100644 assets/controllers/service_exclusion_controller.js create mode 100644 migrations/Version20260804120000.php create mode 100644 src/Service/AdditionalServiceExclusionResolver.php create mode 100644 tests/Service/AdditionalServiceExclusionResolverTest.php diff --git a/assets/controllers/service_exclusion_controller.js b/assets/controllers/service_exclusion_controller.js new file mode 100644 index 0000000..0897142 --- /dev/null +++ b/assets/controllers/service_exclusion_controller.js @@ -0,0 +1,33 @@ +import { Controller } from '@hotwired/stimulus' + +/** + * Unchecks conflicting additional services as soon as one is selected, so the user sees the + * result immediately instead of waiting for the debounced htmx refresh. The server resolves + * the same conflicts again and stays the source of truth. + */ +export default class extends Controller { + + static targets = [ 'input' ] + + select(event) { + const input = event.target + + if (!input.checked) { + return + } + + const group = input.dataset.exclusionGroup + + if (!group) { + return + } + + const exclusive = '1' === input.dataset.exclusive + + this.inputTargets + .filter(other => other !== input) + .filter(other => other.dataset.exclusionGroup === group) + .filter(other => exclusive || '1' === other.dataset.exclusive) + .forEach(other => { other.checked = false }) + } +} diff --git a/migrations/Version20260804120000.php b/migrations/Version20260804120000.php new file mode 100644 index 0000000..862346b --- /dev/null +++ b/migrations/Version20260804120000.php @@ -0,0 +1,28 @@ +addSql('ALTER TABLE additional_service ADD exclusion_group VARCHAR(128) DEFAULT NULL, ADD is_exclusive TINYINT(1) DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE additional_service ALTER COLUMN is_exclusive DROP DEFAULT'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE additional_service DROP exclusion_group, DROP is_exclusive'); + } +} diff --git a/src/Controller/Groups/Step2Controller.php b/src/Controller/Groups/Step2Controller.php index 7506507..a3a89e8 100644 --- a/src/Controller/Groups/Step2Controller.php +++ b/src/Controller/Groups/Step2Controller.php @@ -92,6 +92,7 @@ class Step2Controller extends AbstractAccommodationController $form = $this->createForm(AccommodationStep2Type::class, $dto, [ 'board_service_choices' => $this->buildServiceChoices($services['boardServices']), 'additional_service_choices' => $this->buildServiceChoices($services['additionalServices']), + 'additional_services' => $services['additionalServices'], 'max_adolescent_age' => (int) $accommodation->getMaxAdolescentAge(), 'validation_groups' => $validate ? ['step_2'] : false, ]); diff --git a/src/Entity/Groups/AdditionalService.php b/src/Entity/Groups/AdditionalService.php index 097571f..74a6ee3 100644 --- a/src/Entity/Groups/AdditionalService.php +++ b/src/Entity/Groups/AdditionalService.php @@ -53,6 +53,12 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity #[ORM\Column(length: 128, nullable: true)] private ?string $selectionGroup = null; + #[ORM\Column(length: 128, nullable: true)] + private ?string $exclusionGroup = null; + + #[ORM\Column] + private bool $isExclusive = false; + public function __clone() { $this->id = null; @@ -158,4 +164,28 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity return $this; } + + public function getExclusionGroup(): ?string + { + return $this->exclusionGroup; + } + + public function setExclusionGroup(?string $exclusionGroup): self + { + $this->exclusionGroup = $exclusionGroup; + + return $this; + } + + public function isExclusive(): bool + { + return $this->isExclusive; + } + + public function setIsExclusive(bool $isExclusive): self + { + $this->isExclusive = $isExclusive; + + return $this; + } } diff --git a/src/Form/AccommodationStep2Type.php b/src/Form/AccommodationStep2Type.php index ff8665b..41f35e9 100644 --- a/src/Form/AccommodationStep2Type.php +++ b/src/Form/AccommodationStep2Type.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\Form; +use App\Entity\Groups\AdditionalService; use App\Form\Model\AccommodationBookingDto; +use App\Service\AdditionalServiceExclusionResolver; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; @@ -18,6 +20,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver; */ class AccommodationStep2Type extends AbstractType { + public function __construct( + private readonly AdditionalServiceExclusionResolver $exclusionResolver, + ) { + } + public function buildForm(FormBuilderInterface $builder, array $options): void { $builder @@ -51,7 +58,9 @@ class AccommodationStep2Type extends AbstractType ]) ; - $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void { + $availableServices = $options['additional_services']; + + $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($availableServices): void { $data = $event->getData(); if (!is_array($data)) { @@ -64,6 +73,14 @@ class AccommodationStep2Type extends AbstractType } } + $dto = $event->getForm()->getData(); + + $data['selectedAdditionalServiceIds'] = $this->exclusionResolver->resolve( + $availableServices, + array_map('intval', (array) ($data['selectedAdditionalServiceIds'] ?? [])), + $dto instanceof AccommodationBookingDto ? $dto->selectedAdditionalServiceIds : [], + ); + $event->setData($data); }); } @@ -75,8 +92,10 @@ class AccommodationStep2Type extends AbstractType 'validation_groups' => ['step_2'], 'board_service_choices' => [], 'additional_service_choices' => [], + 'additional_services' => [], 'max_adolescent_age' => 0, ]); $resolver->setAllowedTypes('max_adolescent_age', 'int'); + $resolver->setAllowedTypes('additional_services', AdditionalService::class . '[]'); } } diff --git a/src/Form/Admin/Groups/AdditionalServiceType.php b/src/Form/Admin/Groups/AdditionalServiceType.php index d9dacb1..8884045 100644 --- a/src/Form/Admin/Groups/AdditionalServiceType.php +++ b/src/Form/Admin/Groups/AdditionalServiceType.php @@ -7,6 +7,7 @@ namespace App\Form\Admin\Groups; use App\Entity\Groups\AdditionalService; use App\Enum\Groups\AdditionalServiceType as AdditionalServiceTypeEnum; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\MoneyType; @@ -46,6 +47,15 @@ class AdditionalServiceType extends AbstractType 'required' => false, 'disabled' => true, ]) + ->add('exclusionGroup', TextType::class, [ + 'label' => 'Ausschlussgruppe', + 'required' => false, + 'help' => 'Leistungen mit derselben Ausschlussgruppe können sich gegenseitig abwählen.', + ]) + ->add('isExclusive', CheckboxType::class, [ + 'label' => 'Schließt andere Leistungen dieser Gruppe aus', + 'required' => false, + ]) ->add('dateFrom', DateType::class, [ 'label' => 'Datum von', 'html5' => true, diff --git a/src/Service/AdditionalServiceExclusionResolver.php b/src/Service/AdditionalServiceExclusionResolver.php new file mode 100644 index 0000000..3a55b2e --- /dev/null +++ b/src/Service/AdditionalServiceExclusionResolver.php @@ -0,0 +1,91 @@ + $submittedIds keys are preserved, grouped radios submit under their group name + * @param array $previousIds + * + * @return array + */ + public function resolve(array $availableServices, array $submittedIds, array $previousIds): array + { + $rules = $this->buildRules($availableServices); + + if ([] === $rules) { + return $submittedIds; + } + + $added = array_values(array_diff($submittedIds, $previousIds)); + + foreach ($added as $addedId) { + if (!isset($rules[$addedId])) { + continue; + } + + $submittedIds = array_filter( + $submittedIds, + fn(int $id) => !$this->conflicts($rules, $addedId, $id), + ); + } + + return $submittedIds; + } + + /** + * @param array $rules + */ + private function conflicts(array $rules, int $a, int $b): bool + { + if ($a === $b || !isset($rules[$b])) { + return false; + } + + if ($rules[$a]['group'] !== $rules[$b]['group']) { + return false; + } + + return $rules[$a]['exclusive'] || $rules[$b]['exclusive']; + } + + /** + * @param AdditionalService[] $availableServices + * + * @return array + */ + private function buildRules(array $availableServices): array + { + $rules = []; + + foreach ($availableServices as $service) { + $group = $service->getExclusionGroup(); + $id = $service->getId(); + + if (null === $id || null === $group || '' === $group) { + continue; + } + + $rules[$id] = ['group' => $group, 'exclusive' => $service->isExclusive()]; + } + + return $rules; + } +} diff --git a/templates/admin/additional_service/_form.html.twig b/templates/admin/additional_service/_form.html.twig index 663e207..91d0dc2 100644 --- a/templates/admin/additional_service/_form.html.twig +++ b/templates/admin/additional_service/_form.html.twig @@ -9,6 +9,10 @@ {{ form_row(form.price) }} {{ form_row(form.selectionGroup) }} + {{ form_row(form.exclusionGroup) }} +
+ {{ form_row(form.isExclusive) }} +
{{ form_row(form.dateFrom) }} {{ form_row(form.dateTo) }} diff --git a/templates/groups/booking/step_2.html.twig b/templates/groups/booking/step_2.html.twig index 6dd8321..9c3c457 100644 --- a/templates/groups/booking/step_2.html.twig +++ b/templates/groups/booking/step_2.html.twig @@ -148,6 +148,10 @@ {% if ctx.ungroupedAdditionalServices is not empty or ctx.groupedAdditionalServices is not empty %} + {# Wraps every fieldset so mutually exclusive services can be unchecked across fieldsets. + Purely for instant feedback — the server resolves the same conflicts on submit. #} +
+ {# Ungrouped → checkboxes #} {% if ctx.ungroupedAdditionalServices is not empty %}
@@ -179,6 +183,11 @@ name="{{ form.selectedAdditionalServiceIds.vars.full_name }}" value="{{ service.id }}" {% if service.id in dto.selectedAdditionalServiceIds %}checked{% endif %} + {% if service.exclusionGroup %} + {{ stimulus_target('service-exclusion', 'input') }} + data-exclusion-group="{{ service.exclusionGroup }}" + data-exclusive="{{ service.isExclusive ? '1' : '0' }}" + {% endif %} class="form-checkbox"> @@ -217,6 +226,11 @@ name="{{ selectedAdditionalServiceBaseName }}[{{ groupName|e('html_attr') }}]" value="{{ service.id }}" {% if service.id in dto.selectedAdditionalServiceIds %}checked{% endif %} + {% if service.exclusionGroup %} + data-service-exclusion-target="input" + data-exclusion-group="{{ service.exclusionGroup }}" + data-exclusive="{{ service.isExclusive ? '1' : '0' }}" + {% endif %} class="form-radio"> @@ -226,6 +240,8 @@
{% endfor %} +
+ {% endif %} {% endblock %} diff --git a/tests/Form/AccommodationStep2TypeTest.php b/tests/Form/AccommodationStep2TypeTest.php index ea3485d..dbc51c9 100644 --- a/tests/Form/AccommodationStep2TypeTest.php +++ b/tests/Form/AccommodationStep2TypeTest.php @@ -4,20 +4,21 @@ declare(strict_types=1); namespace App\Tests\Form; +use App\Entity\Groups\AdditionalService; use App\Form\AccommodationStep2Type; use App\Form\Model\AccommodationBookingDto; +use App\Service\AdditionalServiceExclusionResolver; use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\Forms; class AccommodationStep2TypeTest extends TestCase { public function testChildrenLabelIsBuiltFromMaxAdolescentAgeOption(): void { - $form = Forms::createFormFactoryBuilder() - ->getFormFactory() - ->create(AccommodationStep2Type::class, new AccommodationBookingDto(), [ - 'max_adolescent_age' => 15, - ]); + $form = $this->createForm(new AccommodationBookingDto(), [ + 'max_adolescent_age' => 15, + ]); self::assertSame( 'davon Kinder (4–15 Jahre)', @@ -28,9 +29,7 @@ class AccommodationStep2TypeTest extends TestCase public function testBlankOptionalChildCountersAreNormalizedToZero(): void { $dto = new AccommodationBookingDto(); - $form = Forms::createFormFactoryBuilder() - ->getFormFactory() - ->create(AccommodationStep2Type::class, $dto); + $form = $this->createForm($dto); $form->submit([ 'paxCount' => '2', @@ -48,14 +47,12 @@ class AccommodationStep2TypeTest extends TestCase public function testGroupedAdditionalServiceValuesCanSubmitUnderSelectionGroupKeys(): void { $dto = new AccommodationBookingDto(); - $form = Forms::createFormFactoryBuilder() - ->getFormFactory() - ->create(AccommodationStep2Type::class, $dto, [ - 'additional_service_choices' => [ - 'Ski service' => 101, - 'Board service' => 202, - ], - ]); + $form = $this->createForm($dto, [ + 'additional_service_choices' => [ + 'Ski service' => 101, + 'Board service' => 202, + ], + ]); $form->submit([ 'paxCount' => '2', @@ -71,4 +68,87 @@ class AccommodationStep2TypeTest extends TestCase self::assertTrue($form->isSynchronized()); self::assertSame([101, 202], array_values($dto->selectedAdditionalServiceIds)); } + + public function testNewlySelectedExclusiveServiceClearsItsConflicts(): void + { + $dto = new AccommodationBookingDto(); + $dto->selectedAdditionalServiceIds = [11, 12]; + + $form = $this->createForm($dto, [ + 'additional_service_choices' => [ + 'Keller' => 11, + '1. OG' => 12, + 'Komplett' => 13, + ], + 'additional_services' => [ + $this->service(11, 'Reinigung', false), + $this->service(12, 'Reinigung', false), + $this->service(13, 'Reinigung', true), + ], + ]); + + $form->submit([ + 'paxCount' => '2', + 'minorsCount' => '0', + 'childrenCount' => '0', + 'selectedBoardServiceId' => '', + 'selectedAdditionalServiceIds' => ['11', '12', '13'], + ]); + + self::assertTrue($form->isSynchronized()); + self::assertSame([13], array_values($dto->selectedAdditionalServiceIds)); + } + + public function testNewlySelectedRegularServiceClearsTheExclusiveOne(): void + { + $dto = new AccommodationBookingDto(); + $dto->selectedAdditionalServiceIds = [13]; + + $form = $this->createForm($dto, [ + 'additional_service_choices' => [ + 'Keller' => 11, + 'Komplett' => 13, + ], + 'additional_services' => [ + $this->service(11, 'Reinigung', false), + $this->service(13, 'Reinigung', true), + ], + ]); + + $form->submit([ + 'paxCount' => '2', + 'minorsCount' => '0', + 'childrenCount' => '0', + 'selectedBoardServiceId' => '', + 'selectedAdditionalServiceIds' => ['13', '11'], + ]); + + self::assertTrue($form->isSynchronized()); + self::assertSame([11], array_values($dto->selectedAdditionalServiceIds)); + } + + /** + * @param array $options + * + * @return FormInterface + */ + private function createForm(AccommodationBookingDto $dto, array $options = []): FormInterface + { + return Forms::createFormFactoryBuilder() + ->addType(new AccommodationStep2Type(new AdditionalServiceExclusionResolver())) + ->getFormFactory() + ->create(AccommodationStep2Type::class, $dto, $options); + } + + private function service(int $id, ?string $exclusionGroup, bool $isExclusive): AdditionalService + { + $service = new AdditionalService(); + $service->setExclusionGroup($exclusionGroup); + $service->setIsExclusive($isExclusive); + + $property = new \ReflectionProperty(AdditionalService::class, 'id'); + $property->setValue($service, $id); + + return $service; + } } diff --git a/tests/Service/AdditionalServiceExclusionResolverTest.php b/tests/Service/AdditionalServiceExclusionResolverTest.php new file mode 100644 index 0000000..8cc3de6 --- /dev/null +++ b/tests/Service/AdditionalServiceExclusionResolverTest.php @@ -0,0 +1,128 @@ +resolve( + [self::BASEMENT, self::FIRST_FLOOR, self::FULL], + [self::BASEMENT, self::FIRST_FLOOR], + ); + + self::assertSame([self::FULL], array_values($resolved)); + } + + public function testSelectingANonExclusiveServiceClearsOnlyTheExclusiveOne(): void + { + $resolved = $this->resolve( + [self::FULL, self::BASEMENT], + [self::FULL], + ); + + self::assertSame([self::BASEMENT], array_values($resolved)); + } + + public function testNonExclusiveGroupMembersCanBeCombined(): void + { + $resolved = $this->resolve( + [self::BASEMENT, self::FIRST_FLOOR, self::SECOND_FLOOR], + [self::BASEMENT], + ); + + self::assertSame( + [self::BASEMENT, self::FIRST_FLOOR, self::SECOND_FLOOR], + array_values($resolved), + ); + } + + public function testServicesWithoutAnExclusionGroupAreNeverCleared(): void + { + $resolved = $this->resolve( + [self::BREAKFAST, self::BASEMENT, self::FULL], + [self::BREAKFAST, self::BASEMENT], + ); + + self::assertSame([self::BREAKFAST, self::FULL], array_values($resolved)); + } + + public function testUnchangedSelectionIsLeftAlone(): void + { + $resolved = $this->resolve( + [self::BASEMENT, self::FULL], + [self::BASEMENT, self::FULL], + ); + + self::assertSame([self::BASEMENT, self::FULL], array_values($resolved)); + } + + public function testSubmittedArrayKeysArePreserved(): void + { + $resolver = new AdditionalServiceExclusionResolver(); + + $resolved = $resolver->resolve( + $this->services(), + ['Reinigung' => self::FULL, 'Verpflegung' => self::BREAKFAST, 0 => self::BASEMENT], + [self::BASEMENT], + ); + + self::assertSame( + ['Reinigung' => self::FULL, 'Verpflegung' => self::BREAKFAST], + $resolved, + ); + } + + /** + * @param list $submittedIds + * @param list $previousIds + * + * @return array + */ + private function resolve(array $submittedIds, array $previousIds): array + { + return (new AdditionalServiceExclusionResolver())->resolve( + $this->services(), + $submittedIds, + $previousIds, + ); + } + + /** + * @return AdditionalService[] + */ + private function services(): array + { + return [ + $this->service(self::BASEMENT, 'Reinigung', false), + $this->service(self::FIRST_FLOOR, 'Reinigung', false), + $this->service(self::SECOND_FLOOR, 'Reinigung', false), + $this->service(self::FULL, 'Reinigung', true), + $this->service(self::BREAKFAST, null, false), + ]; + } + + private function service(int $id, ?string $exclusionGroup, bool $isExclusive): AdditionalService + { + $service = new AdditionalService(); + $service->setExclusionGroup($exclusionGroup); + $service->setIsExclusive($isExclusive); + + $property = new \ReflectionProperty(AdditionalService::class, 'id'); + $property->setValue($service, $id); + + return $service; + } +}