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

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