Création de promotion personnalisée avec Sylius

Imaginons que vous ayez un site e-commerce qui utilise Sylius.
Il y a un système de promotion à mettre en place. Cette promotion sera appliquée sur tout le site et ne devra pas être modifiable. Elle aura une logique métier complètement personnalisée et dépendra de règles métier spécifiques1.

Vous pourriez définir des promotions avec les règles de promotion proposés par défaut et les lier à des actions. Cependant, cette façon de fonctionner ne s’applique pas vraiment avec les règles de promotion présentes de base dans Sylius :

  • Quantité dans le panier
  • Groupe de clients
  • N-ième commande
  • Pays de livraison
  • A au moins un des taxons
  • Prix total des Articles du taxon
  • Contient le produit
  • Total produit

La manière que je propose dans cet article est découpé en 5 partie :

Table des matières

    Etendre l’Entité

    Déclarer les services que nous allons étendre

    Par défaut, Sylius définit ses propres modèles de données. Pour la Promotion, il y a Sylius\Component\Core\Model\Promotion et Sylius\Bundle\CoreBundle\Doctrine\ORM\PromotionRepository. Nous allons les étendre et les déclarer dans le fichier ci-dessous pour que Sylius sache que l’on veut utiliser ces classes PHP et non plus celles de Sylius.

    sylius_promotion:
      resources:
        promotion:
          classes:
            model: App\Entity\Promotion\Promotion
            repository: App\Repository\Promotion\PromotionRepository

    Créer l’entité

    On vient étendre la classe Promotion de Sylius par notre propre classe Promotion. Cela permet d’y ajouter nos propres propriétés et constantes de classe.

    <?php
    
    declare(strict_types=1);
    
    namespace App\Entity\Promotion;
    
    use Doctrine\ORM\Mapping as ORM;
    use Sylius\Component\Core\Model\Promotion as BasePromotion;
    
    #[ORM\Entity]
    #[ORM\Table(name: 'sylius_promotion')]
    class Promotion extends BasePromotion
    {
        public const DEFAULT_PROMOTION_CODE = 'default_promotion';
    }

    Créer la promotion

    Maintenant, il faut créer la promotion et l’insérer en base de donnée pour qu’elle puisse être récupérée quand l’utilisateur utilise le site. Il est possible de le faire directement depuis l’administration, mais comme je souhaite pouvoir itérer facilement et m’assurer que la promotion existe toujours, je vais la créer via une commande Symfony.
    Pour cela, j’utilise la FactoryInterface de Sylius auquel j’ajoute l’autowiring via l’attribut #[Autowire].

    <?php
    
    declare(strict_types=1);
    
    namespace App\Command\Contact;
    
    use Doctrine\ORM\EntityManagerInterface;
    use Sylius\Component\Channel\Context\ChannelContextInterface;
    use Sylius\Component\Resource\Factory\FactoryInterface;
    use Symfony\Component\Console\Attribute\AsCommand;
    use Symfony\Component\Console\Command\Command;
    use Symfony\Component\Console\Input\InputInterface;
    use Symfony\Component\Console\Output\OutputInterface;
    use Symfony\Component\DependencyInjection\Attribute\Autowire;
    
    #[AsCommand(name: 'app:create-default-promotion', description: 'Create the default promotion')]
    class CreateDefaultPromotionCommand extends Command
    {
        public function __construct(
            private readonly EntityManagerInterface $entityManager,
            #[Autowire(service: 'sylius.factory.promotion')]
            private readonly FactoryInterface $factory,
            private readonly ChannelContextInterface $channelContext,
        ) {
            parent::__construct();
        }
    
        protected function execute(InputInterface $input, OutputInterface $output): int
        {
            /** @var \App\Entity\Promotion\Promotion $promotion */
            $promotion = $this->factory->createNew();
            $promotion->setCode(Promotion::DEFAULT_PROMOTION_CODE);
            $promotion->setName('Default Promotion');
            $promotion->setDescription('Default Promotion used globally on the store');
            $promotion->setPriority(0);
            $promotion->setExclusive(true);
            $promotion->setUsed(0);
            $promotion->setCouponBased(false);
            $promotion->setAppliesToDiscounted(true);
            $promotion->setCreatedAt(new \DateTimeImmutable());
    
            $promotion->addChannel($this->channelContext->getChannel());
    
            $this->entityManager->persist($promotion);
            $this->entityManager->flush();
            return Command::SUCCESS;
        }
    }

    Cacher la promotion dans le back-office

    Nous ne voulons pas que la promotion soit visible dans l’administration. Pour exclure la promotion de l’administration, il suffit de créer une méthode dans le PromotionRepository que nous avons crée. C’est aussi l’intérêt d’étendre les classes de Sylius : pouvoir créer nos propres méthodes spécifiques à nos besoins. Ici, on vient exclure de la requête les promotion qui ont le code égale à Promotion::DEFAULT_PROMOTION_CODE, la constante que nous avons définie précédement.

    Créer le repository

    <?php
    
    declare(strict_types=1);
    
    namespace App\Repository\Promotion;
    
    use App\Entity\Promotion\Promotion;
    use Doctrine\ORM\QueryBuilder;
    use Sylius\Bundle\CoreBundle\Doctrine\ORM\PromotionRepository as BasePromotionRepository;
    use Sylius\Component\Core\Repository\PromotionRepositoryInterface;
    
    /**
     * @method Promotion|null find($id, $lockMode = null, $lockVersion = null)
     * @method Promotion|null findOneBy(array $criteria, array $orderBy = null)
     * @method Promotion[]    findAll()
     * @method Promotion[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
     */
    class PromotionRepository extends BasePromotionRepository implements PromotionRepositoryInterface
    {
        /**
         * This function allows to hide the default custom DEFAULT_PROMOTION_CODE in admin BO
         */
        public function createAdminQueryBuilder(): QueryBuilder
        {
            return $this->_em->createQueryBuilder()
                ->select('p')
                ->from($this->_entityName, 'p')
                ->andWhere('p.code != :promotion')
                ->setParameter('promotion', Promotion::DEFAULT_PROMOTION_CODE)
            ;
        }
    }

    Dans le fichier sylius_grid, on vient changer la queryBuilder utilisé par l’administration pour ne pas afficher la promotion par défaut. Comme elle n’est pas supposée être supprimée ou modifiée, il vaut mieux la cacher. Même si elle reste en fait toujours accessible via l’URL d’édition en connaissant son ID.

    sylius_grid:
      grids:
        sylius_admin_promotion:
          driver:
            name: doctrine/orm
            options:
              class: "%sylius.model.promotion.class%"
              repository:
                method: 'createAdminQueryBuilder'

    Eligibilité de la promotion

    Créer le Vérificateur d’éligibilité

    Il faut maintenant créer le vérificateur d’éligibilité. C’est une classe qui va dire si oui ou non l’objet passé en paramètre2 est eligible a la promotion. Dans notre cas, nous allons autoriser la promotion que si l’utilisateur est un utilisateur qui a un compte sur le site.

    <?php
    
    declare(strict_types=1);
    
    namespace App\Promotion\Checker\Eligibility;
    
    use App\Entity\Order\Order;
    use App\Entity\Promotion\Promotion;
    use Sylius\Component\Promotion\Checker\Eligibility\PromotionEligibilityCheckerInterface;
    use Sylius\Component\Promotion\Exception\UnsupportedTypeException;
    use Sylius\Component\Promotion\Model\PromotionInterface;
    use Sylius\Component\Promotion\Model\PromotionSubjectInterface;
    
    class DefaultPromotionEligibilityChecker implements PromotionEligibilityCheckerInterface
    {
        public function isEligible(PromotionSubjectInterface $promotionSubject, PromotionInterface $promotion): bool
        {
            if (!$promotionSubject instanceof Order) {
                throw new UnsupportedTypeException($promotionSubject, Order::class);
            }
    
            if ($promotion->getCode() !== Promotion::DEFAULT_PROMOTION_CODE) {
                return false;
            }
    
            /** @var \App\Entity\Customer\Customer|null $customer */
            $customer = $promotionSubject->getCustomer();
            // is elligible if the customer is a customer
            return $customer !== null;
        }
    }

    Maintenant que le vérificateur est crée, il faut l’ajouter au fichier services.yaml pour qu’il soit pris en compte par le PromotionProcessor

    services:  
      app.default_promotion_eligibility_checker:
        class: App\Promotion\Checker\Eligibility\DefaultPromotionEligibilityChecker
        public: false
        tags:
          - { name: sylius.promotion_eligibility_checker }

    PromotionProcessor

    Le PromotionProcessor est une classe qui va parcourir et executer tous les vérificateur d’éligibilité jusqu’à trouver le premier renvoyant true pour l’object actuel. Si aucun n’est trouvé, alors on arrête la logique de promotion, car rien n’est applicable.

    <?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\Promotion\Processor;
    
    // ...
    
    final class PromotionProcessor implements PromotionProcessorInterface
    {
        public function __construct(
            private PreQualifiedPromotionsProviderInterface $preQualifiedPromotionsProvider,
            private PromotionEligibilityCheckerInterface $promotionEligibilityChecker,
            private PromotionApplicatorInterface $promotionApplicator,
        ) {
        }
    
        public function process(PromotionSubjectInterface $subject): void
        {
    		// ...
    
            $preQualifiedPromotions = $this->preQualifiedPromotionsProvider->getPromotions($subject);
    
            foreach ($preQualifiedPromotions as $promotion) {
                if ($promotion->isExclusive() && $this->promotionEligibilityChecker->isEligible($subject, $promotion)) {
                    $this->promotionApplicator->apply($subject, $promotion);
    
                    return;
                }
            }
    
            // ...
        }
    }

    Ce qui nous intéresse ici est PromotionEligibilityCheckerInterface. Par défaut Sylius va aller choisir la classe CompositePromotionEligibilityChecker pour implémenter cette interface. Cette classe va ensuite itérer sur tous les classes implémentant PromotionEligibilityCheckerInterface, dont notre vérificateur d’éligibilité fait parti. Il sera donc appelé via la méthode isEligible($promotionSubject, $promotion).

    <?php
    
    declare(strict_types=1);
    
    namespace Sylius\Component\Promotion\Checker\Eligibility;
    
    // ...
    
    final class CompositePromotionEligibilityChecker implements PromotionEligibilityCheckerInterface
    {
        /** @var PromotionEligibilityCheckerInterface[] */
        private array $promotionEligibilityCheckers;
    
        // ...
    
        public function isEligible(PromotionSubjectInterface $promotionSubject, PromotionInterface $promotion): bool
        {
            foreach ($this->promotionEligibilityCheckers as $promotionEligibilityChecker) {
                if (!$promotionEligibilityChecker->isEligible($promotionSubject, $promotion)) {
                    return false;
                }
            }
    
            return true;
        }
    }

    Application de la promotion

    Le PromotionApplicator

    Le PromotionApplicator est utilisé par le PromotionProcessor. Le PromotionProcessor reçoit un PromotionApplicatorInterface dans son constructeur lors de l’injection de dépendance. Par défaut cette interface est instanciée par la classe PromotionApplicator ci-dessous :

    <?php
    // ...
    
    final class PromotionApplicator implements PromotionApplicatorInterface
    {
        public function __construct(private ServiceRegistryInterface $registry)
        {
        }
    
        public function apply(PromotionSubjectInterface $subject, PromotionInterface $promotion): void
        {
            $applyPromotion = false;
            foreach ($promotion->getActions() as $action) {
                $result = $this->getActionCommandByType($action->getType())->execute($subject, $action->getConfiguration(), $promotion);
                $applyPromotion = $applyPromotion || $result;
            }
    
            if ($applyPromotion) {
                $subject->addPromotion($promotion);
            }
        }
    
        public function revert(PromotionSubjectInterface $subject, PromotionInterface $promotion): void
        {
            // ...
        }
    
        private function getActionCommandByType(string $type): PromotionActionCommandInterface
        {
            return $this->registry->get($type);
        }
    }

    On voit dans la classe que la méthode apply() a besoin de récupérer les actions de la promotion. Les actions sont définies dans la base de données. Elles peuvent être par exemple :

    • Remise sur la commande
    • Remise fixe produit
    • Rabais en pourcentage commande
    • Rabais en pourcentage produit
    • Rabais en pourcentage expédition

    Cependant nous n’en avons pas défini. Nous avons une logique métier qui est plus complexe et nécessite de récupérer les infos de la base de données pour savoir quelle montant de remise appliquer en fonction de quel produit.

    Il est possible aussi d’attribuer une action via l’interface ou via notre commande de création de promotion, cependant cela n’a pas trop de sens, alors autant rester sur la promotion utilisant des règles et des actions.

    Décorer l’applicateur

    Dans notre cas, comme nous ne définissons pas d’action sur notre promotion, il faut un moyen d’appliquer quand même notre logique. Si elle n’est pas applicable dans le contexte courant, alors on retombe par défaut sur la logique de PromotionApplicator de Sylius. Pour cela nous allons décorer le PromotionApplicator de Sylius avec notre propre classe.
    En faisant un bin/console debug:container PromotionApplicatorInterface de PromotionApplicator, on peut voir que Sylius lui attribut l’alias sylius.promotion_applicator. Nous allons donc utiliser cette alias pour référencer notre classe décorée.

    Pour cela on va venir ajouter l’attribut AsDecorator disponible depuis Symfony 6.1. On lui passe l’alias en paramètre, et cela nous permet de récupérer le PromotionApplicator de Sylius directement dans notre constructeur. On vient rajouter notre logique dans la fonction apply() et revert() si on est sur une commande et sur la bonne promotion. Si non, on appelle notre classe décorée pour revenir au comportement de base de Sylius.

    <?php
    
    declare(strict_types=1);
    
    namespace App\Promotion\Action;
    
    use App\Entity\Order\Order;
    use App\Entity\Promotion\Promotion;
    use Sylius\Component\Promotion\Action\PromotionApplicatorInterface;
    use Sylius\Component\Promotion\Model\PromotionInterface;
    use Sylius\Component\Promotion\Model\PromotionSubjectInterface;
    use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
    
    #[AsDecorator('sylius.promotion_applicator')]
    final readonly class DefaultPromotionApplicator implements PromotionApplicatorInterface
    {
        public function __construct(
            private PromotionApplicatorInterface $promotionApplicator,
            #[Autowire(service: 'sylius.factory.adjustment')]
            private readonly FactoryInterface $adjustmentFactory,
        ) {
        }
    
        public function apply(PromotionSubjectInterface $subject, PromotionInterface $promotion): void
        {
            if ($subject instanceof Order && $promotion->getCode() === Promotion::DEFAULT_PROMOTION_CODE) {
            	/**
            	 * TODO :
            	 * Il reste a implémenter la logique métier, par exemple en injectant le repository d'un model
            	 * et en faisant des requêtes permettant de savoir si des remises sont disponibles pour le panier 
            	 * courant. Si oui, appliquer des modifications au panier suivant ces règles
            	 * 
            	 */
    
            	$amount = // ...
    
    
    	        $adjustment = $this->adjustmentFactory->createNew();
    	        $adjustment->setType(AdjustmentInterface::ORDER_PROMOTION_ADJUSTMENT);
    	        $adjustment->setAmount($amount);
    	        $adjustment->setNeutral(false);
    	        $adjustment->setLabel('Test Promotion Adjustment');
    	        $order->addAdjustment($adjustment);
    
                return;
            }
    
            $this->promotionApplicator->apply($subject, $promotion);
        }
    
        public function revert(PromotionSubjectInterface $subject, PromotionInterface $promotion): void
        {
            if ($subject instanceof Order && $promotion->getCode() === Promotion::DEFAULT_PROMOTION_CODE) {
            	/**
            	 * TODO :
            	 * Il reste a implémenter la logique métier, par exemple en injectant le repository d'un model
            	 * et en faisant des requêtes permettant de savoir si des remises sont disponibles pour le panier 
            	 * courant. Si oui, appliquer des modifications au panier suivant ces règles
            	 * 
            	 */
    
                return;
            }
    
            $this->promotionApplicator->revert($subject, $promotion);
        }
    }

    Il ne reste plus qu’à créer un Adjustment et à l’attribuer à notre commande pour que la promotion se retrouve ajouté au panier.

    Conclusion

    Nous avons vu comment mettre en place une promotion par défaut, cachée, pour gérer des règles métiers lors de la modification d’une commande.
    Bien sûr, il reste a implémenter la logique de récupération des remises, mais ça dépend du cas et du besoin. Je me suis concentré sur un seul cas d’usage avec une configuration spécifique (promotion exclusive, pas basé sur des coupons, etc…), mais ça me permettais de donner une idée de l’approche que je prend quand je suis fasse à une situation sur Sylius ou je souhaite dévier de ce qui est normalement proposé (ici les règles de promotion liés aux actions de promotion).

    1. Je reste vague, car la logique métier n’est pas le but de cet article. ↩︎
    2. C’est toujours la commande dans ce contexte. ↩︎

    Commentaires

    Laisser un commentaire

    Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *