Add channel-based eligibility and application for promotions

This commit is contained in:
Tomasz Kaliński 2026-06-16 08:46:47 +02:00
parent 340fce0744
commit 58a7b1e566
10 changed files with 323 additions and 0 deletions

View file

@ -14,15 +14,62 @@ declare(strict_types=1);
namespace Sylius\Bundle\AdminBundle\Form\Extension\Promotion;
use Sylius\Bundle\PromotionBundle\Form\Type\PromotionActionType;
use Sylius\Component\Channel\Repository\ChannelRepositoryInterface;
use Sylius\Component\Core\Promotion\ChannelAwareConfigurationInterface;
use Sylius\Component\Promotion\Model\PromotionActionInterface;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
final class PromotionActionTypeExtension extends AbstractTypeExtension
{
public function __construct(private ChannelRepositoryInterface $channelRepository)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('type', HiddenType::class);
$choices = [];
foreach ($this->channelRepository->findAll() as $channel) {
$choices[$channel->getName()] = $channel->getCode();
}
$availableCodes = array_values($choices);
$builder->add(ChannelAwareConfigurationInterface::CHANNELS_CONFIGURATION_KEY, ChoiceType::class, [
'label' => 'sylius.form.promotion_action.channels',
'choices' => $choices,
'multiple' => true,
'expanded' => true,
'required' => false,
'choice_translation_domain' => false,
'mapped' => false,
]);
$builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event) use ($availableCodes): void {
$data = $event->getData();
if (!$data instanceof PromotionActionInterface) {
return;
}
$storedCodes = $data->getConfiguration()[ChannelAwareConfigurationInterface::CHANNELS_CONFIGURATION_KEY] ?? [];
$event->getForm()->get(ChannelAwareConfigurationInterface::CHANNELS_CONFIGURATION_KEY)
->setData(array_values(array_intersect($storedCodes, $availableCodes)));
});
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
$data = $event->getData();
if (!$data instanceof PromotionActionInterface) {
return;
}
$configuration = $data->getConfiguration();
$configuration[ChannelAwareConfigurationInterface::CHANNELS_CONFIGURATION_KEY] =
$event->getForm()->get(ChannelAwareConfigurationInterface::CHANNELS_CONFIGURATION_KEY)->getData() ?? [];
$data->setConfiguration($configuration);
});
}
public static function getExtendedTypes(): iterable

View file

@ -14,15 +14,62 @@ declare(strict_types=1);
namespace Sylius\Bundle\AdminBundle\Form\Extension\Promotion;
use Sylius\Bundle\PromotionBundle\Form\Type\PromotionRuleType;
use Sylius\Component\Channel\Repository\ChannelRepositoryInterface;
use Sylius\Component\Core\Promotion\ChannelAwareConfigurationInterface;
use Sylius\Component\Promotion\Model\PromotionRuleInterface;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
final class PromotionRuleTypeExtension extends AbstractTypeExtension
{
public function __construct(private ChannelRepositoryInterface $channelRepository)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('type', HiddenType::class);
$choices = [];
foreach ($this->channelRepository->findAll() as $channel) {
$choices[$channel->getName()] = $channel->getCode();
}
$availableCodes = array_values($choices);
$builder->add(ChannelAwareConfigurationInterface::CHANNELS_CONFIGURATION_KEY, ChoiceType::class, [
'label' => 'sylius.form.promotion_rule.channels',
'choices' => $choices,
'multiple' => true,
'expanded' => true,
'required' => false,
'choice_translation_domain' => false,
'mapped' => false,
]);
$builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event) use ($availableCodes): void {
$data = $event->getData();
if (!$data instanceof PromotionRuleInterface) {
return;
}
$storedCodes = $data->getConfiguration()[ChannelAwareConfigurationInterface::CHANNELS_CONFIGURATION_KEY] ?? [];
$event->getForm()->get(ChannelAwareConfigurationInterface::CHANNELS_CONFIGURATION_KEY)
->setData(array_values(array_intersect($storedCodes, $availableCodes)));
});
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
$data = $event->getData();
if (!$data instanceof PromotionRuleInterface) {
return;
}
$configuration = $data->getConfiguration();
$configuration[ChannelAwareConfigurationInterface::CHANNELS_CONFIGURATION_KEY] =
$event->getForm()->get(ChannelAwareConfigurationInterface::CHANNELS_CONFIGURATION_KEY)->getData() ?? [];
$data->setConfiguration($configuration);
});
}
public static function getExtendedTypes(): iterable

View file

@ -113,6 +113,7 @@ return static function (ContainerConfigurator $container) {
$services
->set('sylius_admin.form.extension.type.promotion.promotion_action', PromotionActionTypeExtension::class)
->args([service('sylius.repository.channel')])
->tag('form.type_extension')
;
@ -130,6 +131,7 @@ return static function (ContainerConfigurator $container) {
$services
->set('sylius_admin.form.extension.type.promotion.promotion_rule', PromotionRuleTypeExtension::class)
->args([service('sylius.repository.channel')])
->tag('form.type_extension')
;

View file

@ -19,6 +19,7 @@
</div>
<div class="flex-grow-1">
{{- form_row(form.configuration, {'label': false}) -}}
{{- form_row(form._channels) -}}
</div>
</div>
</div>
@ -35,6 +36,7 @@
</div>
<div class="flex-grow-1">
{{- form_row(form.configuration, {'label': false}) -}}
{{- form_row(form._channels) -}}
</div>
</div>
</div>

View file

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\DependencyInjection\Compiler;
use Sylius\Component\Core\Promotion\Action\ChannelAwarePromotionApplicator;
use Sylius\Component\Core\Promotion\Checker\Eligibility\ChannelAwarePromotionRulesEligibilityChecker;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
final class OverridePromotionServicesPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if ($container->hasDefinition('sylius.checker.promotion.rules_eligibility')) {
$container->getDefinition('sylius.checker.promotion.rules_eligibility')
->setClass(ChannelAwarePromotionRulesEligibilityChecker::class);
}
if ($container->hasDefinition('sylius.action.applicator.promotion')) {
$container->getDefinition('sylius.action.applicator.promotion')
->setClass(ChannelAwarePromotionApplicator::class);
}
}
}

View file

@ -75,6 +75,7 @@ sylius:
promotion_action:
add_product_configuration:
product: Product
channels: Applicable channels
promotion_coupon:
per_customer_usage_limit: Per-Customer Usage Limit
reusable_from_cancelled_orders: Reusable from cancelled orders
@ -198,6 +199,7 @@ sylius:
products: Products filter
taxons: Taxons filter
promotion_rule:
channels: Applicable channels
customer_group:
group: Customer group
has_taxon:

View file

@ -28,6 +28,7 @@ use Sylius\Bundle\CoreBundle\DependencyInjection\Compiler\CheckStatisticsOrdersT
use Sylius\Bundle\CoreBundle\DependencyInjection\Compiler\CircularDependencyBreakingErrorListenerPass;
use Sylius\Bundle\CoreBundle\DependencyInjection\Compiler\IgnoreAnnotationsPass;
use Sylius\Bundle\CoreBundle\DependencyInjection\Compiler\LazyCacheWarmupPass;
use Sylius\Bundle\CoreBundle\DependencyInjection\Compiler\OverridePromotionServicesPass;
use Sylius\Bundle\CoreBundle\DependencyInjection\Compiler\OverrideResourceControllerStateMachinePass;
use Sylius\Bundle\CoreBundle\DependencyInjection\Compiler\RegisterTaxCalculationStrategiesPass;
use Sylius\Bundle\CoreBundle\DependencyInjection\Compiler\RegisterUriBasedSectionResolverPass;
@ -93,6 +94,7 @@ final class SyliusCoreBundle extends AbstractResourceBundle
$container->addCompilerPass(new Symfony6PrivateServicesPass());
$container->addCompilerPass(new TranslatableEntityLocalePass());
$container->addCompilerPass(new CheckStatisticsOrdersTotalsProviderTypePass());
$container->addCompilerPass(new OverridePromotionServicesPass());
$container->addCompilerPass(new OverrideResourceControllerStateMachinePass(), priority: -1024);
}

View file

@ -0,0 +1,93 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Component\Core\Promotion\Action;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Promotion\ChannelAwareConfigurationInterface;
use Sylius\Component\Promotion\Action\PromotionActionCommandInterface;
use Sylius\Component\Promotion\Action\PromotionApplicatorInterface;
use Sylius\Component\Promotion\Model\PromotionActionInterface;
use Sylius\Component\Promotion\Model\PromotionInterface;
use Sylius\Component\Promotion\Model\PromotionSubjectInterface;
use Sylius\Component\Registry\ServiceRegistryInterface;
final class ChannelAwarePromotionApplicator implements PromotionApplicatorInterface, ChannelAwareConfigurationInterface
{
public function __construct(private ServiceRegistryInterface $registry)
{
}
public function apply(PromotionSubjectInterface $subject, PromotionInterface $promotion): void
{
$applyPromotion = false;
foreach ($promotion->getActions() as $action) {
if ($this->isActionSkippedForChannel($action, $subject)) {
continue;
}
$configuration = $this->stripChannelKey($action->getConfiguration());
$result = $this->getActionCommandByType($action->getType())->execute($subject, $configuration, $promotion);
$applyPromotion = $applyPromotion || $result;
}
if ($applyPromotion) {
$subject->addPromotion($promotion);
}
}
public function revert(PromotionSubjectInterface $subject, PromotionInterface $promotion): void
{
foreach ($promotion->getActions() as $action) {
if ($this->isActionSkippedForChannel($action, $subject)) {
continue;
}
$configuration = $this->stripChannelKey($action->getConfiguration());
$this->getActionCommandByType($action->getType())->revert($subject, $configuration, $promotion);
}
$subject->removePromotion($promotion);
}
private function isActionSkippedForChannel(PromotionActionInterface $action, PromotionSubjectInterface $subject): bool
{
$channels = $action->getConfiguration()[self::CHANNELS_CONFIGURATION_KEY] ?? [];
if ($channels === []) {
return false;
}
if (!$subject instanceof OrderInterface) {
return false;
}
return !in_array($subject->getChannel()->getCode(), $channels, true);
}
/**
* @param array<string, mixed> $configuration
*
* @return array<string, mixed>
*/
private function stripChannelKey(array $configuration): array
{
unset($configuration[self::CHANNELS_CONFIGURATION_KEY]);
return $configuration;
}
private function getActionCommandByType(string $type): PromotionActionCommandInterface
{
return $this->registry->get($type);
}
}

View file

@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Component\Core\Promotion;
interface ChannelAwareConfigurationInterface
{
public const CHANNELS_CONFIGURATION_KEY = '_channels';
}

View file

@ -0,0 +1,74 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Component\Core\Promotion\Checker\Eligibility;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Promotion\ChannelAwareConfigurationInterface;
use Sylius\Component\Promotion\Checker\Eligibility\PromotionEligibilityCheckerInterface;
use Sylius\Component\Promotion\Checker\Rule\RuleCheckerInterface;
use Sylius\Component\Promotion\Model\PromotionInterface;
use Sylius\Component\Promotion\Model\PromotionRuleInterface;
use Sylius\Component\Promotion\Model\PromotionSubjectInterface;
use Sylius\Component\Registry\ServiceRegistryInterface;
final class ChannelAwarePromotionRulesEligibilityChecker implements PromotionEligibilityCheckerInterface, ChannelAwareConfigurationInterface
{
public function __construct(private ServiceRegistryInterface $ruleRegistry)
{
}
public function isEligible(PromotionSubjectInterface $promotionSubject, PromotionInterface $promotion): bool
{
if (!$promotion->hasRules()) {
return true;
}
foreach ($promotion->getRules() as $rule) {
if ($this->isRuleSkippedForChannel($rule, $promotionSubject)) {
continue;
}
if (!$this->isEligibleToRule($promotionSubject, $rule)) {
return false;
}
}
return true;
}
private function isRuleSkippedForChannel(PromotionRuleInterface $rule, PromotionSubjectInterface $subject): bool
{
$channels = $rule->getConfiguration()[self::CHANNELS_CONFIGURATION_KEY] ?? [];
if ($channels === []) {
return false;
}
if (!$subject instanceof OrderInterface) {
return false;
}
return !in_array($subject->getChannel()->getCode(), $channels, true);
}
private function isEligibleToRule(PromotionSubjectInterface $subject, PromotionRuleInterface $rule): bool
{
/** @var RuleCheckerInterface $checker */
$checker = $this->ruleRegistry->get($rule->getType());
$configuration = $rule->getConfiguration();
unset($configuration[self::CHANNELS_CONFIGURATION_KEY]);
return $checker->isEligible($subject, $configuration);
}
}