vendor/shopware/core/Content/Product/DataAbstractionLayer/StockUpdater.php line 149

Open in your IDE?
  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\Content\Product\DataAbstractionLayer;
  3. use Doctrine\DBAL\Connection;
  4. use Shopware\Core\Checkout\Cart\Event\CheckoutOrderPlacedEvent;
  5. use Shopware\Core\Checkout\Cart\LineItem\LineItem;
  6. use Shopware\Core\Checkout\Order\Aggregate\OrderLineItem\OrderLineItemDefinition;
  7. use Shopware\Core\Checkout\Order\OrderEvents;
  8. use Shopware\Core\Checkout\Order\OrderStates;
  9. use Shopware\Core\Content\Product\ProductDefinition;
  10. use Shopware\Core\Defaults;
  11. use Shopware\Core\Framework\Adapter\Cache\CacheClearer;
  12. use Shopware\Core\Framework\Context;
  13. use Shopware\Core\Framework\DataAbstractionLayer\Cache\EntityCacheKeyGenerator;
  14. use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\RetryableQuery;
  15. use Shopware\Core\Framework\DataAbstractionLayer\EntityWriteResult;
  16. use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
  17. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\ChangeSetAware;
  18. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\DeleteCommand;
  19. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\InsertCommand;
  20. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\UpdateCommand;
  21. use Shopware\Core\Framework\DataAbstractionLayer\Write\Validation\PreWriteValidationEvent;
  22. use Shopware\Core\Framework\Uuid\Uuid;
  23. use Shopware\Core\System\StateMachine\Event\StateMachineTransitionEvent;
  24. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  25. class StockUpdater implements EventSubscriberInterface
  26. {
  27.     /**
  28.      * @var Connection
  29.      */
  30.     private $connection;
  31.     /**
  32.      * @var ProductDefinition
  33.      */
  34.     private $definition;
  35.     /**
  36.      * @var CacheClearer
  37.      */
  38.     private $cache;
  39.     /**
  40.      * @var EntityCacheKeyGenerator
  41.      */
  42.     private $cacheKeyGenerator;
  43.     public function __construct(
  44.         Connection $connection,
  45.         ProductDefinition $definition,
  46.         CacheClearer $cache,
  47.         EntityCacheKeyGenerator $cacheKeyGenerator
  48.     ) {
  49.         $this->connection $connection;
  50.         $this->definition $definition;
  51.         $this->cache $cache;
  52.         $this->cacheKeyGenerator $cacheKeyGenerator;
  53.     }
  54.     /**
  55.      * Returns a list of custom business events to listen where the product maybe changed
  56.      */
  57.     public static function getSubscribedEvents()
  58.     {
  59.         return [
  60.             CheckoutOrderPlacedEvent::class => 'orderPlaced',
  61.             StateMachineTransitionEvent::class => 'stateChanged',
  62.             PreWriteValidationEvent::class => 'triggerChangeSet',
  63.             OrderEvents::ORDER_LINE_ITEM_WRITTEN_EVENT => 'lineItemWritten',
  64.             OrderEvents::ORDER_LINE_ITEM_DELETED_EVENT => 'lineItemWritten',
  65.         ];
  66.     }
  67.     public function triggerChangeSet(PreWriteValidationEvent $event): void
  68.     {
  69.         if ($event->getContext()->getVersionId() !== Defaults::LIVE_VERSION) {
  70.             return;
  71.         }
  72.         foreach ($event->getCommands() as $command) {
  73.             if (!$command instanceof ChangeSetAware) {
  74.                 continue;
  75.             }
  76.             /** @var ChangeSetAware|InsertCommand|UpdateCommand $command */
  77.             if ($command->getDefinition()->getEntityName() !== OrderLineItemDefinition::ENTITY_NAME) {
  78.                 continue;
  79.             }
  80.             if ($command instanceof DeleteCommand) {
  81.                 $command->requestChangeSet();
  82.                 continue;
  83.             }
  84.             if ($command->hasField('referenced_id') || $command->hasField('product_id') || $command->hasField('quantity')) {
  85.                 $command->requestChangeSet();
  86.                 continue;
  87.             }
  88.         }
  89.     }
  90.     /**
  91.      * If the product of an order item changed, the stocks of the old product and the new product must be updated.
  92.      */
  93.     public function lineItemWritten(EntityWrittenEvent $event): void
  94.     {
  95.         $ids = [];
  96.         foreach ($event->getWriteResults() as $result) {
  97.             if ($result->hasPayload('referencedId') && $result->getProperty('type') === LineItem::PRODUCT_LINE_ITEM_TYPE) {
  98.                 $ids[] = $result->getProperty('referencedId');
  99.             }
  100.             if ($result->getOperation() === EntityWriteResult::OPERATION_INSERT) {
  101.                 continue;
  102.             }
  103.             $changeSet $result->getChangeSet();
  104.             if (!$changeSet) {
  105.                 continue;
  106.             }
  107.             $type $changeSet->getBefore('type');
  108.             if ($type !== LineItem::PRODUCT_LINE_ITEM_TYPE) {
  109.                 continue;
  110.             }
  111.             if (!$changeSet->hasChanged('referenced_id') && !$changeSet->hasChanged('quantity')) {
  112.                 continue;
  113.             }
  114.             $ids[] = $changeSet->getBefore('referenced_id');
  115.             $ids[] = $changeSet->getAfter('referenced_id');
  116.         }
  117.         $ids array_filter(array_unique($ids));
  118.         if (empty($ids)) {
  119.             return;
  120.         }
  121.         $this->update($ids$event->getContext());
  122.         $this->clearCache($ids);
  123.     }
  124.     public function stateChanged(StateMachineTransitionEvent $event): void
  125.     {
  126.         if ($event->getContext()->getVersionId() !== Defaults::LIVE_VERSION) {
  127.             return;
  128.         }
  129.         if ($event->getEntityName() !== 'order') {
  130.             return;
  131.         }
  132.         if ($event->getToPlace()->getTechnicalName() === OrderStates::STATE_COMPLETED) {
  133.             $this->decreaseStock($event);
  134.             return;
  135.         }
  136.         if ($event->getFromPlace()->getTechnicalName() === OrderStates::STATE_COMPLETED) {
  137.             $this->increaseStock($event);
  138.             return;
  139.         }
  140.         if ($event->getToPlace()->getTechnicalName() === OrderStates::STATE_CANCELLED || $event->getFromPlace()->getTechnicalName() === OrderStates::STATE_CANCELLED) {
  141.             $products $this->getProductsOfOrder($event->getEntityId());
  142.             $ids array_column($products'referenced_id');
  143.             $this->updateAvailableStockAndSales($ids$event->getContext());
  144.             $this->updateAvailableFlag($ids$event->getContext());
  145.             $this->clearCache($ids);
  146.             return;
  147.         }
  148.     }
  149.     public function update(array $idsContext $context): void
  150.     {
  151.         if ($context->getVersionId() !== Defaults::LIVE_VERSION) {
  152.             return;
  153.         }
  154.         $this->updateAvailableStockAndSales($ids$context);
  155.         $this->updateAvailableFlag($ids$context);
  156.     }
  157.     public function orderPlaced(CheckoutOrderPlacedEvent $event): void
  158.     {
  159.         $ids = [];
  160.         foreach ($event->getOrder()->getLineItems() as $lineItem) {
  161.             if ($lineItem->getType() !== LineItem::PRODUCT_LINE_ITEM_TYPE) {
  162.                 continue;
  163.             }
  164.             $ids[] = $lineItem->getReferencedId();
  165.         }
  166.         $this->update($ids$event->getContext());
  167.         $this->clearCache($ids);
  168.     }
  169.     private function increaseStock(StateMachineTransitionEvent $event): void
  170.     {
  171.         $products $this->getProductsOfOrder($event->getEntityId());
  172.         $ids array_column($products'referenced_id');
  173.         $this->updateStock($products, +1);
  174.         $this->updateAvailableStockAndSales($ids$event->getContext());
  175.         $this->updateAvailableFlag($ids$event->getContext());
  176.         $this->clearCache($ids);
  177.     }
  178.     private function decreaseStock(StateMachineTransitionEvent $event): void
  179.     {
  180.         $products $this->getProductsOfOrder($event->getEntityId());
  181.         $ids array_column($products'referenced_id');
  182.         $this->updateStock($products, -1);
  183.         $this->updateAvailableStockAndSales($ids$event->getContext());
  184.         $this->updateAvailableFlag($ids$event->getContext());
  185.         $this->clearCache($ids);
  186.     }
  187.     private function updateAvailableStockAndSales(array $idsContext $context): void
  188.     {
  189.         $ids array_filter(array_keys(array_flip($ids)));
  190.         if (empty($ids)) {
  191.             return;
  192.         }
  193.         $sql '
  194. SELECT LOWER(HEX(order_line_item.product_id)) as product_id,
  195.     IFNULL(
  196.         SUM(IF(state_machine_state.technical_name = :completed_state, 0, order_line_item.quantity)),
  197.         0
  198.     ) as open_quantity,
  199.     IFNULL(
  200.         SUM(IF(state_machine_state.technical_name = :completed_state, order_line_item.quantity, 0)),
  201.         0
  202.     ) as sales_quantity
  203. FROM order_line_item
  204.     INNER JOIN `order`
  205.         ON `order`.id = order_line_item.order_id
  206.         AND `order`.version_id = order_line_item.order_version_id
  207.     INNER JOIN state_machine_state
  208.         ON state_machine_state.id = `order`.state_id
  209.         AND state_machine_state.technical_name <> :cancelled_state
  210. WHERE LOWER(order_line_item.referenced_id) IN (:ids)
  211.     AND order_line_item.type = :type
  212.     AND order_line_item.version_id = :version
  213.     AND order_line_item.product_id IS NOT NULL
  214. GROUP BY product_id;
  215.         ';
  216.         $rows $this->connection->fetchAll(
  217.             $sql,
  218.             [
  219.                 'type' => LineItem::PRODUCT_LINE_ITEM_TYPE,
  220.                 'version' => Uuid::fromHexToBytes($context->getVersionId()),
  221.                 'completed_state' => OrderStates::STATE_COMPLETED,
  222.                 'cancelled_state' => OrderStates::STATE_CANCELLED,
  223.                 'ids' => $ids,
  224.             ],
  225.             [
  226.                 'ids' => Connection::PARAM_STR_ARRAY,
  227.             ]
  228.         );
  229.         $fallback array_column($rows'product_id');
  230.         $fallback array_diff($ids$fallback);
  231.         $update = new RetryableQuery(
  232.             $this->connection->prepare('UPDATE product SET available_stock = stock - :open_quantity, sales = :sales_quantity WHERE id = :id')
  233.         );
  234.         foreach ($fallback as $id) {
  235.             $update->execute([
  236.                 'id' => Uuid::fromHexToBytes((string) $id),
  237.                 'open_quantity' => 0,
  238.                 'sales_quantity' => 0,
  239.             ]);
  240.         }
  241.         foreach ($rows as $row) {
  242.             $update->execute([
  243.                 'id' => Uuid::fromHexToBytes($row['product_id']),
  244.                 'open_quantity' => $row['open_quantity'],
  245.                 'sales_quantity' => $row['sales_quantity'],
  246.             ]);
  247.         }
  248.     }
  249.     private function updateAvailableFlag(array $idsContext $context): void
  250.     {
  251.         $ids array_filter(array_keys(array_flip($ids)));
  252.         if (empty($ids)) {
  253.             return;
  254.         }
  255.         $bytes Uuid::fromHexToBytesList($ids);
  256.         $sql '
  257.             UPDATE product
  258.             LEFT JOIN product parent
  259.                 ON parent.id = product.parent_id
  260.                 AND parent.version_id = product.version_id
  261.             SET product.available = IFNULL((
  262.                 IFNULL(product.is_closeout, parent.is_closeout) * product.available_stock
  263.                 >=
  264.                 IFNULL(product.is_closeout, parent.is_closeout) * IFNULL(product.min_purchase, parent.min_purchase)
  265.             ), 0)
  266.             WHERE product.id IN (:ids)
  267.             AND product.version_id = :version
  268.         ';
  269.         RetryableQuery::retryable(function () use ($sql$context$bytes): void {
  270.             $this->connection->executeUpdate(
  271.                 $sql,
  272.                 ['ids' => $bytes'version' => Uuid::fromHexToBytes($context->getVersionId())],
  273.                 ['ids' => Connection::PARAM_STR_ARRAY]
  274.             );
  275.         });
  276.     }
  277.     private function updateStock(array $productsint $multiplier): void
  278.     {
  279.         $query = new RetryableQuery(
  280.             $this->connection->prepare('UPDATE product SET stock = stock + :quantity WHERE id = :id AND version_id = :version')
  281.         );
  282.         foreach ($products as $product) {
  283.             $query->execute([
  284.                 'quantity' => (int) $product['quantity'] * $multiplier,
  285.                 'id' => Uuid::fromHexToBytes($product['referenced_id']),
  286.                 'version' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION),
  287.             ]);
  288.         }
  289.     }
  290.     private function getProductsOfOrder(string $orderId): array
  291.     {
  292.         $query $this->connection->createQueryBuilder();
  293.         $query->select(['referenced_id''quantity']);
  294.         $query->from('order_line_item');
  295.         $query->andWhere('type = :type');
  296.         $query->andWhere('order_id = :id');
  297.         $query->andWhere('version_id = :version');
  298.         $query->setParameter('id'Uuid::fromHexToBytes($orderId));
  299.         $query->setParameter('version'Uuid::fromHexToBytes(Defaults::LIVE_VERSION));
  300.         $query->setParameter('type'LineItem::PRODUCT_LINE_ITEM_TYPE);
  301.         return $query->execute()->fetchAll(\PDO::FETCH_ASSOC);
  302.     }
  303.     private function clearCache(array $ids): void
  304.     {
  305.         $tags = [];
  306.         foreach ($ids as $id) {
  307.             $tags[] = $this->cacheKeyGenerator->getEntityTag($id$this->definition->getEntityName());
  308.         }
  309.         $tags[] = $this->cacheKeyGenerator->getFieldTag($this->definition'id');
  310.         $tags[] = $this->cacheKeyGenerator->getFieldTag($this->definition'available');
  311.         $tags[] = $this->cacheKeyGenerator->getFieldTag($this->definition'availableStock');
  312.         $tags[] = $this->cacheKeyGenerator->getFieldTag($this->definition'stock');
  313.         $this->cache->invalidateTags($tags);
  314.     }
  315. }