feat: exclusion groups for additional services

This commit is contained in:
Björn Fromme
2026-08-04 10:24:29 +02:00
parent c31d086356
commit 331c88c38e
11 changed files with 457 additions and 17 deletions
@@ -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,
]);
+30
View File
@@ -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;
}
}
+20 -1
View File
@@ -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 . '[]');
}
}
@@ -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,
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Groups\AdditionalService;
/**
* Resolves mutually exclusive additional services.
*
* Services sharing an exclusion group interact as soon as at least one of the two involved
* services is flagged as exclusive: selecting the exclusive one clears all other members of
* the group, selecting any other member clears the exclusive ones. Members that are not
* exclusive can be combined freely.
*
* Conflicts are resolved in favour of the newest selection, determined by diffing the
* submitted ids against the previously stored ones.
*/
class AdditionalServiceExclusionResolver
{
/**
* @param AdditionalService[] $availableServices
* @param array<array-key, int> $submittedIds keys are preserved, grouped radios submit under their group name
* @param array<array-key, int> $previousIds
*
* @return array<array-key, int>
*/
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<int, array{group: string, exclusive: bool}> $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<int, array{group: string, exclusive: bool}>
*/
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;
}
}