vendor/shopware/core/Checkout/Cart/CartRuleLoader.php line 87

  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\Checkout\Cart;
  3. use Doctrine\DBAL\Connection;
  4. use Psr\Log\LoggerInterface;
  5. use Shopware\Core\Checkout\Cart\Event\CartCreatedEvent;
  6. use Shopware\Core\Checkout\Cart\Exception\CartTokenNotFoundException;
  7. use Shopware\Core\Checkout\Cart\LineItem\LineItem;
  8. use Shopware\Core\Checkout\Cart\Price\Struct\CartPrice;
  9. use Shopware\Core\Checkout\Cart\Tax\TaxDetector;
  10. use Shopware\Core\Content\Rule\RuleCollection;
  11. use Shopware\Core\Defaults;
  12. use Shopware\Core\Framework\Context;
  13. use Shopware\Core\Framework\DataAbstractionLayer\Exception\EntityNotFoundException;
  14. use Shopware\Core\Framework\Log\Package;
  15. use Shopware\Core\Framework\Util\FloatComparator;
  16. use Shopware\Core\Framework\Uuid\Uuid;
  17. use Shopware\Core\Profiling\Profiler;
  18. use Shopware\Core\System\Country\CountryDefinition;
  19. use Shopware\Core\System\Country\CountryEntity;
  20. use Shopware\Core\System\SalesChannel\SalesChannelContext;
  21. use Symfony\Contracts\Cache\CacheInterface;
  22. use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
  23. use Symfony\Contracts\Service\ResetInterface;
  24. #[Package('checkout')]
  25. class CartRuleLoader implements ResetInterface
  26. {
  27.     private const MAX_ITERATION 7;
  28.     private ?RuleCollection $rules null;
  29.     /**
  30.      * @var array<string, float>
  31.      */
  32.     private array $currencyFactor = [];
  33.     /**
  34.      * @internal
  35.      */
  36.     public function __construct(
  37.         private readonly AbstractCartPersister $cartPersister,
  38.         private readonly Processor $processor,
  39.         private readonly LoggerInterface $logger,
  40.         private readonly CacheInterface $cache,
  41.         private readonly AbstractRuleLoader $ruleLoader,
  42.         private readonly TaxDetector $taxDetector,
  43.         private readonly Connection $connection,
  44.         private readonly EventDispatcherInterface $dispatcher
  45.     ) {
  46.     }
  47.     public function loadByToken(SalesChannelContext $contextstring $cartToken): RuleLoaderResult
  48.     {
  49.         try {
  50.             $cart $this->cartPersister->load($cartToken$context);
  51.             return $this->load($context$cart, new CartBehavior($context->getPermissions()), false);
  52.         } catch (CartTokenNotFoundException) {
  53.             $cart = new Cart($cartToken);
  54.             $this->dispatcher->dispatch(new CartCreatedEvent($cart));
  55.             return $this->load($context$cart, new CartBehavior($context->getPermissions()), true);
  56.         }
  57.     }
  58.     public function loadByCart(SalesChannelContext $contextCart $cartCartBehavior $behaviorContextbool $isNew false): RuleLoaderResult
  59.     {
  60.         return $this->load($context$cart$behaviorContext$isNew);
  61.     }
  62.     public function reset(): void
  63.     {
  64.         $this->rules null;
  65.     }
  66.     public function invalidate(): void
  67.     {
  68.         $this->reset();
  69.         $this->cache->delete(CachedRuleLoader::CACHE_KEY);
  70.     }
  71.     private function load(SalesChannelContext $contextCart $cartCartBehavior $behaviorContextbool $new): RuleLoaderResult
  72.     {
  73.         return Profiler::trace('cart-rule-loader', function () use ($context$cart$behaviorContext$new) {
  74.             $rules $this->loadRules($context->getContext());
  75.             // save all rules for later usage
  76.             $all $rules;
  77.             $ids $new $rules->getIds() : $cart->getRuleIds();
  78.             // update rules in current context
  79.             $context->setRuleIds($ids);
  80.             $iteration 1;
  81.             $timestamps $cart->getLineItems()->fmap(function (LineItem $lineItem) {
  82.                 if ($lineItem->getDataTimestamp() === null) {
  83.                     return null;
  84.                 }
  85.                 return $lineItem->getDataTimestamp()->format(Defaults::STORAGE_DATE_TIME_FORMAT);
  86.             });
  87.             // start first cart calculation to have all objects enriched
  88.             $cart $this->processor->process($cart$context$behaviorContext);
  89.             do {
  90.                 $compare $cart;
  91.                 if ($iteration self::MAX_ITERATION) {
  92.                     break;
  93.                 }
  94.                 // filter rules which matches to current scope
  95.                 $rules $rules->filterMatchingRules($cart$context);
  96.                 // update matching rules in context
  97.                 $context->setRuleIds($rules->getIds());
  98.                 // calculate cart again
  99.                 $cart $this->processor->process($cart$context$behaviorContext);
  100.                 // check if the cart changed, in this case we have to recalculate the cart again
  101.                 $recalculate $this->cartChanged($cart$compare);
  102.                 // check if rules changed for the last calculated cart, in this case we have to recalculate
  103.                 $ruleCompare $all->filterMatchingRules($cart$context);
  104.                 if (!$rules->equals($ruleCompare)) {
  105.                     $recalculate true;
  106.                     $rules $ruleCompare;
  107.                 }
  108.                 ++$iteration;
  109.             } while ($recalculate);
  110.             $cart $this->validateTaxFree($context$cart$behaviorContext);
  111.             $index 0;
  112.             foreach ($rules as $rule) {
  113.                 ++$index;
  114.                 $this->logger->info(
  115.                     sprintf('#%d Rule detection: %s with priority %d (id: %s)'$index$rule->getName(), $rule->getPriority(), $rule->getId())
  116.                 );
  117.             }
  118.             $context->setRuleIds($rules->getIds());
  119.             $context->setAreaRuleIds($rules->getIdsByArea());
  120.             // save the cart if errors exist, so the errors get persisted
  121.             if ($cart->getErrors()->count() > || $this->updated($cart$timestamps)) {
  122.                 $this->cartPersister->save($cart$context);
  123.             }
  124.             return new RuleLoaderResult($cart$rules);
  125.         });
  126.     }
  127.     private function loadRules(Context $context): RuleCollection
  128.     {
  129.         if ($this->rules !== null) {
  130.             return $this->rules;
  131.         }
  132.         return $this->rules $this->ruleLoader->load($context)->filterForContext();
  133.     }
  134.     private function cartChanged(Cart $previousCart $current): bool
  135.     {
  136.         $previousLineItems $previous->getLineItems();
  137.         $currentLineItems $current->getLineItems();
  138.         return $previousLineItems->count() !== $currentLineItems->count()
  139.             || $previous->getPrice()->getTotalPrice() !== $current->getPrice()->getTotalPrice()
  140.             || $previousLineItems->getKeys() !== $currentLineItems->getKeys()
  141.             || $previousLineItems->getTypes() !== $currentLineItems->getTypes()
  142.         ;
  143.     }
  144.     private function detectTaxType(SalesChannelContext $contextfloat $cartNetAmount 0): string
  145.     {
  146.         $currency $context->getCurrency();
  147.         $currencyTaxFreeAmount $currency->getTaxFreeFrom();
  148.         $isReachedCurrencyTaxFreeAmount $currencyTaxFreeAmount && $cartNetAmount >= $currencyTaxFreeAmount;
  149.         if ($isReachedCurrencyTaxFreeAmount) {
  150.             return CartPrice::TAX_STATE_FREE;
  151.         }
  152.         $country $context->getShippingLocation()->getCountry();
  153.         $isReachedCustomerTaxFreeAmount $country->getCustomerTax()->getEnabled() && $this->isReachedCountryTaxFreeAmount($context$country$cartNetAmount);
  154.         $isReachedCompanyTaxFreeAmount $this->taxDetector->isCompanyTaxFree($context$country) && $this->isReachedCountryTaxFreeAmount($context$country$cartNetAmountCountryDefinition::TYPE_COMPANY_TAX_FREE);
  155.         if ($isReachedCustomerTaxFreeAmount || $isReachedCompanyTaxFreeAmount) {
  156.             return CartPrice::TAX_STATE_FREE;
  157.         }
  158.         if ($this->taxDetector->useGross($context)) {
  159.             return CartPrice::TAX_STATE_GROSS;
  160.         }
  161.         return CartPrice::TAX_STATE_NET;
  162.     }
  163.     /**
  164.      * @param array<string, string> $timestamps
  165.      */
  166.     private function updated(Cart $cart, array $timestamps): bool
  167.     {
  168.         foreach ($cart->getLineItems() as $lineItem) {
  169.             if (!isset($timestamps[$lineItem->getId()])) {
  170.                 return true;
  171.             }
  172.             $original $timestamps[$lineItem->getId()];
  173.             $timestamp $lineItem->getDataTimestamp() !== null $lineItem->getDataTimestamp()->format(Defaults::STORAGE_DATE_TIME_FORMAT) : null;
  174.             if ($original !== $timestamp) {
  175.                 return true;
  176.             }
  177.         }
  178.         return \count($timestamps) !== $cart->getLineItems()->count();
  179.     }
  180.     private function isReachedCountryTaxFreeAmount(
  181.         SalesChannelContext $context,
  182.         CountryEntity $country,
  183.         float $cartNetAmount 0,
  184.         string $taxFreeType CountryDefinition::TYPE_CUSTOMER_TAX_FREE
  185.     ): bool {
  186.         $countryTaxFreeLimit $taxFreeType === CountryDefinition::TYPE_CUSTOMER_TAX_FREE $country->getCustomerTax() : $country->getCompanyTax();
  187.         if (!$countryTaxFreeLimit->getEnabled()) {
  188.             return false;
  189.         }
  190.         $countryTaxFreeLimitAmount $countryTaxFreeLimit->getAmount() / $this->fetchCurrencyFactor($countryTaxFreeLimit->getCurrencyId(), $context);
  191.         $currency $context->getCurrency();
  192.         $cartNetAmount /= $this->fetchCurrencyFactor($currency->getId(), $context);
  193.         // currency taxFreeAmount === 0.0 mean currency taxFreeFrom is disabled
  194.         return $currency->getTaxFreeFrom() === 0.0 && FloatComparator::greaterThanOrEquals($cartNetAmount$countryTaxFreeLimitAmount);
  195.     }
  196.     private function fetchCurrencyFactor(string $currencyIdSalesChannelContext $context): float
  197.     {
  198.         if ($currencyId === Defaults::CURRENCY) {
  199.             return 1;
  200.         }
  201.         $currency $context->getCurrency();
  202.         if ($currencyId === $currency->getId()) {
  203.             return $currency->getFactor();
  204.         }
  205.         if (\array_key_exists($currencyId$this->currencyFactor)) {
  206.             return $this->currencyFactor[$currencyId];
  207.         }
  208.         $currencyFactor $this->connection->fetchOne(
  209.             'SELECT `factor` FROM `currency` WHERE `id` = :currencyId',
  210.             ['currencyId' => Uuid::fromHexToBytes($currencyId)]
  211.         );
  212.         if (!$currencyFactor) {
  213.             throw new EntityNotFoundException('currency'$currencyId);
  214.         }
  215.         return $this->currencyFactor[$currencyId] = (float) $currencyFactor;
  216.     }
  217.     private function validateTaxFree(SalesChannelContext $contextCart $cartCartBehavior $behaviorContext): Cart
  218.     {
  219.         $totalCartNetAmount $cart->getPrice()->getPositionPrice();
  220.         if ($context->getTaxState() === CartPrice::TAX_STATE_GROSS) {
  221.             $totalCartNetAmount $totalCartNetAmount $cart->getLineItems()->getPrices()->getCalculatedTaxes()->getAmount();
  222.         }
  223.         $taxState $this->detectTaxType($context$totalCartNetAmount);
  224.         $previous $context->getTaxState();
  225.         if ($taxState === $previous) {
  226.             return $cart;
  227.         }
  228.         $context->setTaxState($taxState);
  229.         $cart->setData(null);
  230.         $cart $this->processor->process($cart$context$behaviorContext);
  231.         if ($previous !== CartPrice::TAX_STATE_FREE) {
  232.             $context->setTaxState($previous);
  233.         }
  234.         return $cart;
  235.     }
  236. }