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

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