vendor/shopware/core/Checkout/Promotion/Cart/PromotionCollector.php line 79

Open in your IDE?
  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\Checkout\Promotion\Cart;
  3. use Shopware\Core\Checkout\Cart\Cart;
  4. use Shopware\Core\Checkout\Cart\CartBehavior;
  5. use Shopware\Core\Checkout\Cart\CartDataCollectorInterface;
  6. use Shopware\Core\Checkout\Cart\Exception\InvalidPayloadException;
  7. use Shopware\Core\Checkout\Cart\Exception\InvalidQuantityException;
  8. use Shopware\Core\Checkout\Cart\LineItem\CartDataCollection;
  9. use Shopware\Core\Checkout\Cart\LineItem\LineItem;
  10. use Shopware\Core\Checkout\Cart\LineItem\LineItemCollection;
  11. use Shopware\Core\Checkout\Customer\CustomerEntity;
  12. use Shopware\Core\Checkout\Promotion\Aggregate\PromotionDiscount\PromotionDiscountCollection;
  13. use Shopware\Core\Checkout\Promotion\Cart\Extension\CartExtension;
  14. use Shopware\Core\Checkout\Promotion\Exception\UnknownPromotionDiscountTypeException;
  15. use Shopware\Core\Checkout\Promotion\Gateway\PromotionGatewayInterface;
  16. use Shopware\Core\Checkout\Promotion\Gateway\Template\PermittedAutomaticPromotions;
  17. use Shopware\Core\Checkout\Promotion\Gateway\Template\PermittedGlobalCodePromotions;
  18. use Shopware\Core\Checkout\Promotion\Gateway\Template\PermittedIndividualCodePromotions;
  19. use Shopware\Core\Checkout\Promotion\PromotionCollection;
  20. use Shopware\Core\Checkout\Promotion\PromotionEntity;
  21. use Shopware\Core\Framework\DataAbstractionLayer\Exception\InconsistentCriteriaIdsException;
  22. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  23. use Shopware\Core\Framework\Log\Package;
  24. use Shopware\Core\Framework\Util\HtmlSanitizer;
  25. use Shopware\Core\Profiling\Profiler;
  26. use Shopware\Core\System\SalesChannel\SalesChannelContext;
  27. #[Package('checkout')]
  28. class PromotionCollector implements CartDataCollectorInterface
  29. {
  30.     use PromotionCartInformationTrait;
  31.     public const SKIP_PROMOTION 'skipPromotion';
  32.     public const SKIP_AUTOMATIC_PROMOTIONS 'skipAutomaticPromotions';
  33.     private PromotionGatewayInterface $gateway;
  34.     private PromotionItemBuilder $itemBuilder;
  35.     private HtmlSanitizer $htmlSanitizer;
  36.     private array $requiredDalAssociations;
  37.     /**
  38.      * @internal
  39.      */
  40.     public function __construct(PromotionGatewayInterface $gatewayPromotionItemBuilder $itemBuilderHtmlSanitizer $htmlSanitizer)
  41.     {
  42.         $this->gateway $gateway;
  43.         $this->itemBuilder $itemBuilder;
  44.         $this->htmlSanitizer $htmlSanitizer;
  45.         $this->requiredDalAssociations = [
  46.             'personaRules',
  47.             'personaCustomers',
  48.             'cartRules',
  49.             'orderRules',
  50.             'discounts.discountRules',
  51.             'discounts.promotionDiscountPrices',
  52.             'setgroups',
  53.             'setgroups.setGroupRules',
  54.         ];
  55.     }
  56.     /**
  57.      * This function is used to collect our promotion data for our cart.
  58.      * It queries the database for all promotions with codes within our cart extension
  59.      * along with all non-code promotions that are applied automatically if conditions are met.
  60.      * The eligible promotions will then be used in the enrichment process and converted
  61.      * into Line Items which will be passed on to the next processor.
  62.      *
  63.      * @throws InvalidPayloadException
  64.      * @throws InvalidQuantityException
  65.      * @throws UnknownPromotionDiscountTypeException
  66.      * @throws InconsistentCriteriaIdsException
  67.      */
  68.     public function collect(CartDataCollection $dataCart $originalSalesChannelContext $contextCartBehavior $behavior): void
  69.     {
  70.         Profiler::trace('cart::promotion::collect', function () use ($data$original$context$behavior): void {
  71.             // The promotions have a special function:
  72.             // If the user comes to the shop via a promotion link, a discount is to be placed in the cart.
  73.             // However, this cannot be applied directly, because it does not yet have any items in the cart.
  74.             // Therefore the code is stored in the extension and as soon
  75.             // as the user has enough items in the cart, it is added again.
  76.             $cartExtension $original->getExtension(CartExtension::KEY);
  77.             if (!$cartExtension instanceof CartExtension) {
  78.                 $cartExtension = new CartExtension();
  79.                 $original->addExtension(CartExtension::KEY$cartExtension);
  80.             }
  81.             // if we are in recalculation,
  82.             // we must not re-add any promotions. just leave it as it is.
  83.             if ($behavior->hasPermission(self::SKIP_PROMOTION)) {
  84.                 return;
  85.             }
  86.             // now get the codes from our configuration
  87.             // and also from our line items (that already exist)
  88.             // and merge them both into a flat list
  89.             $extensionCodes $cartExtension->getCodes();
  90.             $cartCodes $original->getLineItems()->filterType(PromotionProcessor::LINE_ITEM_TYPE)->getReferenceIds();
  91.             $allCodes array_unique(array_merge(array_values($cartCodes), $extensionCodes));
  92.             $allPromotions $this->searchPromotionsByCodes($data$allCodes$context);
  93.             if (!$behavior->hasPermission(self::SKIP_AUTOMATIC_PROMOTIONS)) {
  94.                 // add auto promotions
  95.                 $allPromotions->addAutomaticPromotions($this->searchPromotionsAuto($data$context));
  96.             }
  97.             // check if max allowed redemption of promotion have been reached or not
  98.             // if max redemption has been reached promotion will not be added
  99.             $allPromotions $this->getEligiblePromotionsWithDiscounts($allPromotions$context->getCustomer());
  100.             $discountLineItems = [];
  101.             $foundCodes = [];
  102.             /** @var PromotionCodeTuple $tuple */
  103.             foreach ($allPromotions->getPromotionCodeTuples() as $tuple) {
  104.                 // verify if the user might have removed and "blocked"
  105.                 // the promotion from being added again
  106.                 if ($cartExtension->isPromotionBlocked($tuple->getPromotion()->getId())) {
  107.                     continue;
  108.                 }
  109.                 // lets build separate line items for each
  110.                 // of the available discounts within the current promotion
  111.                 $lineItems $this->buildDiscountLineItems($tuple->getCode(), $tuple->getPromotion(), $original$context);
  112.                 // add to our list of all line items
  113.                 /** @var LineItem $nested */
  114.                 foreach ($lineItems as $nested) {
  115.                     $discountLineItems[] = $nested;
  116.                 }
  117.                 // we need the list of found codes
  118.                 // for our NotFound errors below
  119.                 $foundCodes[] = $tuple->getCode();
  120.             }
  121.             // now iterate through all codes that have been added
  122.             // and add errors, if a promotion for that code couldn't be found
  123.             foreach ($allCodes as $code) {
  124.                 if (!\in_array($code$foundCodestrue)) {
  125.                     $cartExtension->removeCode($code);
  126.                     $this->addPromotionNotFoundError($this->htmlSanitizer->sanitize($codenulltrue), $original);
  127.                 }
  128.             }
  129.             // if we do have promotions, set them to be processed
  130.             // otherwise make sure to remove the entry to avoid any processing
  131.             // within our promotions scope
  132.             if (\count($discountLineItems) > 0) {
  133.                 $data->set(PromotionProcessor::DATA_KEY, new LineItemCollection($discountLineItems));
  134.             } else {
  135.                 $data->remove(PromotionProcessor::DATA_KEY);
  136.             }
  137.         }, 'cart');
  138.     }
  139.     /**
  140.      * Gets either the cached list of auto-promotions that
  141.      * are valid, or loads them from the database.
  142.      *
  143.      * @throws InconsistentCriteriaIdsException
  144.      */
  145.     private function searchPromotionsAuto(CartDataCollection $dataSalesChannelContext $context): array
  146.     {
  147.         if ($data->has('promotions-auto')) {
  148.             return $data->get('promotions-auto');
  149.         }
  150.         $criteria = (new Criteria())->addFilter(new PermittedAutomaticPromotions($context->getSalesChannel()->getId()));
  151.         /** @var string $association */
  152.         foreach ($this->requiredDalAssociations as $association) {
  153.             $criteria->addAssociation($association);
  154.         }
  155.         /** @var PromotionCollection $automaticPromotions */
  156.         $automaticPromotions $this->gateway->get($criteria$context);
  157.         $data->set('promotions-auto'$automaticPromotions->getElements());
  158.         return $automaticPromotions->getElements();
  159.     }
  160.     /**
  161.      * Gets all promotions by using the provided list of codes.
  162.      * The promotions will be either taken from a cached list of a previous call,
  163.      * or are loaded directly from the database if a certain code is new
  164.      * and has not yet been fetched.
  165.      *
  166.      * @throws InconsistentCriteriaIdsException
  167.      */
  168.     private function searchPromotionsByCodes(CartDataCollection $data, array $allCodesSalesChannelContext $context): CartPromotionsDataDefinition
  169.     {
  170.         $keyCacheList 'promotions-code';
  171.         // create a new cached list that is empty at first
  172.         if (!$data->has($keyCacheList)) {
  173.             $data->set($keyCacheList, new CartPromotionsDataDefinition());
  174.         }
  175.         // load it
  176.         /** @var CartPromotionsDataDefinition $promotionsList */
  177.         $promotionsList $data->get($keyCacheList);
  178.         // our data is a runtime cached structure.
  179.         // but when line items get removed, the collect function gets called multiple times.
  180.         // in the first iterations we still have a promotion code item
  181.         // and then it is suddenly gone. so we also have to remove
  182.         // entities from our cache if the code is suddenly not provided anymore.
  183.         /*
  184.          * @var string
  185.          */
  186.         foreach ($promotionsList->getAllCodes() as $code) {
  187.             // if code is not existing anymore,
  188.             // make sure to remove it in our list
  189.             if (!\in_array($code$allCodestrue)) {
  190.                 $promotionsList->removeCode((string) $code);
  191.             }
  192.         }
  193.         $codesToFetch = [];
  194.         // let's find out what promotions we
  195.         // really need to fetch from our database.
  196.         foreach ($allCodes as $code) {
  197.             // check if promotion is already cached
  198.             if ($promotionsList->hasCode($code)) {
  199.                 continue;
  200.             }
  201.             // fetch that new code
  202.             $codesToFetch[] = $code;
  203.             // add a new entry with null
  204.             // so if we cant fetch it, we do at least
  205.             // tell our cache that we have tried it
  206.             $promotionsList->addCodePromotions($code, []);
  207.         }
  208.         // if we have new codes to fetch
  209.         // make sure to load it and assign it to
  210.         // the code in our cache list.
  211.         if (\count($codesToFetch) > 0) {
  212.             $salesChannelId $context->getSalesChannel()->getId();
  213.             foreach ($codesToFetch as $currentCode) {
  214.                 // try to find a global code first because
  215.                 // that search has less data involved
  216.                 $globalCriteria = (new Criteria())->addFilter(new PermittedGlobalCodePromotions([$currentCode], $salesChannelId));
  217.                 /** @var string $association */
  218.                 foreach ($this->requiredDalAssociations as $association) {
  219.                     $globalCriteria->addAssociation($association);
  220.                 }
  221.                 /** @var PromotionCollection $foundPromotions */
  222.                 $foundPromotions $this->gateway->get($globalCriteria$context);
  223.                 if (\count($foundPromotions->getElements()) <= 0) {
  224.                     // no global code, so try with an individual code instead
  225.                     $individualCriteria = (new Criteria())->addFilter(new PermittedIndividualCodePromotions([$currentCode], $salesChannelId));
  226.                     /** @var string $association */
  227.                     foreach ($this->requiredDalAssociations as $association) {
  228.                         $individualCriteria->addAssociation($association);
  229.                     }
  230.                     /** @var PromotionCollection $foundPromotions */
  231.                     $foundPromotions $this->gateway->get($individualCriteria$context);
  232.                 }
  233.                 // if we finally have found promotions add them to our list for the current code
  234.                 if (\count($foundPromotions->getElements()) > 0) {
  235.                     $promotionsList->addCodePromotions($currentCode$foundPromotions->getElements());
  236.                 }
  237.             }
  238.         }
  239.         // update our cached list with the latest cleaned array
  240.         $data->set($keyCacheList$promotionsList);
  241.         return $promotionsList;
  242.     }
  243.     /**
  244.      * function returns all promotions that have discounts and that are eligible
  245.      * (function validates that max usage or customer max usage hasn't exceeded)
  246.      */
  247.     private function getEligiblePromotionsWithDiscounts(CartPromotionsDataDefinition $dataDefinition, ?CustomerEntity $customer): CartPromotionsDataDefinition
  248.     {
  249.         $result = new CartPromotionsDataDefinition();
  250.         // we now have a list of promotions that could be added to our cart.
  251.         // verify if they have any discounts. if so, add them to our
  252.         // data struct, which ensures that they will be added later in the enrichment process.
  253.         /** @var PromotionCodeTuple $tuple */
  254.         foreach ($dataDefinition->getPromotionCodeTuples() as $tuple) {
  255.             $promotion $tuple->getPromotion();
  256.             if (!$promotion->isOrderCountValid()) {
  257.                 continue;
  258.             }
  259.             if ($customer !== null && !$promotion->isOrderCountPerCustomerCountValid($customer->getId())) {
  260.                 continue;
  261.             }
  262.             // check if no discounts have been set
  263.             if (!$promotion->hasDiscount()) {
  264.                 continue;
  265.             }
  266.             // now add it to our result definition object.
  267.             // we also have to remember the code that has been
  268.             // used for a particular promotion (if promotion is type of code).
  269.             // that's why we differ between automatic and code
  270.             if (empty($tuple->getCode())) {
  271.                 $result->addAutomaticPromotions([$promotion]);
  272.             } else {
  273.                 $result->addCodePromotions($tuple->getCode(), [$promotion]);
  274.             }
  275.         }
  276.         return $result;
  277.     }
  278.     /**
  279.      * This function builds separate line items for each of the
  280.      * available discounts within the provided promotion.
  281.      * Every item will be built with a corresponding price definition based
  282.      * on the configuration of a discount entity.
  283.      * The resulting list of line items will then be returned and can be added to the cart.
  284.      * The function will already avoid duplicate entries.
  285.      *
  286.      * @throws InvalidPayloadException
  287.      * @throws InvalidQuantityException
  288.      * @throws UnknownPromotionDiscountTypeException
  289.      */
  290.     private function buildDiscountLineItems(string $codePromotionEntity $promotionCart $cartSalesChannelContext $context): array
  291.     {
  292.         $collection $promotion->getDiscounts();
  293.         if (!$collection instanceof PromotionDiscountCollection) {
  294.             return [];
  295.         }
  296.         $lineItems = [];
  297.         foreach ($collection->getElements() as $discount) {
  298.             $itemIds $this->getAllLineItemIds($cart);
  299.             // add a new discount line item for this discount
  300.             // if we have at least one valid item that will be discounted.
  301.             if (\count($itemIds) <= 0) {
  302.                 continue;
  303.             }
  304.             $factor 1.0;
  305.             if (!$context->getCurrency()->getIsSystemDefault()) {
  306.                 $factor $context->getCurrency()->getFactor();
  307.             }
  308.             $discountItem $this->itemBuilder->buildDiscountLineItem(
  309.                 $code,
  310.                 $promotion,
  311.                 $discount,
  312.                 $context->getCurrency()->getId(),
  313.                 $factor
  314.             );
  315.             $originalCodeItem $cart->getLineItems()->filter(function (LineItem $item) use ($code) {
  316.                 if ($item->getReferencedId() === $code) {
  317.                     return $item;
  318.                 }
  319.                 return null;
  320.             })->first();
  321.             if ($originalCodeItem && \count($originalCodeItem->getExtensions()) > 0) {
  322.                 $discountItem->setExtensions($originalCodeItem->getExtensions());
  323.             }
  324.             $lineItems[] = $discountItem;
  325.         }
  326.         return $lineItems;
  327.     }
  328.     private function getAllLineItemIds(Cart $cart): array
  329.     {
  330.         return $cart->getLineItems()->fmap(
  331.             static function (LineItem $lineItem) {
  332.                 if ($lineItem->getType() === PromotionProcessor::LINE_ITEM_TYPE) {
  333.                     return null;
  334.                 }
  335.                 return $lineItem->getId();
  336.             }
  337.         );
  338.     }
  339. }