feat: exclusion groups for additional services
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user