platform/src/Core/Content/Product/DataAbstractionLayer/StockUpdater.php line 55

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