vendor/shopware/core/Content/Product/Cart/ProductCartProcessor.php line 124

Open in your IDE?
  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\Content\Product\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\CartProcessorInterface;
  7. use Shopware\Core\Checkout\Cart\Delivery\Struct\DeliveryInformation;
  8. use Shopware\Core\Checkout\Cart\Delivery\Struct\DeliveryTime;
  9. use Shopware\Core\Checkout\Cart\Exception\MissingLineItemPriceException;
  10. use Shopware\Core\Checkout\Cart\LineItem\CartDataCollection;
  11. use Shopware\Core\Checkout\Cart\LineItem\LineItem;
  12. use Shopware\Core\Checkout\Cart\LineItem\LineItemCollection;
  13. use Shopware\Core\Checkout\Cart\LineItem\QuantityInformation;
  14. use Shopware\Core\Checkout\Cart\Price\QuantityPriceCalculator;
  15. use Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice;
  16. use Shopware\Core\Checkout\Cart\Price\Struct\QuantityPriceDefinition;
  17. use Shopware\Core\Checkout\Cart\Price\Struct\ReferencePriceDefinition;
  18. use Shopware\Core\Content\Product\SalesChannel\Price\AbstractProductPriceCalculator;
  19. use Shopware\Core\Content\Product\SalesChannel\SalesChannelProductEntity;
  20. use Shopware\Core\Defaults;
  21. use Shopware\Core\Framework\DataAbstractionLayer\Cache\EntityCacheKeyGenerator;
  22. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  23. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\AndFilter;
  24. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
  25. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\OrFilter;
  26. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\RangeFilter;
  27. use Shopware\Core\Framework\Feature;
  28. use Shopware\Core\Profiling\Profiler;
  29. use Shopware\Core\System\SalesChannel\Entity\SalesChannelRepositoryInterface;
  30. use Shopware\Core\System\SalesChannel\SalesChannelContext;
  31. class ProductCartProcessor implements CartProcessorInterfaceCartDataCollectorInterface
  32. {
  33.     public const CUSTOM_PRICE 'customPrice';
  34.     public const ALLOW_PRODUCT_PRICE_OVERWRITES 'allowProductPriceOverwrites';
  35.     public const ALLOW_PRODUCT_LABEL_OVERWRITES 'allowProductLabelOverwrites';
  36.     public const SKIP_PRODUCT_RECALCULATION 'skipProductRecalculation';
  37.     public const SKIP_PRODUCT_STOCK_VALIDATION 'skipProductStockValidation';
  38.     public const KEEP_INACTIVE_PRODUCT 'keepInactiveProduct';
  39.     private ProductGatewayInterface $productGateway;
  40.     private QuantityPriceCalculator $calculator;
  41.     private ProductFeatureBuilder $featureBuilder;
  42.     private AbstractProductPriceCalculator $priceCalculator;
  43.     private EntityCacheKeyGenerator $generator;
  44.     private SalesChannelRepositoryInterface $repository;
  45.     /**
  46.      * @internal
  47.      */
  48.     public function __construct(
  49.         ProductGatewayInterface $productGateway,
  50.         QuantityPriceCalculator $calculator,
  51.         ProductFeatureBuilder $featureBuilder,
  52.         AbstractProductPriceCalculator $priceCalculator,
  53.         EntityCacheKeyGenerator $generator,
  54.         SalesChannelRepositoryInterface $repository
  55.     ) {
  56.         $this->productGateway $productGateway;
  57.         $this->calculator $calculator;
  58.         $this->featureBuilder $featureBuilder;
  59.         $this->priceCalculator $priceCalculator;
  60.         $this->generator $generator;
  61.         $this->repository $repository;
  62.     }
  63.     public function collect(CartDataCollection $dataCart $originalSalesChannelContext $contextCartBehavior $behavior): void
  64.     {
  65.         Profiler::trace('cart::product::collect', function () use ($data$original$context$behavior): void {
  66.             $lineItems $this->getProducts($original->getLineItems());
  67.             $items array_column($lineItems'item');
  68.             // find products in original cart which requires data from gateway
  69.             $ids $this->getNotCompleted($data$items$context);
  70.             if (!empty($ids)) {
  71.                 // fetch missing data over gateway
  72.                 $products $this->productGateway->get($ids$context);
  73.                 // add products to data collection
  74.                 foreach ($products as $product) {
  75.                     $data->set($this->getDataKey($product->getId()), $product);
  76.                 }
  77.                 $hash $this->generator->getSalesChannelContextHash($context);
  78.                 // refresh data timestamp to prevent unnecessary gateway calls
  79.                 foreach ($items as $lineItem) {
  80.                     if (\in_array($lineItem->getReferencedId(), $products->getIds(), true)) {
  81.                         $lineItem->setDataTimestamp(new \DateTimeImmutable());
  82.                         $lineItem->setDataContextHash($hash);
  83.                     }
  84.                 }
  85.             }
  86.             foreach ($lineItems as $match) {
  87.                 // enrich all products in original cart
  88.                 $this->enrich($context$match['item'], $data$behavior);
  89.                 // remove "parent" products which should never be displayed in storefront
  90.                 $this->validateParents($match['item'], $data$match['scope']);
  91.                 // validate data timestamps that inactive products (or not assigned to sales channel) are removed
  92.                 $this->validateTimestamp($match['item'], $original$data$behavior$match['scope']);
  93.                 // validate availability of the product stock
  94.                 $this->validateStock($match['item'], $original$match['scope'], $behavior);
  95.             }
  96.             $this->featureBuilder->prepare($items$data$context);
  97.         }, 'cart');
  98.     }
  99.     /**
  100.      * @throws MissingLineItemPriceException
  101.      */
  102.     public function process(CartDataCollection $dataCart $originalCart $toCalculateSalesChannelContext $contextCartBehavior $behavior): void
  103.     {
  104.         Profiler::trace('cart::product::process', function () use ($data$original$toCalculate$context): void {
  105.             $hash $this->generator->getSalesChannelContextHash($context);
  106.             $items $original->getLineItems()->filterFlatByType(LineItem::PRODUCT_LINE_ITEM_TYPE);
  107.             foreach ($items as $item) {
  108.                 $definition $item->getPriceDefinition();
  109.                 if (!$definition instanceof QuantityPriceDefinition) {
  110.                     throw new MissingLineItemPriceException($item->getId());
  111.                 }
  112.                 $definition->setQuantity($item->getQuantity());
  113.                 $item->setPrice($this->calculator->calculate($definition$context));
  114.                 $item->setDataContextHash($hash);
  115.             }
  116.             $this->featureBuilder->add($items$data$context);
  117.             // handle all products which stored in root level
  118.             $items $original->getLineItems()->filterType(LineItem::PRODUCT_LINE_ITEM_TYPE);
  119.             foreach ($items as $item) {
  120.                 $toCalculate->add($item);
  121.             }
  122.         }, 'cart');
  123.     }
  124.     /**
  125.      * @return list<array{'item': LineItem, 'scope': LineItemCollection}>
  126.      */
  127.     private function getProducts(LineItemCollection $items): array
  128.     {
  129.         $matches = [];
  130.         foreach ($items as $item) {
  131.             if ($item->getType() === LineItem::PRODUCT_LINE_ITEM_TYPE) {
  132.                 $matches[] = ['item' => $item'scope' => $items];
  133.             }
  134.             $nested $this->getProducts($item->getChildren());
  135.             foreach ($nested as $match) {
  136.                 $matches[] = $match;
  137.             }
  138.         }
  139.         return $matches;
  140.     }
  141.     private function validateTimestamp(LineItem $itemCart $cartCartDataCollection $dataCartBehavior $behaviorLineItemCollection $items): void
  142.     {
  143.         $product $data->get(
  144.             $this->getDataKey((string) $item->getReferencedId())
  145.         );
  146.         // product data was never detected and the product is not inside the data collection
  147.         if ($product !== null || $item->getDataTimestamp() !== null) {
  148.             return;
  149.         }
  150.         if ($behavior->hasPermission(self::KEEP_INACTIVE_PRODUCT)) {
  151.             return;
  152.         }
  153.         $cart->addErrors(new ProductNotFoundError($item->getLabel() ?: $item->getId()));
  154.         $items->remove($item->getId());
  155.     }
  156.     private function validateParents(LineItem $itemCartDataCollection $dataLineItemCollection $items): void
  157.     {
  158.         $product $data->get(
  159.             $this->getDataKey((string) $item->getReferencedId())
  160.         );
  161.         // no data for enrich exists
  162.         if (!$product instanceof SalesChannelProductEntity) {
  163.             return;
  164.         }
  165.         // container products can not be bought
  166.         if ($product->getChildCount() <= 0) {
  167.             return;
  168.         }
  169.         $items->remove($item->getId());
  170.     }
  171.     private function validateStock(LineItem $itemCart $cartLineItemCollection $scopeCartBehavior $behavior): void
  172.     {
  173.         if ($behavior->hasPermission(self::SKIP_PRODUCT_STOCK_VALIDATION)) {
  174.             return;
  175.         }
  176.         $minPurchase 1;
  177.         $steps 1;
  178.         $available $item->getQuantity();
  179.         if ($item->getQuantityInformation() !== null) {
  180.             $minPurchase $item->getQuantityInformation()->getMinPurchase();
  181.             $available $item->getQuantityInformation()->getMaxPurchase() ?? 0;
  182.             $steps $item->getQuantityInformation()->getPurchaseSteps() ?? 1;
  183.         }
  184.         if ($available $minPurchase) {
  185.             $scope->remove($item->getId());
  186.             $cart->addErrors(
  187.                 new ProductOutOfStockError((string) $item->getReferencedId(), (string) $item->getLabel())
  188.             );
  189.             return;
  190.         }
  191.         if ($available $item->getQuantity()) {
  192.             $maxAvailable $this->fixQuantity($minPurchase$available$steps);
  193.             $item->setQuantity($maxAvailable);
  194.             $cart->addErrors(
  195.                 new ProductStockReachedError((string) $item->getReferencedId(), (string) $item->getLabel(), $maxAvailable)
  196.             );
  197.             return;
  198.         }
  199.         if ($item->getQuantity() < $minPurchase) {
  200.             $item->setQuantity($minPurchase);
  201.             $cart->addErrors(
  202.                 new MinOrderQuantityError((string) $item->getReferencedId(), (string) $item->getLabel(), $minPurchase)
  203.             );
  204.             return;
  205.         }
  206.         $fixedQuantity $this->fixQuantity($minPurchase$item->getQuantity(), $steps);
  207.         if ($item->getQuantity() !== $fixedQuantity) {
  208.             $item->setQuantity($fixedQuantity);
  209.             $cart->addErrors(
  210.                 new PurchaseStepsError((string) $item->getReferencedId(), (string) $item->getLabel(), $fixedQuantity)
  211.             );
  212.         }
  213.     }
  214.     private function enrich(SalesChannelContext $contextLineItem $lineItemCartDataCollection $dataCartBehavior $behavior): void
  215.     {
  216.         $id $lineItem->getReferencedId();
  217.         $product $data->get(
  218.             $this->getDataKey((string) $id)
  219.         );
  220.         // no data for enrich exists
  221.         if (!$product instanceof SalesChannelProductEntity) {
  222.             return;
  223.         }
  224.         $label trim($lineItem->getLabel() ?? '');
  225.         $name $product->getTranslation('name');
  226.         // set the label if its empty or the context does not have the permission to overwrite it
  227.         if ($label === '' || !$behavior->hasPermission(self::ALLOW_PRODUCT_LABEL_OVERWRITES)) {
  228.             $lineItem->setLabel($product->getTranslation('name'));
  229.         }
  230.         if ($product->getCover()) {
  231.             $lineItem->setCover($product->getCover()->getMedia());
  232.         }
  233.         $deliveryTime null;
  234.         if ($product->getDeliveryTime() !== null) {
  235.             $deliveryTime DeliveryTime::createFromEntity($product->getDeliveryTime());
  236.         }
  237.         $weight $product->getWeight();
  238.         if (!Feature::isActive('v6.5.0.0')) {
  239.             $weight = (float) $weight;
  240.         }
  241.         $lineItem->setDeliveryInformation(
  242.             new DeliveryInformation(
  243.                 (int) $product->getAvailableStock(),
  244.                 $weight,
  245.                 $product->getShippingFree() === true,
  246.                 $product->getRestockTime(),
  247.                 $deliveryTime,
  248.                 $product->getHeight(),
  249.                 $product->getWidth(),
  250.                 $product->getLength()
  251.             )
  252.         );
  253.         //Check if the price has to be updated
  254.         if ($this->shouldPriceBeRecalculated($lineItem$behavior)) {
  255.             $lineItem->setPriceDefinition(
  256.                 $this->getPriceDefinition($product$context$lineItem->getQuantity())
  257.             );
  258.         }
  259.         $quantityInformation = new QuantityInformation();
  260.         $quantityInformation->setMinPurchase(
  261.             $product->getMinPurchase() ?? 1
  262.         );
  263.         $quantityInformation->setMaxPurchase(
  264.             $product->getCalculatedMaxPurchase()
  265.         );
  266.         $quantityInformation->setPurchaseSteps(
  267.             $product->getPurchaseSteps() ?? 1
  268.         );
  269.         $lineItem->setQuantityInformation($quantityInformation);
  270.         $purchasePrices null;
  271.         $purchasePricesCollection $product->getPurchasePrices();
  272.         if ($purchasePricesCollection !== null) {
  273.             $purchasePrices $purchasePricesCollection->getCurrencyPrice(Defaults::CURRENCY);
  274.         }
  275.         $payload = [
  276.             'isCloseout' => $product->getIsCloseout(),
  277.             'customFields' => $product->getCustomFields(),
  278.             'createdAt' => $product->getCreatedAt() ? $product->getCreatedAt()->format(Defaults::STORAGE_DATE_TIME_FORMAT) : null,
  279.             'releaseDate' => $product->getReleaseDate() ? $product->getReleaseDate()->format(Defaults::STORAGE_DATE_TIME_FORMAT) : null,
  280.             'isNew' => $product->isNew(),
  281.             'markAsTopseller' => $product->getMarkAsTopseller(),
  282.             'purchasePrices' => $purchasePrices json_encode($purchasePrices) : null,
  283.             'productNumber' => $product->getProductNumber(),
  284.             'manufacturerId' => $product->getManufacturerId(),
  285.             'taxId' => $product->getTaxId(),
  286.             'tagIds' => $product->getTagIds(),
  287.             'categoryIds' => $product->getCategoryTree(),
  288.             'propertyIds' => $product->getPropertyIds(),
  289.             'optionIds' => $product->getOptionIds(),
  290.             'options' => $product->getVariation(),
  291.             'streamIds' => $product->getStreamIds(),
  292.             'parentId' => $product->getParentId(),
  293.             'stock' => $product->getStock(),
  294.         ];
  295.         $lineItem->replacePayload($payload);
  296.     }
  297.     private function getPriceDefinition(SalesChannelProductEntity $productSalesChannelContext $contextint $quantity): QuantityPriceDefinition
  298.     {
  299.         $this->priceCalculator->calculate([$product], $context);
  300.         if ($product->getCalculatedPrices()->count() === 0) {
  301.             return $this->buildPriceDefinition($product->getCalculatedPrice(), $quantity);
  302.         }
  303.         // keep loop reference to $price variable to get last quantity price in case of "null"
  304.         $price $product->getCalculatedPrice();
  305.         foreach ($product->getCalculatedPrices() as $price) {
  306.             if ($quantity <= $price->getQuantity()) {
  307.                 break;
  308.             }
  309.         }
  310.         return $this->buildPriceDefinition($price$quantity);
  311.     }
  312.     private function buildPriceDefinition(CalculatedPrice $priceint $quantity): QuantityPriceDefinition
  313.     {
  314.         $definition = new QuantityPriceDefinition($price->getUnitPrice(), $price->getTaxRules(), $quantity);
  315.         if ($price->getListPrice() !== null) {
  316.             $definition->setListPrice($price->getListPrice()->getPrice());
  317.         }
  318.         if ($price->getReferencePrice() !== null) {
  319.             $definition->setReferencePriceDefinition(
  320.                 new ReferencePriceDefinition(
  321.                     $price->getReferencePrice()->getPurchaseUnit(),
  322.                     $price->getReferencePrice()->getReferenceUnit(),
  323.                     $price->getReferencePrice()->getUnitName()
  324.                 )
  325.             );
  326.         }
  327.         return $definition;
  328.     }
  329.     private function getNotCompleted(CartDataCollection $data, array $lineItemsSalesChannelContext $context): array
  330.     {
  331.         $ids = [];
  332.         $changes = [];
  333.         $hash $this->generator->getSalesChannelContextHash($context);
  334.         /** @var LineItem $lineItem */
  335.         foreach ($lineItems as $lineItem) {
  336.             $id $lineItem->getReferencedId();
  337.             $key $this->getDataKey((string) $id);
  338.             // data already fetched?
  339.             if ($data->has($key)) {
  340.                 continue;
  341.             }
  342.             // user change line item quantity or price?
  343.             if ($lineItem->isModified()) {
  344.                 $ids[] = $id;
  345.                 continue;
  346.             }
  347.             if ($lineItem->getDataTimestamp() === null) {
  348.                 $ids[] = $id;
  349.                 continue;
  350.             }
  351.             if ($lineItem->getDataContextHash() !== $hash) {
  352.                 $ids[] = $id;
  353.                 continue;
  354.             }
  355.             // check if some data is missing (label, price, cover)
  356.             if (!$this->isComplete($lineItem)) {
  357.                 $ids[] = $id;
  358.                 continue;
  359.             }
  360.             // @internal (flag:FEATURE_NEXT_13250) - The IF must be removed so that $changes is filled
  361.             if (!Feature::isActive('FEATURE_NEXT_13250')) {
  362.                 $ids[] = $id;
  363.                 continue;
  364.             }
  365.             $changes[$id] = $lineItem->getDataTimestamp()->format(Defaults::STORAGE_DATE_TIME_FORMAT);
  366.         }
  367.         // @internal (flag:FEATURE_NEXT_13250) - The IF can be removed completely so that $changes is taken into account.
  368.         if (!Feature::isActive('FEATURE_NEXT_13250')) {
  369.             return $ids;
  370.         }
  371.         if (empty($changes)) {
  372.             return $ids;
  373.         }
  374.         $filter = new OrFilter();
  375.         foreach ($changes as $id => $timestamp) {
  376.             $filter->addQuery(new AndFilter([
  377.                 new EqualsFilter('product.id'$id),
  378.                 new RangeFilter('updatedAt', [
  379.                     RangeFilter::GTE => $timestamp,
  380.                 ]),
  381.             ]));
  382.         }
  383.         $criteria = new Criteria();
  384.         $criteria->setTitle('cart::products::not-completed');
  385.         $criteria->addFilter($filter);
  386.         $changed $this->repository->searchIds($criteria$context)->getIds();
  387.         return array_filter(array_unique(array_merge($ids$changed)));
  388.     }
  389.     private function isComplete(LineItem $lineItem): bool
  390.     {
  391.         return $lineItem->getPriceDefinition() !== null
  392.             && $lineItem->getLabel() !== null
  393.             && $lineItem->getDeliveryInformation() !== null
  394.             && $lineItem->getQuantityInformation() !== null;
  395.     }
  396.     private function shouldPriceBeRecalculated(LineItem $lineItemCartBehavior $behavior): bool
  397.     {
  398.         if ($lineItem->getPriceDefinition() !== null
  399.             && $lineItem->hasExtension(self::CUSTOM_PRICE)
  400.             && $behavior->hasPermission(self::ALLOW_PRODUCT_PRICE_OVERWRITES)) {
  401.             return false;
  402.         }
  403.         if ($lineItem->getPriceDefinition() !== null
  404.             && $behavior->hasPermission(self::SKIP_PRODUCT_RECALCULATION)) {
  405.             return false;
  406.         }
  407.         return true;
  408.     }
  409.     private function fixQuantity(int $minint $currentint $steps): int
  410.     {
  411.         return (int) (floor(($current $min) / $steps) * $steps $min);
  412.     }
  413.     private function getDataKey(string $id): string
  414.     {
  415.         return 'product-' $id;
  416.     }
  417. }