vendor/shopware/core/Checkout/Promotion/Validator/PromotionValidator.php line 62

Open in your IDE?
  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\Checkout\Promotion\Validator;
  3. use Doctrine\DBAL\Connection;
  4. use Shopware\Core\Checkout\Promotion\Aggregate\PromotionDiscount\PromotionDiscountDefinition;
  5. use Shopware\Core\Checkout\Promotion\Aggregate\PromotionDiscount\PromotionDiscountEntity;
  6. use Shopware\Core\Checkout\Promotion\PromotionDefinition;
  7. use Shopware\Core\Framework\Api\Exception\ResourceNotFoundException;
  8. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\InsertCommand;
  9. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\UpdateCommand;
  10. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommand;
  11. use Shopware\Core\Framework\DataAbstractionLayer\Write\Validation\PreWriteValidationEvent;
  12. use Shopware\Core\Framework\Validation\WriteConstraintViolationException;
  13. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  14. use Symfony\Component\Validator\ConstraintViolation;
  15. use Symfony\Component\Validator\ConstraintViolationInterface;
  16. use Symfony\Component\Validator\ConstraintViolationList;
  17. class PromotionValidator implements EventSubscriberInterface
  18. {
  19.     /**
  20.      * this is the min value for all types
  21.      * (absolute, percentage, ...)
  22.      */
  23.     private const DISCOUNT_MIN_VALUE 0.00;
  24.     /**
  25.      * this is used for the maximum allowed
  26.      * percentage discount.
  27.      */
  28.     private const DISCOUNT_PERCENTAGE_MAX_VALUE 100.0;
  29.     private Connection $connection;
  30.     private array $databasePromotions;
  31.     private array $databaseDiscounts;
  32.     /**
  33.      * @internal
  34.      */
  35.     public function __construct(Connection $connection)
  36.     {
  37.         $this->connection $connection;
  38.     }
  39.     public static function getSubscribedEvents(): array
  40.     {
  41.         return [
  42.             PreWriteValidationEvent::class => 'preValidate',
  43.         ];
  44.     }
  45.     /**
  46.      * This function validates our incoming delta-values for promotions
  47.      * and its aggregation. It does only check for business relevant rules and logic.
  48.      * All primitive "required" constraints are done inside the definition of the entity.
  49.      *
  50.      * @throws WriteConstraintViolationException
  51.      */
  52.     public function preValidate(PreWriteValidationEvent $event): void
  53.     {
  54.         $this->collect($event->getCommands());
  55.         $violationList = new ConstraintViolationList();
  56.         $writeCommands $event->getCommands();
  57.         foreach ($writeCommands as $index => $command) {
  58.             if (!$command instanceof InsertCommand && !$command instanceof UpdateCommand) {
  59.                 continue;
  60.             }
  61.             switch (\get_class($command->getDefinition())) {
  62.                 case PromotionDefinition::class:
  63.                     /** @var string $promotionId */
  64.                     $promotionId $command->getPrimaryKey()['id'];
  65.                     try {
  66.                         /** @var array $promotion */
  67.                         $promotion $this->getPromotionById($promotionId);
  68.                     } catch (ResourceNotFoundException $ex) {
  69.                         $promotion = [];
  70.                     }
  71.                     $this->validatePromotion(
  72.                         $promotion,
  73.                         $command->getPayload(),
  74.                         $violationList,
  75.                         $index
  76.                     );
  77.                     break;
  78.                 case PromotionDiscountDefinition::class:
  79.                     /** @var string $discountId */
  80.                     $discountId $command->getPrimaryKey()['id'];
  81.                     try {
  82.                         /** @var array $discount */
  83.                         $discount $this->getDiscountById($discountId);
  84.                     } catch (ResourceNotFoundException $ex) {
  85.                         $discount = [];
  86.                     }
  87.                     $this->validateDiscount(
  88.                         $discount,
  89.                         $command->getPayload(),
  90.                         $violationList,
  91.                         $index
  92.                     );
  93.                     break;
  94.             }
  95.         }
  96.         if ($violationList->count() > 0) {
  97.             $event->getExceptions()->add(new WriteConstraintViolationException($violationList));
  98.         }
  99.     }
  100.     /**
  101.      * This function collects all database data that might be
  102.      * required for any of the received entities and values.
  103.      *
  104.      * @throws ResourceNotFoundException
  105.      * @throws \Doctrine\DBAL\DBALException
  106.      */
  107.     private function collect(array $writeCommands): void
  108.     {
  109.         $promotionIds = [];
  110.         $discountIds = [];
  111.         /** @var WriteCommand $command */
  112.         foreach ($writeCommands as $command) {
  113.             if (!$command instanceof InsertCommand && !$command instanceof UpdateCommand) {
  114.                 continue;
  115.             }
  116.             switch (\get_class($command->getDefinition())) {
  117.                 case PromotionDefinition::class:
  118.                     $promotionIds[] = $command->getPrimaryKey()['id'];
  119.                     break;
  120.                 case PromotionDiscountDefinition::class:
  121.                     $discountIds[] = $command->getPrimaryKey()['id'];
  122.                     break;
  123.             }
  124.         }
  125.         // why do we have inline sql queries in here?
  126.         // because we want to avoid any other private functions that accidentally access
  127.         // the database. all private getters should only access the local in-memory list
  128.         // to avoid additional database queries.
  129.         $this->databasePromotions = [];
  130.         if (!empty($promotionIds)) {
  131.             $promotionQuery $this->connection->executeQuery(
  132.                 'SELECT * FROM `promotion` WHERE `id` IN (:ids)',
  133.                 ['ids' => $promotionIds],
  134.                 ['ids' => Connection::PARAM_STR_ARRAY]
  135.             );
  136.             $this->databasePromotions $promotionQuery->fetchAll();
  137.         }
  138.         $this->databaseDiscounts = [];
  139.         if (!empty($discountIds)) {
  140.             $discountQuery $this->connection->executeQuery(
  141.                 'SELECT * FROM `promotion_discount` WHERE `id` IN (:ids)',
  142.                 ['ids' => $discountIds],
  143.                 ['ids' => Connection::PARAM_STR_ARRAY]
  144.             );
  145.             $this->databaseDiscounts $discountQuery->fetchAll();
  146.         }
  147.     }
  148.     /**
  149.      * Validates the provided Promotion data and adds
  150.      * violations to the provided list of violations, if found.
  151.      *
  152.      * @param array                   $promotion     the current promotion from the database as array type
  153.      * @param array                   $payload       the incoming delta-data
  154.      * @param ConstraintViolationList $violationList the list of violations that needs to be filled
  155.      * @param int                     $index         the index of this promotion in the command queue
  156.      *
  157.      * @throws \Exception
  158.      */
  159.     private function validatePromotion(array $promotion, array $payloadConstraintViolationList $violationListint $index): void
  160.     {
  161.         /** @var string|null $validFrom */
  162.         $validFrom $this->getValue($payload'valid_from'$promotion);
  163.         /** @var string|null $validUntil */
  164.         $validUntil $this->getValue($payload'valid_until'$promotion);
  165.         /** @var bool $useCodes */
  166.         $useCodes $this->getValue($payload'use_codes'$promotion);
  167.         /** @var bool $useCodesIndividual */
  168.         $useCodesIndividual $this->getValue($payload'use_individual_codes'$promotion);
  169.         /** @var string|null $pattern */
  170.         $pattern $this->getValue($payload'individual_code_pattern'$promotion);
  171.         /** @var string|null $promotionId */
  172.         $promotionId $this->getValue($payload'id'$promotion);
  173.         /** @var string|null $code */
  174.         $code $this->getValue($payload'code'$promotion);
  175.         if ($code === null) {
  176.             $code '';
  177.         }
  178.         if ($pattern === null) {
  179.             $pattern '';
  180.         }
  181.         $trimmedCode trim($code);
  182.         // if we have both a date from and until, make sure that
  183.         // the dateUntil is always in the future.
  184.         if ($validFrom !== null && $validUntil !== null) {
  185.             // now convert into real date times
  186.             // and start comparing them
  187.             $dateFrom = new \DateTime($validFrom);
  188.             $dateUntil = new \DateTime($validUntil);
  189.             if ($dateUntil $dateFrom) {
  190.                 $violationList->add($this->buildViolation(
  191.                     'Expiration Date of Promotion must be after Start of Promotion',
  192.                     $payload['valid_until'],
  193.                     'validUntil',
  194.                     'PROMOTION_VALID_UNTIL_VIOLATION',
  195.                     $index
  196.                 ));
  197.             }
  198.         }
  199.         // check if we use global codes
  200.         if ($useCodes && !$useCodesIndividual) {
  201.             // make sure the code is not empty
  202.             if ($trimmedCode === '') {
  203.                 $violationList->add($this->buildViolation(
  204.                     'Please provide a valid code',
  205.                     $code,
  206.                     'code',
  207.                     'PROMOTION_EMPTY_CODE_VIOLATION',
  208.                     $index
  209.                 ));
  210.             }
  211.             // if our code length is greater than the trimmed one,
  212.             // this means we have leading or trailing whitespaces
  213.             if (mb_strlen($code) > mb_strlen($trimmedCode)) {
  214.                 $violationList->add($this->buildViolation(
  215.                     'Code may not have any leading or ending whitespaces',
  216.                     $code,
  217.                     'code',
  218.                     'PROMOTION_CODE_WHITESPACE_VIOLATION',
  219.                     $index
  220.                 ));
  221.             }
  222.         }
  223.         if ($pattern !== '' && $this->isCodePatternAlreadyUsed($pattern$promotionId)) {
  224.             $violationList->add($this->buildViolation(
  225.                 'Code Pattern already exists in other promotion. Please provide a different pattern.',
  226.                 $pattern,
  227.                 'individualCodePattern',
  228.                 'PROMOTION_DUPLICATE_PATTERN_VIOLATION',
  229.                 $index
  230.             ));
  231.         }
  232.         // lookup global code if it does already exist in database
  233.         if ($trimmedCode !== '' && $this->isCodeAlreadyUsed($trimmedCode$promotionId)) {
  234.             $violationList->add($this->buildViolation(
  235.                 'Code already exists in other promotion. Please provide a different code.',
  236.                 $trimmedCode,
  237.                 'code',
  238.                 'PROMOTION_DUPLICATED_CODE_VIOLATION',
  239.                 $index
  240.             ));
  241.         }
  242.     }
  243.     /**
  244.      * Validates the provided PromotionDiscount data and adds
  245.      * violations to the provided list of violations, if found.
  246.      *
  247.      * @param array                   $discount      the discount as array from the database
  248.      * @param array                   $payload       the incoming delta-data
  249.      * @param ConstraintViolationList $violationList the list of violations that needs to be filled
  250.      */
  251.     private function validateDiscount(array $discount, array $payloadConstraintViolationList $violationListint $index): void
  252.     {
  253.         /** @var string $type */
  254.         $type $this->getValue($payload'type'$discount);
  255.         /** @var float|null $value */
  256.         $value $this->getValue($payload'value'$discount);
  257.         if ($value === null) {
  258.             return;
  259.         }
  260.         if ($value self::DISCOUNT_MIN_VALUE) {
  261.             $violationList->add($this->buildViolation(
  262.                 'Value must not be less than ' self::DISCOUNT_MIN_VALUE,
  263.                 $value,
  264.                 'value',
  265.                 'PROMOTION_DISCOUNT_MIN_VALUE_VIOLATION',
  266.                 $index
  267.             ));
  268.         }
  269.         switch ($type) {
  270.             case PromotionDiscountEntity::TYPE_PERCENTAGE:
  271.                 if ($value self::DISCOUNT_PERCENTAGE_MAX_VALUE) {
  272.                     $violationList->add($this->buildViolation(
  273.                         'Absolute value must not greater than ' self::DISCOUNT_PERCENTAGE_MAX_VALUE,
  274.                         $value,
  275.                         'value',
  276.                         'PROMOTION_DISCOUNT_MAX_VALUE_VIOLATION',
  277.                         $index
  278.                     ));
  279.                 }
  280.                 break;
  281.         }
  282.     }
  283.     /**
  284.      * Gets a value from an array. It also does clean checks if
  285.      * the key is set, and also provides the option for default values.
  286.      *
  287.      * @param array  $data  the data array
  288.      * @param string $key   the requested key in the array
  289.      * @param array  $dbRow the db row of from the database
  290.      *
  291.      * @return mixed the object found in the key, or the default value
  292.      */
  293.     private function getValue(array $datastring $key, array $dbRow)
  294.     {
  295.         // try in our actual data set
  296.         if (isset($data[$key])) {
  297.             return $data[$key];
  298.         }
  299.         // try in our db row fallback
  300.         if (isset($dbRow[$key])) {
  301.             return $dbRow[$key];
  302.         }
  303.         // use default
  304.         return null;
  305.     }
  306.     /**
  307.      * @throws ResourceNotFoundException
  308.      *
  309.      * @return array|mixed
  310.      */
  311.     private function getPromotionById(string $id)
  312.     {
  313.         /** @var array $promotion */
  314.         foreach ($this->databasePromotions as $promotion) {
  315.             if ($promotion['id'] === $id) {
  316.                 return $promotion;
  317.             }
  318.         }
  319.         throw new ResourceNotFoundException('promotion', [$id]);
  320.     }
  321.     /**
  322.      * @throws ResourceNotFoundException
  323.      *
  324.      * @return array|mixed
  325.      */
  326.     private function getDiscountById(string $id)
  327.     {
  328.         /** @var array $discount */
  329.         foreach ($this->databaseDiscounts as $discount) {
  330.             if ($discount['id'] === $id) {
  331.                 return $discount;
  332.             }
  333.         }
  334.         throw new ResourceNotFoundException('promotion_discount', [$id]);
  335.     }
  336.     /**
  337.      * This helper function builds an easy violation
  338.      * object for our validator.
  339.      *
  340.      * @param string $message      the error message
  341.      * @param mixed  $invalidValue the actual invalid value
  342.      * @param string $propertyPath the property path from the root value to the invalid value without initial slash
  343.      * @param string $code         the error code of the violation
  344.      * @param int    $index        the position of this entity in the command queue
  345.      *
  346.      * @return ConstraintViolationInterface the built constraint violation
  347.      */
  348.     private function buildViolation(string $message$invalidValuestring $propertyPathstring $codeint $index): ConstraintViolationInterface
  349.     {
  350.         $formattedPath "/{$index}/{$propertyPath}";
  351.         return new ConstraintViolation(
  352.             $message,
  353.             '',
  354.             [
  355.                 'value' => $invalidValue,
  356.             ],
  357.             $invalidValue,
  358.             $formattedPath,
  359.             $invalidValue,
  360.             null,
  361.             $code
  362.         );
  363.     }
  364.     /**
  365.      * True, if the provided pattern is already used in another promotion.
  366.      */
  367.     private function isCodePatternAlreadyUsed(string $pattern, ?string $promotionId): bool
  368.     {
  369.         $qb $this->connection->createQueryBuilder();
  370.         $query $qb
  371.             ->select('id')
  372.             ->from('promotion')
  373.             ->where($qb->expr()->eq('individual_code_pattern'':pattern'))
  374.             ->setParameter('pattern'$pattern);
  375.         $promotions $query->execute()->fetchAll();
  376.         /** @var array $p */
  377.         foreach ($promotions as $p) {
  378.             // if we have a promotion id to verify
  379.             // and a promotion with another id exists, then return that is used
  380.             if ($promotionId !== null && $p['id'] !== $promotionId) {
  381.                 return true;
  382.             }
  383.         }
  384.         return false;
  385.     }
  386.     /**
  387.      * True, if the provided code is already used as global
  388.      * or individual code in another promotion.
  389.      */
  390.     private function isCodeAlreadyUsed(string $code, ?string $promotionId): bool
  391.     {
  392.         $qb $this->connection->createQueryBuilder();
  393.         // check if individual code.
  394.         // if we dont have a promotion Id only
  395.         // check if its existing somewhere,
  396.         // if we have an Id, verify if it's existing in another promotion
  397.         $query $qb
  398.             ->select('id')
  399.             ->from('promotion_individual_code')
  400.             ->where($qb->expr()->eq('code'':code'))
  401.             ->setParameter('code'$code);
  402.         if ($promotionId !== null) {
  403.             $query->andWhere($qb->expr()->neq('promotion_id'':promotion_id'))
  404.                 ->setParameter('promotion_id'$promotionId);
  405.         }
  406.         $existingIndividual = \count($query->execute()->fetchAll()) > 0;
  407.         if ($existingIndividual) {
  408.             return true;
  409.         }
  410.         $qb $this->connection->createQueryBuilder();
  411.         // check if it is a global promotion code.
  412.         // again with either an existing promotion Id
  413.         // or without one.
  414.         $query
  415.             $qb->select('id')
  416.             ->from('promotion')
  417.             ->where($qb->expr()->eq('code'':code'))
  418.             ->setParameter('code'$code);
  419.         if ($promotionId !== null) {
  420.             $query->andWhere($qb->expr()->neq('id'':id'))
  421.                 ->setParameter('id'$promotionId);
  422.         }
  423.         return \count($query->execute()->fetchAll()) > 0;
  424.     }
  425. }