custom/plugins/WbfkBundles/src/Core/Content/Product/DataAbstractionLayer/BundleStockUpdater.php line 131

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace Wbfk\Bundles\Core\Content\Product\DataAbstractionLayer;
  4. use DateTime;
  5. use Doctrine\DBAL\Connection;
  6. use Doctrine\DBAL\Exception;
  7. use Shopware\Core\Checkout\Cart\Event\CheckoutOrderPlacedEvent;
  8. use Shopware\Core\Checkout\Cart\LineItem\LineItem;
  9. use Shopware\Core\Checkout\Order\OrderEvents;
  10. use Shopware\Core\Checkout\Order\OrderStates;
  11. use Shopware\Core\Content\Product\Events\ProductNoLongerAvailableEvent;
  12. use Shopware\Core\Defaults;
  13. use Shopware\Core\Framework\Context;
  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\Uuid\Uuid;
  18. use Shopware\Core\Profiling\Profiler;
  19. use Shopware\Core\System\StateMachine\Event\StateMachineTransitionEvent;
  20. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  21. use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
  22. use function array_key_exists;
  23. use function array_keys;
  24. use function in_array;
  25. /*
  26.  *  Bundle stock updater is based on \Shopware\Core\Content\Product\DataAbstractionLayer\StockUpdater for bundle stock calculation.
  27.  *  We have filtered bundle children and bundle parents from Shopware StockUpdater using \Wbfk\Bundles\Core\Content\Product\DataAbstractionLayer\BundleStockUpdateFilter
  28.  *  So that their stocks are not updated by Shopware StockUpdater.
  29.  */
  30. class BundleStockUpdater implements EventSubscriberInterface
  31. {
  32.     public function __construct(
  33.         private readonly Connection $connection,
  34.         private readonly EventDispatcherInterface $dispatcher,
  35.         private readonly BundleFilter $bundleFilter,
  36.     ) {
  37.     }
  38.     public static function getSubscribedEvents(): array
  39.     {
  40.         return [
  41.             CheckoutOrderPlacedEvent::class => 'orderPlaced',
  42.             StateMachineTransitionEvent::class => 'stateChanged',
  43.             OrderEvents::ORDER_LINE_ITEM_WRITTEN_EVENT => 'lineItemWritten',
  44.             OrderEvents::ORDER_LINE_ITEM_DELETED_EVENT => 'lineItemWritten',
  45.         ];
  46.     }
  47.     /**
  48.      * @throws Exception
  49.      */
  50.     public function orderPlaced(CheckoutOrderPlacedEvent $event): void
  51.     {
  52.         /** @noinspection DuplicatedCode */
  53.         $orderedProductQuantities = [];
  54.         foreach ($event->getOrder()->getLineItems() ?? [] as $lineItem) {
  55.             if ($lineItem->getType() !== LineItem::PRODUCT_LINE_ITEM_TYPE) {
  56.                 continue;
  57.             }
  58.             $referencedId $lineItem->getReferencedId();
  59.             if (!$referencedId) {
  60.                 continue;
  61.             }
  62.             if (!array_key_exists($referencedId$orderedProductQuantities)) {
  63.                 $orderedProductQuantities[$referencedId] = 0;
  64.             }
  65.             $orderedProductQuantities[$referencedId] += $lineItem->getQuantity();
  66.         }
  67.         /*
  68.          *  Foreach bundle product, we need to find child products and their quantity in the bundle.
  69.          *  Then add these child products to the list of ordered products with the quantity of the parent product multiplied by the quantity of the child product in the bundle.
  70.          *  After that, we can remove the parent product from the list.
  71.          *
  72.          *  Bundle product's available stock is calculated based on the available stock of child products.
  73.          *  This is done in the final step after updating the available stock of child products.
  74.          */
  75.         $orderedBundleIds $this->bundleFilter->filterParentProductIds(array_keys($orderedProductQuantities), $event->getContext());
  76.         $bundleChildrenQuantities $this->bundleFilter->findBundleChildrenQuantities($orderedBundleIds$event->getContext());
  77.         foreach ($orderedBundleIds as $bundleId) {
  78.             $childIds array_keys($bundleChildrenQuantities[$bundleId]);
  79.             $childQuantities $bundleChildrenQuantities[$bundleId];
  80.             foreach ($childIds as $childId) {
  81.                 if (!array_key_exists($childId$orderedProductQuantities)) {
  82.                     $orderedProductQuantities[$childId] = 0;
  83.                 }
  84.                 $orderedProductQuantities[$childId] += $childQuantities[$childId] * $orderedProductQuantities[$bundleId];
  85.             }
  86.             // Now that we have updated the quantity of child products in the bundle, we can remove the parent product from the list
  87.             unset($orderedProductQuantities[$bundleId]);
  88.         }
  89.         /*
  90.          * Shopware has already calculated available stock of normal products, we need to calculate available stock of bundle children
  91.          * After that, we can calculate available stock of bundles whose children's available stock is updated
  92.          */
  93.         $orderedBundleChildrenIds $this->bundleFilter->filterBundleChildrenIds(array_keys($orderedProductQuantities), $event->getContext());
  94.         $orderedBundleChildrenQuantities array_intersect_key($orderedProductQuantitiesarray_flip($orderedBundleChildrenIds));
  95.         $query = new RetryableQuery(
  96.             $this->connection,
  97.             $this->connection->prepare('UPDATE product SET available_stock = available_stock - :quantity WHERE id = :id')
  98.         );
  99.         foreach ($orderedBundleChildrenQuantities as $id => $quantity) {
  100.             /** @noinspection PhpDeprecationInspection */
  101.             $query->execute(['id' => Uuid::fromHexToBytes((string)$id), 'quantity' => $quantity]);
  102.         }
  103.         $this->updateAvailableFlag(array_keys($orderedBundleChildrenQuantities), $event->getContext());
  104.         $bundleIds $this->bundleFilter->findParentIds($orderedBundleChildrenIds$event->getContext());
  105.         $this->updateBundleStockAndAvailability($bundleIds$event->getContext());
  106.     }
  107.     /**
  108.      * If the product of an order item changed, the stocks of the old product and the new product must be updated.
  109.      * @throws Exception
  110.      */
  111.     public function lineItemWritten(EntityWrittenEvent $event): void
  112.     {
  113.         /** @noinspection DuplicatedCode */
  114.         $ids = [];
  115.         // we don't want to trigger to `update` method when we are inside the order process
  116.         if ($event->getContext()->hasState('checkout-order-route')) {
  117.             return;
  118.         }
  119.         foreach ($event->getWriteResults() as $result) {
  120.             if ($result->hasPayload('referencedId') && $result->getProperty('type') === LineItem::PRODUCT_LINE_ITEM_TYPE) {
  121.                 $ids[] = $result->getProperty('referencedId');
  122.             }
  123.             if ($result->getOperation() === EntityWriteResult::OPERATION_INSERT) {
  124.                 continue;
  125.             }
  126.             $changeSet $result->getChangeSet();
  127.             if (!$changeSet) {
  128.                 continue;
  129.             }
  130.             $type $changeSet->getBefore('type');
  131.             if ($type !== LineItem::PRODUCT_LINE_ITEM_TYPE) {
  132.                 continue;
  133.             }
  134.             if (!$changeSet->hasChanged('referenced_id') && !$changeSet->hasChanged('quantity')) {
  135.                 continue;
  136.             }
  137.             $ids[] = $changeSet->getBefore('referenced_id');
  138.             $ids[] = $changeSet->getAfter('referenced_id');
  139.         }
  140.         $ids array_filter(array_unique($ids));
  141.         if (empty($ids)) {
  142.             return;
  143.         }
  144.         $this->update($ids$event->getContext());
  145.     }
  146.     /**
  147.      * @throws \Doctrine\DBAL\Driver\Exception
  148.      * @throws Exception
  149.      */
  150.     public function stateChanged(StateMachineTransitionEvent $event): void
  151.     {
  152.         /** @noinspection DuplicatedCode */
  153.         if ($event->getContext()->getVersionId() !== Defaults::LIVE_VERSION) {
  154.             return;
  155.         }
  156.         if ($event->getEntityName() !== 'order') {
  157.             return;
  158.         }
  159.         if ($event->getToPlace()->getTechnicalName() === OrderStates::STATE_COMPLETED) {
  160.             /*
  161.              *  Stock decrease for bundle children, then bundle parents based on the available stock of bundle children
  162.              */
  163.             $this->decreaseStock($event);
  164.             return;
  165.         }
  166.         if ($event->getFromPlace()->getTechnicalName() === OrderStates::STATE_COMPLETED) {
  167.             /*
  168.              *  Stock increase for bundle children, then bundle parents based on the available stock of bundle children
  169.              */
  170.             $this->increaseStock($event);
  171.             return;
  172.         }
  173.         if ($event->getToPlace()->getTechnicalName() === OrderStates::STATE_CANCELLED || $event->getFromPlace()->getTechnicalName() === OrderStates::STATE_CANCELLED) {
  174.             [
  175.                 'parentProductIds' => $parentProductIds,
  176.                 'childProductIds' => $childProductIds,
  177.             ] = $this->getBundleProductsAndBundleChildProductsInOrder($event->getEntityId(), $event->getContext());
  178.             $ids array_merge($childProductIds$parentProductIds);
  179.             $this->update($ids$event->getContext());
  180.         }
  181.     }
  182.     /**
  183.      * @throws Exception
  184.      * @throws \Doctrine\DBAL\Driver\Exception
  185.      */
  186.     private function increaseStock(StateMachineTransitionEvent $event): void
  187.     {
  188.         [
  189.             'lineItems' => $lineItems,
  190.             'parentProductIds' => $parentProductIds,
  191.             'childProductIds' => $childProductIds,
  192.         ] = $this->getBundleProductsAndBundleChildProductsInOrder($event->getEntityId(), $event->getContext());
  193.         // Update stock of bundle children which are directly in the order
  194.         $childProductLineItems array_filter($lineItems, static fn(array $item) => in_array($item['referenced_id'], $childProductIdstrue));
  195.         $this->updateStock($childProductLineItems, +1);
  196.         // Update stock of bundle children which are part of the order because their parent product is in the order
  197.         $bundleChildrenQuantities $this->bundleFilter->findBundleChildrenQuantities($parentProductIds$event->getContext());
  198.         $childProductLineItemsFromParents $this->getChildProductLineItemsFromParents($parentProductIds$lineItems$bundleChildrenQuantities);
  199.         $this->updateStock($childProductLineItemsFromParents, +1);
  200.         $ids array_merge($childProductIdsarray_keys($childProductLineItemsFromParents));
  201.         $this->update($ids$event->getContext());
  202.     }
  203.     /**
  204.      * @throws Exception
  205.      * @throws \Doctrine\DBAL\Driver\Exception
  206.      */
  207.     private function decreaseStock(StateMachineTransitionEvent $event): void
  208.     {
  209.         [
  210.             'lineItems' => $lineItems,
  211.             'parentProductIds' => $parentProductIds,
  212.             'childProductIds' => $childProductIds,
  213.         ] = $this->getBundleProductsAndBundleChildProductsInOrder($event->getEntityId(), $event->getContext());
  214.         // Update stock of bundle children which are directly in the order
  215.         $childProductLineItems array_filter($lineItems, static fn(array $item) => in_array($item['referenced_id'], $childProductIdstrue));
  216.         $this->updateStock($childProductLineItems, -1);
  217.         // Update stock of bundle children which are part of the order because their parent product is in the order
  218.         $bundleChildrenQuantities $this->bundleFilter->findBundleChildrenQuantities($parentProductIds$event->getContext());
  219.         $childProductLineItemsFromParents $this->getChildProductLineItemsFromParents($parentProductIds$lineItems$bundleChildrenQuantities);
  220.         $this->updateStock($childProductLineItemsFromParents, -1);
  221.         $ids array_merge($childProductIdsarray_keys($childProductLineItemsFromParents));
  222.         $this->update($ids$event->getContext());
  223.     }
  224.     /**
  225.      * @param list<string> $ids
  226.      * @throws Exception
  227.      */
  228.     private function updateAvailableFlag(array $idsContext $context): void
  229.     {
  230.         /** @noinspection DuplicatedCode */
  231.         $ids array_filter(array_unique($ids));
  232.         if (empty($ids)) {
  233.             return;
  234.         }
  235.         $bytes Uuid::fromHexToBytesList($ids);
  236.         $sql '
  237.             UPDATE product
  238.             LEFT JOIN product parent
  239.                 ON parent.id = product.parent_id
  240.                 AND parent.version_id = product.version_id
  241.  
  242.             SET product.available = IFNULL((
  243.                 IFNULL(product.is_closeout, parent.is_closeout) * product.available_stock
  244.                 >=
  245.                 IFNULL(product.is_closeout, parent.is_closeout) * IFNULL(product.min_purchase, parent.min_purchase)
  246.             ), 0)
  247.             WHERE product.id IN (:ids)
  248.             AND product.version_id = :version
  249.         ';
  250.         /** @noinspection PhpDeprecationInspection */
  251.         RetryableQuery::retryable($this->connection, function () use ($sql$context$bytes): void {
  252.             $this->connection->executeStatement(
  253.                 $sql,
  254.                 ['ids' => $bytes'version' => Uuid::fromHexToBytes($context->getVersionId())],
  255.                 ['ids' => Connection::PARAM_STR_ARRAY]
  256.             );
  257.         });
  258.         $updated $this->connection->fetchFirstColumn(
  259.             'SELECT LOWER(HEX(id)) FROM product WHERE available = 0 AND id IN (:ids) AND product.version_id = :version',
  260.             ['ids' => $bytes'version' => Uuid::fromHexToBytes($context->getVersionId())],
  261.             ['ids' => Connection::PARAM_STR_ARRAY]
  262.         );
  263.         if (!empty($updated)) {
  264.             $this->dispatcher->dispatch(new ProductNoLongerAvailableEvent($updated$context));
  265.         }
  266.     }
  267.     /**
  268.      * @param list<array{referenced_id: string, quantity: string}> $products
  269.      * @throws Exception
  270.      */
  271.     private function updateStock(array $productsint $multiplier): void
  272.     {
  273.         $query = new RetryableQuery(
  274.             $this->connection,
  275.             $this->connection->prepare('UPDATE product SET stock = stock + :quantity WHERE id = :id AND version_id = :version')
  276.         );
  277.         foreach ($products as $product) {
  278.             /** @noinspection PhpDeprecationInspection */
  279.             $query->execute([
  280.                 'quantity' => (int)$product['quantity'] * $multiplier,
  281.                 'id' => Uuid::fromHexToBytes($product['referenced_id']),
  282.                 'version' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION),
  283.             ]);
  284.         }
  285.     }
  286.     /**
  287.      * @return array{lineItems: list<array{referenced_id: string, quantity: string}>, parentProductIds: list<string>, childProductIds: list<string>}
  288.      * @throws Exception|\Doctrine\DBAL\Driver\Exception
  289.      */
  290.     private function getBundleProductsAndBundleChildProductsInOrder(string $orderIdContext $context): array
  291.     {
  292.         /** @noinspection DuplicatedCode */
  293.         $query $this->connection->createQueryBuilder();
  294.         $query->select(['referenced_id''quantity']);
  295.         $query->from('order_line_item');
  296.         $query->andWhere('type = :type');
  297.         $query->andWhere('order_id = :id');
  298.         $query->andWhere('version_id = :version');
  299.         $query->setParameter('id'Uuid::fromHexToBytes($orderId));
  300.         $query->setParameter('version'Uuid::fromHexToBytes(Defaults::LIVE_VERSION));
  301.         $query->setParameter('type'LineItem::PRODUCT_LINE_ITEM_TYPE);
  302.         /** @var list<array{referenced_id: string, quantity: string}> $result */
  303.         $result $query->executeQuery()->fetchAllAssociative();
  304.         $parentProductIds $this->bundleFilter->filterParentProductIds(array_column($result'referenced_id'), $context);
  305.         $childProductIds $this->bundleFilter->filterBundleChildrenIds(array_column($result'referenced_id'), $context);
  306.         $filteredIds array_unique(array_merge($parentProductIds$childProductIds));
  307.         return [
  308.             'lineItems' => array_filter($result, static fn(array $item) => in_array($item['referenced_id'], $filteredIdstrue)),
  309.             'parentProductIds' => $parentProductIds,
  310.             'childProductIds' => $childProductIds,
  311.         ];
  312.     }
  313.     /**
  314.      * @throws Exception
  315.      */
  316.     public function update(array $idsContext $context): void
  317.     {
  318.         $bundleParentIds $this->bundleFilter->filterParentProductIds($ids$context);
  319.         $bundleChildrenIds $this->bundleFilter->filterBundleChildrenIds($ids$context);
  320.         /*
  321.          *  Bundle Children Available Stock Calculation
  322.          *  ===========================================
  323.          *  Available stock of bundle children is difference of stock and open quantity of bundle children from their direct orders
  324.          *  and open quantities as part of open orders of bundle parents.
  325.          *
  326.          *  available_stock = stock - open_quantity - open_quantity_in_bundles
  327.          *
  328.          *  eg: Available stock calculation for product A which is part of Bundle 1 with quantity 2 and Bundle 2 with quantity 3
  329.          *        - Product A has stock 100
  330.          *        - Product A has open quantity 10 (direct product orders)
  331.          *        - Bundle 1 has open quantity 5
  332.          *        - Bundle 2 has open quantity 2
  333.          *
  334.          *        Available stock of product A = 100 - 10 - (5 * 2) - (2 * 3) =  74
  335.          *
  336.          *  Bundle Children Sales Calculation
  337.          *  =================================
  338.          *  Sales of bundle children is sum of sales from their direct completed orders and sales of bundle children as part of completed orders of their bundle parents.
  339.          *
  340.          *  sales = sales_quantity + sales_quantity_in_bundles
  341.          *
  342.          *  eg: Sales calculation for product A which is part of Bundle 1 with quantity 2 and Bundle 2 with quantity 3
  343.          *      - Product A has sales 20 (direct product orders)
  344.          *      - Bundle 1 has sales 10
  345.          *      - Bundle 2 has sales 5
  346.          *
  347.          *      Sales of product A = 20 + (10 * 2) + (5 * 3) = 50
  348.          *
  349.          *
  350.          *  Bundle Parents Available Stock Calculation
  351.          *  ==========================================
  352.          *  Available stock of bundle parents is the minimum of available stock of bundle children divided by quantity of bundle children.
  353.          *
  354.          *  available_stock = MIN(available_stock_of_bundle_children / quantity)
  355.          *
  356.          *  eg: Bundle 1 has Product A with quantity 2 and Product B with quantity 3
  357.          *      - Product A has available stock 74
  358.          *      - Product B has available stock 60
  359.          *      - Product A is available quantity for Bundle 1 = 74 / 2 = 37
  360.          *      - Product B is available quantity for Bundle 1 = 60 / 3 = 20
  361.          *
  362.          *     Available stock of Bundle 1 = MIN(37, 20) = 20
  363.          *
  364.          *  Bundles Parents Availability And Close-out
  365.          *  ==========================================
  366.          *  If any child product is not available, then the parent product should not be available.
  367.          *  If any child product is close-out, then the parent product should be close-out.
  368.          *
  369.          */
  370.         /*
  371.          *  For bundle parents, we don't calculate available stock and sales directly.
  372.          *  We calculate available stock and sales of bundle children and then calculate available stock and sales of bundle parents.
  373.          *
  374.          *  If any bundle child's available stock is updated, then we need to update the available stock of bundle parent.
  375.          */
  376.         $bundleChildrenIdsFromUpdatedParentIds $this->bundleFilter->findChildrenIds($bundleParentIds$context);
  377.         $bundleParentIdsFromUpdatedChildrenIds $this->bundleFilter->findParentIds($bundleChildrenIds$context);
  378.         // We need to find the open and sale quantity of all children and their parents to calculate the available stock and sales of bundle children
  379.         $productIds array_unique(array_merge($bundleParentIds$bundleChildrenIds$bundleChildrenIdsFromUpdatedParentIds$bundleParentIdsFromUpdatedChildrenIds));
  380.         $openAndSaleQuantityOfProducts $this->findOpenAndSaleQuantityOfProducts($productIds$context);
  381.         // All the bundle children id's that are either directly updated or indirectly updated by parent product updates
  382.         $bundleChildrenIds array_unique(array_merge($bundleChildrenIds$bundleChildrenIdsFromUpdatedParentIds));
  383.         // We need to know the quantity of each child product in each bundle, finding them together in a single query is more efficient
  384.         $bundleQuantities $this->bundleFilter->findBundleQuantities($bundleChildrenIds$context);
  385.         // Update available stock and sales of bundle children
  386.         foreach ($bundleChildrenIds as $childId) {
  387.             $this->updateAvailableStockAndSalesOfBundleChild(
  388.                 $childId,
  389.                 $bundleQuantities[$childId],
  390.                 $openAndSaleQuantityOfProducts
  391.             );
  392.         }
  393.         $this->updateAvailableFlag($bundleChildrenIds$context);
  394.         // Update available stock and sales of bundle parents
  395.         $parentIds $this->bundleFilter->findParentIds($bundleChildrenIds$context);
  396.         $this->updateBundleStockAndAvailability($parentIds$context);
  397.         $this->updateParentProductsSales($parentIds$openAndSaleQuantityOfProducts);
  398.     }
  399.     /**
  400.      * @param array $bundleIds
  401.      * @param Context $context
  402.      * @return void
  403.      * @throws Exception
  404.      */
  405.     public function updateBundleStockAndAvailability(array $bundleIdsContext $context): void
  406.     {
  407.         /*
  408.          *  We need to know which all products are no longer after update, hence first we get currently available bundle products for given children.
  409.          *  After update, we again get available bundle products, by comparing we can find which all bundle products are no longer available.
  410.          *  We need to dispatch no longer available event for these products.
  411.          */
  412.         $availableBundleProductsBeforeUpdate $this->getCurrentlyAvailableBundleProducts($bundleIds);
  413.         $bundleAvailableStockUpdaterQuery = <<<SQL
  414.                 UPDATE product
  415.                     INNER JOIN
  416.                     (SELECT all_stock.product_id,
  417.                             # Close-out stock has higher priority than normal stock
  418.                             COALESCE(close_out_stock.stock, all_stock.stock)                     AS stock,
  419.                             COALESCE(close_out_stock.available_stock, all_stock.available_stock) AS available_stock,
  420.                             # If any child product is close-out, then the parent product should be close-out
  421.                             COALESCE(close_out_stock.is_close_out, 0)                            AS is_close_out
  422.                      FROM 
  423.                          # From all child products of bundles find the available stock of bundle, we assume none of the child products are close-out
  424.                          (SELECT parent_product.id                                                      AS product_id,
  425.                                   MIN((child_product.stock DIV wbfk_bundle_product.quantity))           AS stock,
  426.                                   MIN((child_product.available_stock DIV wbfk_bundle_product.quantity)) AS available_stock,
  427.                                   0                                                                     AS is_close_out
  428.                            FROM wbfk_bundle_product
  429.                                     INNER JOIN product parent_product ON wbfk_bundle_product.product_id = parent_product.id
  430.                                     INNER JOIN product child_product ON wbfk_bundle_product.child_product_id = child_product.id
  431.                            WHERE wbfk_bundle_product.product_id IN (:bundle_product_ids)
  432.                             AND product_version_id = :version_id
  433.                            GROUP BY parent_product.id) AS all_stock
  434.                               LEFT JOIN
  435.                           # From close-out child products of bundles find the available stock of bundle, available stock is minimum of all close-out child products   
  436.                           (SELECT parent_product.id                                                     AS product_id,
  437.                                   MIN((child_product.stock DIV wbfk_bundle_product.quantity))           AS stock,
  438.                                   MIN((child_product.available_stock DIV wbfk_bundle_product.quantity)) AS available_stock,
  439.                                   1                                                                     AS is_close_out
  440.                            FROM wbfk_bundle_product
  441.                                     INNER JOIN product parent_product ON wbfk_bundle_product.product_id = parent_product.id
  442.                                     INNER JOIN product child_product ON wbfk_bundle_product.child_product_id = child_product.id
  443.                                AND child_product.is_closeout = 1
  444.                            WHERE wbfk_bundle_product.product_id IN (:bundle_product_ids)
  445.                             AND product_version_id = :version_id
  446.                            GROUP BY parent_product.id) AS close_out_stock
  447.                           ON all_stock.product_id = close_out_stock.product_id) AS bundle_stock
  448.                     ON product.id = bundle_stock.product_id
  449.                         AND product.version_id = :version_id
  450.                 SET product.stock           = IF(bundle_stock.stock > 0, bundle_stock.stock, 0), # Stock of bundle parent cannot be negative
  451.                     product.available_stock = IF(bundle_stock.available_stock > 0 , bundle_stock.available_stock, 0), # Available stock of bundle parent cannot be negative
  452.                     product.is_closeout     = bundle_stock.is_close_out
  453.                 WHERE 
  454.                     product.stock != IF(bundle_stock.stock > 0, bundle_stock.stock, 0) OR 
  455.                     product.available_stock != IF(bundle_stock.available_stock > 0 , bundle_stock.available_stock, 0) OR 
  456.                     product.is_closeout != bundle_stock.is_close_out
  457.         SQL;
  458.         Profiler::trace('order::update-bundle-parent-stock', function () use ($bundleAvailableStockUpdaterQuery$bundleIds): void {
  459.             $this->connection->executeStatement(
  460.                 $bundleAvailableStockUpdaterQuery,
  461.                 [
  462.                     'bundle_product_ids' => Uuid::fromHexToBytesList($bundleIds),
  463.                     'version_id' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION),
  464.                 ],
  465.                 [
  466.                     'bundle_product_ids' => Connection::PARAM_STR_ARRAY,
  467.                 ]
  468.             );
  469.         });
  470.         $availableBundleProductsAfterUpdate $this->getCurrentlyAvailableBundleProducts($bundleIds);
  471.         $bundleProductsNoLongerAvailable array_diff($availableBundleProductsBeforeUpdate$availableBundleProductsAfterUpdate);
  472.         if (!empty($bundleProductsNoLongerAvailable)) {
  473.             $this->dispatcher->dispatch(new ProductNoLongerAvailableEvent($bundleProductsNoLongerAvailable$context));
  474.         }
  475.     }
  476.     /**
  477.      * @throws Exception
  478.      */
  479.     private function updateAvailableStockAndSalesOfBundleChild(string $bundleChildId, array $bundleQuantities, array $openAndSalesQuantitiesOfProducts): void
  480.     {
  481.         $openQuantity $openAndSalesQuantitiesOfProducts[$bundleChildId]['open_quantity'] ?? 0;
  482.         $salesQuantity $openAndSalesQuantitiesOfProducts[$bundleChildId]['sales_quantity'] ?? 0;
  483.         foreach ($bundleQuantities as $parentId => $quantity) {
  484.             $openAndSalesQuantityOfParent $openAndSalesQuantitiesOfProducts[$parentId] ?? ['open_quantity' => 0'sales_quantity' => 0];
  485.             $openQuantity += $openAndSalesQuantityOfParent['open_quantity'] * $quantity;
  486.             $salesQuantity += $openAndSalesQuantityOfParent['sales_quantity'] * $quantity;
  487.         }
  488.         // Update available stock of child product
  489.         $update = new RetryableQuery(
  490.             $this->connection,
  491.             $this->connection->prepare('UPDATE product SET available_stock = stock - :open_quantity, sales = :sales_quantity, updated_at = :now WHERE id = :id')
  492.         );
  493.         /** @noinspection PhpDeprecationInspection */
  494.         $update->execute([
  495.             'id' => Uuid::fromHexToBytes($bundleChildId),
  496.             'open_quantity' => $openQuantity,
  497.             'sales_quantity' => $salesQuantity,
  498.             'now' => (new DateTime())->format(Defaults::STORAGE_DATE_TIME_FORMAT),
  499.         ]);
  500.     }
  501.     /**
  502.      * @throws Exception
  503.      */
  504.     private function findOpenAndSaleQuantityOfProducts(array $idsContext $context): array
  505.     {
  506.         if (empty($ids)) {
  507.             return [];
  508.         }
  509.         $sql '
  510. SELECT LOWER(HEX(order_line_item.product_id)) as product_id,
  511.     IFNULL(
  512.         SUM(IF(state_machine_state.technical_name = :completed_state, 0, order_line_item.quantity)),
  513.         0
  514.     ) as open_quantity,
  515.     IFNULL(
  516.         SUM(IF(state_machine_state.technical_name = :completed_state, order_line_item.quantity, 0)),
  517.         0
  518.     ) as sales_quantity
  519. FROM order_line_item
  520.     INNER JOIN `order`
  521.         ON `order`.id = order_line_item.order_id
  522.         AND `order`.version_id = order_line_item.order_version_id
  523.     INNER JOIN state_machine_state
  524.         ON state_machine_state.id = `order`.state_id
  525.         AND state_machine_state.technical_name <> :cancelled_state
  526. WHERE order_line_item.product_id IN (:ids)
  527.     AND order_line_item.type = :type
  528.     AND order_line_item.version_id = :version
  529.     AND order_line_item.product_id IS NOT NULL
  530. GROUP BY product_id;
  531.         ';
  532.         $rows $this->connection->fetchAllAssociative(
  533.             $sql,
  534.             [
  535.                 'type' => LineItem::PRODUCT_LINE_ITEM_TYPE,
  536.                 'version' => Uuid::fromHexToBytes($context->getVersionId()),
  537.                 'completed_state' => OrderStates::STATE_COMPLETED,
  538.                 'cancelled_state' => OrderStates::STATE_CANCELLED,
  539.                 'ids' => Uuid::fromHexToBytesList($ids),
  540.             ],
  541.             [
  542.                 'ids' => Connection::PARAM_STR_ARRAY,
  543.             ]
  544.         );
  545.         // array key by product id
  546.         $openAndSalesQuantityOfProducts = [];
  547.         foreach ($rows as $row) {
  548.             $openAndSalesQuantityOfProducts[$row['product_id']] = [
  549.                 'open_quantity' => $row['open_quantity'],
  550.                 'sales_quantity' => $row['sales_quantity'],
  551.             ];
  552.         }
  553.         return $openAndSalesQuantityOfProducts;
  554.     }
  555.     /**
  556.      * @throws Exception
  557.      */
  558.     private function updateParentProductsSales(array $parentIds, array $openAndSaleQuantityOfProducts): void
  559.     {
  560.         $update = new RetryableQuery(
  561.             $this->connection,
  562.             $this->connection->prepare('UPDATE product SET sales = :sales_quantity, updated_at = :now WHERE id = :id')
  563.         );
  564.         foreach ($parentIds as $parentId) {
  565.             /** @noinspection PhpDeprecationInspection */
  566.             $update->execute([
  567.                 'id' => Uuid::fromHexToBytes($parentId),
  568.                 'sales_quantity' => $openAndSaleQuantityOfProducts[$parentId]['sales_quantity'] ?? 0,
  569.                 'now' => (new DateTime())->format(Defaults::STORAGE_DATE_TIME_FORMAT),
  570.             ]);
  571.         }
  572.     }
  573.     private function getChildProductLineItemsFromParents($parentProductIds$lineItems$bundleChildrenQuantities): array
  574.     {
  575.         $childProductLineItemsFromParents = [];
  576.         foreach ($parentProductIds as $parentId) {
  577.             $childQuantities $bundleChildrenQuantities[$parentId];
  578.             $parentQuantityInOrder $lineItems[array_search($parentIdarray_column($lineItems'referenced_id'), true)]['quantity'];
  579.             foreach ($childQuantities as $childId => $quantity) {
  580.                 if (isset($childProductLineItemsFromParents[$childId])) {
  581.                     $childProductLineItemsFromParents[$childId]['quantity'] += $quantity $parentQuantityInOrder;
  582.                     continue;
  583.                 }
  584.                 $childProductLineItemsFromParents[$childId] = [
  585.                     'referenced_id' => $childId,
  586.                     'quantity' => $quantity $parentQuantityInOrder,
  587.                 ];
  588.             }
  589.         }
  590.         return $childProductLineItemsFromParents;
  591.     }
  592.     /**
  593.      * @param array $bundleIds
  594.      * @return string[]
  595.      * @throws Exception
  596.      */
  597.     public function getCurrentlyAvailableBundleProducts(array $bundleIds): array
  598.     {
  599.         $availableBundleProductsQuery = <<<SQL
  600.             SELECT LOWER(HEX(product.id)) AS product_id FROM product
  601.             WHERE product.available = 1 AND 
  602.                   product.version_id = :version_id AND
  603.                   id IN (:ids)
  604.         SQL;
  605.         return $this->connection->fetchFirstColumn(
  606.             $availableBundleProductsQuery,
  607.             [
  608.                 'ids' => Uuid::fromHexToBytesList($bundleIds),
  609.                 'version_id' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION),
  610.             ],
  611.             [
  612.                 'ids' => Connection::PARAM_STR_ARRAY,
  613.             ]
  614.         );
  615.     }
  616. }