custom/plugins/PickwareErpStarter/src/Stock/ProductReservedStockUpdater.php line 149

Open in your IDE?
  1. <?php
  2. /*
  3.  * Copyright (c) Pickware GmbH. All rights reserved.
  4.  * This file is part of software that is released under a proprietary license.
  5.  * You must not copy, modify, distribute, make publicly available, or execute
  6.  * its contents or parts thereof without express permission by the copyright
  7.  * holder, unless otherwise permitted by law.
  8.  */
  9. declare(strict_types=1);
  10. namespace Pickware\PickwareErpStarter\Stock;
  11. use Doctrine\DBAL\Connection;
  12. use Pickware\DalBundle\RetryableTransaction;
  13. use Shopware\Core\Checkout\Cart\LineItem\LineItem;
  14. use Shopware\Core\Checkout\Order\Aggregate\OrderDelivery\OrderDeliveryDefinition;
  15. use Shopware\Core\Checkout\Order\Aggregate\OrderDelivery\OrderDeliveryStates;
  16. use Shopware\Core\Checkout\Order\Aggregate\OrderLineItem\OrderLineItemDefinition;
  17. use Shopware\Core\Checkout\Order\OrderDefinition;
  18. use Shopware\Core\Checkout\Order\OrderEvents;
  19. use Shopware\Core\Checkout\Order\OrderStates;
  20. use Shopware\Core\Content\Product\ProductEvents;
  21. use Shopware\Core\Defaults;
  22. use Shopware\Core\Framework\DataAbstractionLayer\EntityWriteResult;
  23. use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
  24. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\ChangeSetAware;
  25. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\DeleteCommand;
  26. use Shopware\Core\Framework\DataAbstractionLayer\Write\Validation\PreWriteValidationEvent;
  27. use Symfony\Component\EventDispatcher\EventDispatcherInterface;
  28. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  29. class ProductReservedStockUpdater implements EventSubscriberInterface
  30. {
  31.     /**
  32.      * @var Connection
  33.      */
  34.     private $db;
  35.     /**
  36.      * @var EventDispatcherInterface
  37.      */
  38.     private $eventDispatcher;
  39.     public function __construct(Connection $dbEventDispatcherInterface $eventDispatcher)
  40.     {
  41.         $this->db $db;
  42.         $this->eventDispatcher $eventDispatcher;
  43.     }
  44.     public static function getSubscribedEvents(): array
  45.     {
  46.         return [
  47.             PreWriteValidationEvent::class => 'triggerChangeSet',
  48.             StockUpdatedForStockMovementsEvent::EVENT_NAME => 'stockUpdatedForStockMovements',
  49.             ProductEvents::PRODUCT_WRITTEN_EVENT => 'productWritten',
  50.             OrderEvents::ORDER_WRITTEN_EVENT => 'orderWritten',
  51.             OrderEvents::ORDER_DELETED_EVENT => 'orderWritten',
  52.             OrderEvents::ORDER_DELIVERY_WRITTEN_EVENT => 'orderDeliveryWritten',
  53.             OrderEvents::ORDER_LINE_ITEM_WRITTEN_EVENT => 'orderLineItemWritten',
  54.             OrderEvents::ORDER_LINE_ITEM_DELETED_EVENT => 'orderLineItemWritten',
  55.         ];
  56.     }
  57.     public function triggerChangeSet(PreWriteValidationEvent $event): void
  58.     {
  59.         foreach ($event->getCommands() as $command) {
  60.             if (!$command instanceof ChangeSetAware) {
  61.                 continue;
  62.             }
  63.             switch ($command->getDefinition()->getEntityName()) {
  64.                 case OrderLineItemDefinition::ENTITY_NAME:
  65.                     if ($command instanceof DeleteCommand
  66.                         || $command->hasField('product_id')
  67.                         || $command->hasField('product_version_id')
  68.                         || $command->hasField('version_id')
  69.                         || $command->hasField('type')
  70.                         || $command->hasField('quantity')
  71.                     ) {
  72.                         $command->requestChangeSet();
  73.                     }
  74.                     break;
  75.                 case OrderDefinition::ENTITY_NAME:
  76.                     if ($command instanceof DeleteCommand
  77.                         || $command->hasField('order_line_item_id')
  78.                     ) {
  79.                         $command->requestChangeSet();
  80.                     }
  81.                     break;
  82.                 case OrderDeliveryDefinition::ENTITY_NAME:
  83.                     if ($command->hasField('order_id')
  84.                     ) {
  85.                         $command->requestChangeSet();
  86.                     }
  87.                     break;
  88.             }
  89.         }
  90.     }
  91.     public function stockUpdatedForStockMovements(StockUpdatedForStockMovementsEvent $event): void
  92.     {
  93.         $productIds = [];
  94.         foreach ($event->getStockMovements() as $stockMovement) {
  95.             if ($stockMovement['sourceOrderId'] || $stockMovement['destinationOrderId']) {
  96.                 $productIds[] = $stockMovement['productId'];
  97.             }
  98.         }
  99.         $this->recalculateProductReservedStock($productIds);
  100.     }
  101.     public function orderWritten(EntityWrittenEvent $entityWrittenEvent): void
  102.     {
  103.         if ($entityWrittenEvent->getContext()->getVersionId() !== Defaults::LIVE_VERSION) {
  104.             return;
  105.         }
  106.         $orderIds = [];
  107.         foreach ($entityWrittenEvent->getWriteResults() as $writeResult) {
  108.             $payload $writeResult->getPayload();
  109.             if (isset($payload['versionId'])
  110.                 || isset($payload['stateId'])
  111.             ) {
  112.                 $orderIds[] = $writeResult->getPrimaryKey();
  113.             }
  114.         }
  115.         $products $this->db->fetchAllAssociative(
  116.             'SELECT LOWER(HEX(`order_line_item`.`product_id`)) AS `id`
  117.             FROM `order_line_item`
  118.             WHERE `order_line_item`.`order_id` IN (:orderIds)
  119.                 AND `order_line_item`.`version_id` = :liveVersionId
  120.                 AND `order_line_item`.`order_version_id` = :liveVersionId
  121.                 AND `order_line_item`.`product_version_id` = :liveVersionId
  122.                 AND `order_line_item`.`product_id` IS NOT NULL',
  123.             [
  124.                 'orderIds' => array_map('hex2bin'$orderIds),
  125.                 'liveVersionId' => hex2bin(Defaults::LIVE_VERSION),
  126.             ],
  127.             [
  128.                 'orderIds' => Connection::PARAM_STR_ARRAY,
  129.             ],
  130.         );
  131.         $productIds array_column($products'id');
  132.         $this->recalculateProductReservedStock($productIds);
  133.     }
  134.     public function orderDeliveryWritten(EntityWrittenEvent $entityWrittenEvent): void
  135.     {
  136.         if ($entityWrittenEvent->getContext()->getVersionId() !== Defaults::LIVE_VERSION) {
  137.             return;
  138.         }
  139.         $orderDeliveryIds = [];
  140.         $orderIds = [];
  141.         foreach ($entityWrittenEvent->getWriteResults() as $writeResult) {
  142.             $payload $writeResult->getPayload();
  143.             if (isset($payload['stateId'])
  144.                 || isset($payload['versionId'])
  145.                 || isset($payload['orderVersionId'])
  146.             ) {
  147.                 $orderDeliveryIds[] = $payload['id'];
  148.             }
  149.             $changeSet $writeResult->getChangeSet();
  150.             if ($changeSet && $changeSet->hasChanged('order_id')) {
  151.                 $orderIds[] = bin2hex($changeSet->getBefore('order_id'));
  152.                 $orderIdAfter $changeSet->getAfter('order_id');
  153.                 if ($orderIdAfter) {
  154.                     // $orderIdAfter === null, when product_id was not changed
  155.                     $orderIds[] = bin2hex($orderIdAfter);
  156.                 }
  157.             }
  158.         }
  159.         $productIds = [];
  160.         if (count($orderDeliveryIds) > 0) {
  161.             $orderDeliveries $this->db->fetchAllAssociative(
  162.                 'SELECT
  163.                     LOWER(HEX(`order_line_item`.`product_id`)) AS `productId`
  164.                 FROM `order_delivery`
  165.                 INNER JOIN `order`
  166.                     ON `order`.`id` = `order_delivery`.`order_id`
  167.                     AND `order`.`version_id` = `order_delivery`.`order_version_id`
  168.                 INNER JOIN `order_line_item`
  169.                     ON `order`.`id` = `order_line_item`.`order_id`
  170.                     AND `order`.`version_id` = `order_line_item`.`order_version_id`
  171.                 WHERE `order_delivery`.`id` IN (:orderDeliveryIds)
  172.                     AND `order_line_item`.`product_id` IS NOT NULL
  173.                     AND `order_line_item`.`product_version_id` = :liveVersionId',
  174.                 [
  175.                     'orderDeliveryIds' => array_map('hex2bin'$orderDeliveryIds),
  176.                     'liveVersionId' => hex2bin(Defaults::LIVE_VERSION),
  177.                 ],
  178.                 [
  179.                     'orderDeliveryIds' => Connection::PARAM_STR_ARRAY,
  180.                 ],
  181.             );
  182.             $productIds array_merge($productIdsarray_column($orderDeliveries'productId'));
  183.         }
  184.         if (count($orderIds) > 0) {
  185.             $orders $this->db->fetchAllAssociative(
  186.                 'SELECT
  187.                     LOWER(HEX(`order_line_item`.`product_id`)) AS `productId`
  188.                 FROM `order`
  189.                 INNER JOIN `order_line_item`
  190.                     ON `order`.`id` = `order_line_item`.`order_id`
  191.                     AND `order`.`version_id` = `order_line_item`.`order_version_id`
  192.                 WHERE `order`.`id` IN (:orderIds)
  193.                     AND `order_line_item`.`product_id` IS NOT NULL
  194.                     AND `order_line_item`.`product_version_id` = :liveVersionId',
  195.                 [
  196.                     'orderIds' => array_map('hex2bin'$orderIds),
  197.                     'liveVersionId' => hex2bin(Defaults::LIVE_VERSION),
  198.                 ],
  199.                 [
  200.                     'orderIds' => Connection::PARAM_STR_ARRAY,
  201.                 ],
  202.             );
  203.             $productIds array_merge($productIdsarray_column($orders'productId'));
  204.         }
  205.         $productIds array_values(array_unique($productIds));
  206.         $this->recalculateProductReservedStock($productIds);
  207.     }
  208.     public function productWritten(EntityWrittenEvent $entityWrittenEvent): void
  209.     {
  210.         if ($entityWrittenEvent->getContext()->getVersionId() !== Defaults::LIVE_VERSION) {
  211.             return;
  212.         }
  213.         $productIds = [];
  214.         foreach ($entityWrittenEvent->getWriteResults() as $writeResult) {
  215.             $payload $writeResult->getPayload();
  216.             if (isset($payload['versionId'])
  217.                 || isset($payload['availableStock'])
  218.             ) {
  219.                 $productIds[] = $payload['id'];
  220.             }
  221.         }
  222.         $this->recalculateProductReservedStock($productIds);
  223.     }
  224.     /**
  225.      * Updates the old and the new product, if the product of an order line item is changed.
  226.      */
  227.     public function orderLineItemWritten(EntityWrittenEvent $entityWrittenEvent): void
  228.     {
  229.         if ($entityWrittenEvent->getContext()->getVersionId() !== Defaults::LIVE_VERSION) {
  230.             return;
  231.         }
  232.         $productIds = [];
  233.         foreach ($entityWrittenEvent->getWriteResults() as $writeResult) {
  234.             // $writeResult->getExistence() can be null, but we have no idea why and also not what this means.
  235.             $existence $writeResult->getExistence();
  236.             $isNewOrderLineItem = (
  237.                 $existence === null
  238.                 && $writeResult->getOperation() === EntityWriteResult::OPERATION_INSERT
  239.             ) || (
  240.                 $existence !== null && !$existence->exists()
  241.             );
  242.             if ($isNewOrderLineItem && array_key_exists('productId'$writeResult->getPayload())) {
  243.                 // This is a newly-created order line item
  244.                 $productIds[] = $writeResult->getPayload()['productId'];
  245.                 continue;
  246.             }
  247.             $changeSet $writeResult->getChangeSet();
  248.             if ($changeSet) {
  249.                 if ($changeSet->hasChanged('product_id')
  250.                     || $changeSet->hasChanged('product_version_id')
  251.                     || $changeSet->hasChanged('type')
  252.                     || $changeSet->hasChanged('version_id')
  253.                     || $changeSet->hasChanged('quantity')
  254.                 ) {
  255.                     $productIdBefore $changeSet->getBefore('product_id');
  256.                     if ($productIdBefore) {
  257.                         $productIds[] = bin2hex($productIdBefore);
  258.                     }
  259.                     $productIdAfter $changeSet->getAfter('product_id');
  260.                     if ($productIdAfter) {
  261.                         // $productIdAfter === null, when product_id was not changed
  262.                         $productIds[] = bin2hex($productIdAfter);
  263.                     }
  264.                 }
  265.             }
  266.         }
  267.         $productIds array_values(array_filter(array_unique($productIds)));
  268.         $this->recalculateProductReservedStock($productIds);
  269.     }
  270.     /**
  271.      * @param string[] $productIds
  272.      */
  273.     public function recalculateProductReservedStock(array $productIds): void
  274.     {
  275.         if (count($productIds) === 0) {
  276.             return;
  277.         }
  278.         RetryableTransaction::retryable($this->db, function () use ($productIds): void {
  279.             $this->db->executeStatement(
  280.                 'UPDATE `product`
  281.                 LEFT JOIN (
  282.                     SELECT
  283.                         `product`.`id` AS `productId`,
  284.                         `product`.`version_id` AS `productVersionId`,
  285.                         SUM(
  286.                             GREATEST(0, `order_line_item`.`quantity` - IFNULL(`stock`.`quantity`, 0))
  287.                         ) AS `reserved_quantity`
  288.                     FROM `order_line_item`
  289.                     LEFT JOIN `order`
  290.                         ON `order`.`id` = `order_line_item`.`order_id`
  291.                         AND `order`.`version_id` = `order_line_item`.`version_id`
  292.                     INNER JOIN `state_machine_state` AS `order_state`
  293.                         ON `order`.`state_id` = `order_state`.`id`
  294.                     INNER JOIN (
  295.                         -- Select a single order delivery with the highest shippingCosts.unitPrice as the primary order
  296.                         -- delivery for the order. This selection strategy is adapted from how order deliveries are selected
  297.                         -- in the administration. See /administration/src/module/sw-order/view/sw-order-detail-base/index.js
  298.                         SELECT
  299.                             `order_id`,
  300.                             `order_version_id`,
  301.                             MAX(
  302.                                 JSON_UNQUOTE(
  303.                                     JSON_EXTRACT(`order_delivery`.`shipping_costs`, "$.unitPrice")
  304.                                 )
  305.                             ) AS `unitPrice`
  306.                             FROM `order_delivery`
  307.                             GROUP BY `order_id`, `order_version_id`
  308.                     ) `primary_order_delivery_shipping_cost`
  309.                         ON `primary_order_delivery_shipping_cost`.`order_id` = `order`.`id`
  310.                         AND `primary_order_delivery_shipping_cost`.`order_version_id` = `order`.`version_id`
  311.                     INNER JOIN `order_delivery`
  312.                         ON `order_delivery`.`order_id` = `order`.`id`
  313.                         AND `order_delivery`.`order_version_id` = `order`.`version_id`
  314.                         AND JSON_UNQUOTE(JSON_EXTRACT(`order_delivery`.`shipping_costs`, "$.unitPrice")) = `primary_order_delivery_shipping_cost`.`unitPrice`
  315.                     INNER JOIN `state_machine_state` AS `order_delivery_state`
  316.                         ON `order_delivery_state`.`id` = `order_delivery`.`state_id`
  317.                     INNER JOIN `product`
  318.                         ON `order_line_item`.`product_id` = `product`.`id`
  319.                         AND `order_line_item`.`product_version_id` = `product`.`version_id`
  320.                     LEFT JOIN `pickware_erp_stock` AS `stock`
  321.                         ON `product`.`id` = `stock`.`product_id`
  322.                         AND `product`.`version_id` = `stock`.`product_version_id`
  323.                         AND `order`.`id` = `stock`.`order_id`
  324.                         AND `order`.`version_id` = `stock`.`order_version_id`
  325.                     WHERE
  326.                         `order_line_item`.`type` = :orderLineItemTypeProduct
  327.                         AND `order_state`.`technical_name` IN (:orderStates)
  328.                         AND `order_delivery_state`.`technical_name` IN (:orderDeliveryStates)
  329.                         AND `order`.`version_id` = :liveVersionId
  330.                         AND `order_line_item`.`version_id` = :liveVersionId
  331.                         AND `product`.`version_id` = :liveVersionId
  332.                         -- The following two conditions are performance optimizations
  333.                         AND `product`.`id` IN (:productIds)
  334.                         AND `product`.`version_id` = :liveVersionId
  335.                     GROUP BY `productId`, `productVersionId`
  336.                 ) AS `product_reserved_stock`
  337.                     ON `product_reserved_stock`.`productId` = `product`.`id`
  338.                     AND `product_reserved_stock`.`productVersionId` = `product`.`version_id`
  339.                 SET
  340.                     `product`.`available_stock` = `product`.`stock` - IFNULL(`product_reserved_stock`.`reserved_quantity`, 0),
  341.                     `product`.`updated_at` = NOW(3)
  342.                 WHERE `product`.`id` IN (:productIds)
  343.                     AND `product`.`version_id` = :liveVersionId',
  344.                 [
  345.                     'orderStates' => [
  346.                         OrderStates::STATE_OPEN,
  347.                         OrderStates::STATE_IN_PROGRESS,
  348.                     ],
  349.                     'orderDeliveryStates' => [
  350.                         OrderDeliveryStates::STATE_OPEN,
  351.                         OrderDeliveryStates::STATE_PARTIALLY_SHIPPED,
  352.                     ],
  353.                     'liveVersionId' => hex2bin(Defaults::LIVE_VERSION),
  354.                     'productIds' => array_map('hex2bin'$productIds),
  355.                     'orderLineItemTypeProduct' => LineItem::PRODUCT_LINE_ITEM_TYPE,
  356.                 ],
  357.                 [
  358.                     'orderStates' => Connection::PARAM_STR_ARRAY,
  359.                     'orderDeliveryStates' => Connection::PARAM_STR_ARRAY,
  360.                     'productIds' => Connection::PARAM_STR_ARRAY,
  361.                 ],
  362.             );
  363.         });
  364.         $this->eventDispatcher->dispatch(
  365.             new ProductAvailableStockUpdatedEvent($productIds),
  366.             ProductAvailableStockUpdatedEvent::EVENT_NAME,
  367.         );
  368.     }
  369. }