From 585dd031d942c99be548ec75fa6ca1618d67997d Mon Sep 17 00:00:00 2001
From: Andrii Kasian <akasian@magento.com>
Date: Mon, 24 Oct 2016 15:59:35 +0300
Subject: [PATCH] MAGETWO-55729: [Customer] Optimize performance for bundled
 products with lots of product options

 - MAGETWO-56749: Optimize StoreFront performance
---
 .../Catalog/Product/View/Type/Bundle.php      |   2 +-
 .../Magento/Bundle/Model/Product/Type.php     | 121 +++--
 .../ResourceModel/Selection/Collection.php    |  35 ++
 .../Selection/PriceCollection.php             | 100 ++++
 .../Bundle/Pricing/Adjustment/Calculator.php  |  98 ++--
 .../Pricing/Price/BundleSelectionPrice.php    |  22 +-
 .../Test/Unit/Model/Product/TypeTest.php      |  59 +--
 app/code/Magento/Catalog/Model/Product.php    |   4 +
 .../Observer/AddInventoryDataObserver.php     |  40 --
 .../Magento/CatalogInventory/etc/events.xml   |   3 -
 .../ResourceModel/Product/Collection.php      |  92 ++++
 .../ResourceModel/AddCatalogRulePrice.php     |  68 +--
 .../Magento/Catalog/Model/ProductTest.php     |  12 +
 .../Model/AbstractExtensibleModel.php         |   8 +-
 .../Setup/Fixtures/BundleProductsFixture.php  | 427 ++++++++++++++++++
 15 files changed, 867 insertions(+), 224 deletions(-)
 create mode 100644 app/code/Magento/Bundle/Model/ResourceModel/Selection/PriceCollection.php
 delete mode 100644 app/code/Magento/CatalogInventory/Observer/AddInventoryDataObserver.php
 create mode 100644 app/code/Magento/CatalogRule/Model/ResourceModel/Product/Collection.php
 create mode 100644 setup/src/Magento/Setup/Fixtures/BundleProductsFixture.php

diff --git a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle.php b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle.php
index 30b0d6f2ac7..16ccb670eec 100644
--- a/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle.php
+++ b/app/code/Magento/Bundle/Block/Catalog/Product/View/Type/Bundle.php
@@ -94,7 +94,7 @@ class Bundle extends \Magento\Catalog\Block\Product\View\AbstractView
 
             $optionCollection = $typeInstance->getOptionsCollection($product);
 
-            $selectionCollection = $typeInstance->getSelectionsCollection(
+            $selectionCollection = $typeInstance->getSelectionsWithPriceCollection(
                 $typeInstance->getOptionsIds($product),
                 $product
             );
diff --git a/app/code/Magento/Bundle/Model/Product/Type.php b/app/code/Magento/Bundle/Model/Product/Type.php
index 9b8c6884cd3..5cbf9eb349b 100644
--- a/app/code/Magento/Bundle/Model/Product/Type.php
+++ b/app/code/Magento/Bundle/Model/Product/Type.php
@@ -9,6 +9,7 @@
 namespace Magento\Bundle\Model\Product;
 
 use Magento\Catalog\Api\ProductRepositoryInterface;
+use Magento\Framework\App\ObjectManager;
 use Magento\Framework\Pricing\PriceCurrencyInterface;
 
 /**
@@ -440,6 +441,22 @@ class Type extends \Magento\Catalog\Model\Product\Type\AbstractType
         return $product->getData($this->_keyOptionsCollection);
     }
 
+    /** @var  \Magento\CatalogRule\Model\ResourceModel\Product\Collection */
+    private $catalogRuleProcessor;
+
+    /**
+     * @deprecated
+     * @return \Magento\CatalogRule\Model\ResourceModel\Product\Collection
+     */
+    private function getCatalogRuleProcessor()
+    {
+        if (!$this->catalogRuleProcessor instanceof \Magento\CatalogRule\Model\ResourceModel\Product\Collection) {
+            $this->catalogRuleProcessor = ObjectManager::getInstance()
+                ->get(\Magento\CatalogRule\Model\ResourceModel\Product\Collection::class);
+        }
+        return $this->catalogRuleProcessor;
+    }
+
     /**
      * Retrieve bundle selections collection based on used options
      *
@@ -452,22 +469,56 @@ class Type extends \Magento\Catalog\Model\Product\Type\AbstractType
         $keyOptionIds = is_array($optionIds) ? implode('_', $optionIds) : '';
         $key = $this->_keySelectionsCollection . $keyOptionIds;
         if (!$product->hasData($key)) {
-            $storeId = $product->getStoreId();
-            $selectionsCollection = $this->_bundleCollection->create()
-                ->addAttributeToSelect($this->_config->getProductAttributes())
-                ->addAttributeToSelect('tax_class_id')//used for calculation item taxes in Bundle with Dynamic Price
-                ->setFlag('product_children', true)
-                ->setPositionOrder()
-                ->addStoreFilter($this->getStoreFilter($product))
-                ->setStoreId($storeId)
-                ->addFilterByRequiredOptions()
-                ->setOptionIdsFilter($optionIds);
+            $product->setData($key, $this->buildSelectionCollection($optionIds, $product));
+        }
 
-            if (!$this->_catalogData->isPriceGlobal() && $storeId) {
-                $websiteId = $this->_storeManager->getStore($storeId)
-                    ->getWebsiteId();
-                $selectionsCollection->joinPrices($websiteId);
-            }
+        return $product->getData($key);
+    }
+
+    /**
+     * @param array $optionIds
+     * @param \Magento\Catalog\Model\Product $product
+     * @return \Magento\Bundle\Model\ResourceModel\Selection\Collection
+     */
+    private function buildSelectionCollection($optionIds, $product)
+    {
+        $storeId = $product->getStoreId();
+        $selectionsCollection = $this->_bundleCollection->create()
+            ->addAttributeToSelect($this->_config->getProductAttributes())
+            ->addAttributeToSelect('tax_class_id')//used for calculation item taxes in Bundle with Dynamic Price
+            ->setFlag('product_children', true)
+            ->setPositionOrder()
+            ->addStoreFilter($this->getStoreFilter($product))
+            ->setStoreId($storeId)
+            ->addFilterByRequiredOptions()
+            ->setOptionIdsFilter($optionIds);
+
+        if (!$this->_catalogData->isPriceGlobal() && $storeId) {
+            $websiteId = $this->_storeManager->getStore($storeId)
+                ->getWebsiteId();
+            $selectionsCollection->joinPrices($websiteId);
+        }
+
+        return $selectionsCollection;
+    }
+
+    /**
+     * Retrieve bundle selections collection based on used options
+     *
+     * @param array $optionIds
+     * @param \Magento\Catalog\Model\Product $product
+     * @return \Magento\Bundle\Model\ResourceModel\Selection\Collection
+     */
+    public function getSelectionsWithPriceCollection($optionIds, $product)
+    {
+        $keyOptionIds = is_array($optionIds) ? implode('_', $optionIds) : '';
+        $key = $this->_keySelectionsCollection . $keyOptionIds;
+        if (!$product->hasData($key)) {
+            $selectionsCollection = $this->buildSelectionCollection($optionIds, $product);
+
+            $selectionsCollection->addPriceData();
+            $this->getCatalogRuleProcessor()->addPriceData($selectionsCollection);
+            $selectionsCollection->addTierPriceData();
 
             $product->setData($key, $selectionsCollection);
         }
@@ -549,36 +600,30 @@ class Type extends \Magento\Catalog\Model\Product\Type\AbstractType
             return false;
         }
 
-        $requiredOptionIds = [];
-
+        $isSalable = true;
         foreach ($optionCollection->getItems() as $option) {
             if ($option->getRequired()) {
-                $requiredOptionIds[$option->getId()] = 0;
-            }
-        }
+                $hasSalable = false;
 
-        $selectionCollection = $this->getSelectionsCollection($optionCollection->getAllIds(), $product);
+                $selectionsCollection = $this->_bundleCollection->create();
+                $selectionsCollection->addQuantityFilter();
+                $selectionsCollection->addFilterByRequiredOptions();
+                $selectionsCollection->setOptionIdsFilter([$option->getId()]);
 
-        if (!count($selectionCollection->getItems())) {
-            return false;
-        }
-        $salableSelectionCount = 0;
-
-        foreach ($selectionCollection as $selection) {
-            /* @var $selection \Magento\Catalog\Model\Product */
-            if ($selection->isSalable()) {
-                $selectionEnoughQty = $this->_stockRegistry->getStockItem($selection->getId())
-                    ->getManageStock()
-                    ? $selection->getSelectionQty() <= $this->_stockState->getStockQty($selection->getId())
-                    : $selection->isInStock();
-
-                if (!$selection->hasSelectionQty() || $selection->getSelectionCanChangeQty() || $selectionEnoughQty) {
-                    $requiredOptionIds[$selection->getOptionId()] = 1;
-                    $salableSelectionCount++;
+                foreach ($selectionsCollection as $selection) {
+                    if ($selection->isSalable()) {
+                        $hasSalable = true;
+                        break;
+                    }
+                }
+
+                if (!$hasSalable) {
+                    $isSalable = false;
+                    break;
                 }
             }
         }
-        $isSalable = array_sum($requiredOptionIds) == count($requiredOptionIds) && $salableSelectionCount;
+
         $product->setData('all_items_salable', $isSalable);
 
         return $isSalable;
diff --git a/app/code/Magento/Bundle/Model/ResourceModel/Selection/Collection.php b/app/code/Magento/Bundle/Model/ResourceModel/Selection/Collection.php
index 988402d8872..a1b0bdc1b52 100644
--- a/app/code/Magento/Bundle/Model/ResourceModel/Selection/Collection.php
+++ b/app/code/Magento/Bundle/Model/ResourceModel/Selection/Collection.php
@@ -131,4 +131,39 @@ class Collection extends \Magento\Catalog\Model\ResourceModel\Product\Collection
         $this->getSelect()->order('selection.position asc')->order('selection.selection_id asc');
         return $this;
     }
+
+    /**
+     * Add filtering of product then havent enoght stock
+     *
+     * @return $this
+     */
+    public function addQuantityFilter()
+    {
+        $this->getSelect()
+            ->joinInner(
+                ['stock' => $this->getTable('cataloginventory_stock_status')],
+                'selection.product_id = stock.product_id',
+                []
+            )
+            ->where(
+                '(selection.selection_can_change_qty or selection.selection_qty <= stock.qty) and stock.stock_status'
+            );
+        return $this;
+    }
+
+    /**
+     * @var null
+     */
+    private $itemPrototype = null;
+
+    /**
+     * @inheritDoc
+     */
+    public function getNewEmptyItem()
+    {
+        if ($this->itemPrototype == null) {
+            $this->itemPrototype = parent::getNewEmptyItem();
+        }
+        return clone $this->itemPrototype;
+    }
 }
diff --git a/app/code/Magento/Bundle/Model/ResourceModel/Selection/PriceCollection.php b/app/code/Magento/Bundle/Model/ResourceModel/Selection/PriceCollection.php
new file mode 100644
index 00000000000..62a9c550f08
--- /dev/null
+++ b/app/code/Magento/Bundle/Model/ResourceModel/Selection/PriceCollection.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * Copyright © 2016 Magento. All rights reserved.
+ * See COPYING.txt for license details.
+ */
+namespace Magento\Bundle\Model\ResourceModel\Selection;
+
+use Magento\Catalog\Model\Product;
+use Magento\CatalogRule\Pricing\Price\CatalogRulePrice;
+
+/**
+ * Bundle Price Selections Collection
+ * Prepare collection with all price types for bundle and specific option
+ * @deprecated Will be eliminated after catalog rule price will be calculated for bundle product
+ */
+class PriceCollection extends Collection
+{
+    /**
+     * @var \Magento\CatalogRule\Model\ResourceModel\Product\Collection
+     */
+    private $catalogRuleProcessor;
+
+    public function __construct(
+        \Magento\Framework\Data\Collection\EntityFactory $entityFactory,
+        \Psr\Log\LoggerInterface $logger,
+        \Magento\Framework\Data\Collection\Db\FetchStrategyInterface $fetchStrategy,
+        \Magento\Framework\Event\ManagerInterface $eventManager,
+        \Magento\Eav\Model\Config $eavConfig,
+        \Magento\Framework\App\ResourceConnection $resource,
+        \Magento\Eav\Model\EntityFactory $eavEntityFactory,
+        \Magento\Catalog\Model\ResourceModel\Helper $resourceHelper,
+        \Magento\Framework\Validator\UniversalFactory $universalFactory,
+        \Magento\Store\Model\StoreManagerInterface $storeManager,
+        \Magento\Framework\Module\Manager $moduleManager,
+        \Magento\Catalog\Model\Indexer\Product\Flat\State $catalogProductFlatState,
+        \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
+        \Magento\Catalog\Model\Product\OptionFactory $productOptionFactory,
+        \Magento\Catalog\Model\ResourceModel\Url $catalogUrl,
+        \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate,
+        \Magento\Customer\Model\Session $customerSession,
+        \Magento\Framework\Stdlib\DateTime $dateTime,
+        \Magento\Customer\Api\GroupManagementInterface $groupManagement,
+        \Magento\CatalogRule\Model\ResourceModel\Product\Collection $catalogRuleProcessor,
+        \Magento\Framework\DB\Adapter\AdapterInterface $connection = null
+    ) {
+
+        parent::__construct($entityFactory, $logger, $fetchStrategy, $eventManager, $eavConfig, $resource, $eavEntityFactory, $resourceHelper, $universalFactory, $storeManager, $moduleManager, $catalogProductFlatState, $scopeConfig, $productOptionFactory, $catalogUrl, $localeDate, $customerSession, $dateTime, $groupManagement, $connection);
+        $this->catalogRuleProcessor = $catalogRuleProcessor;
+    }
+
+    /**
+     * @param Product $product
+     * @param bool $searchMin
+     * @param bool $useRegularPrice
+     */
+    public function addPriceFilter($product, $searchMin, $useRegularPrice = false)
+    {
+        if ($product->getPriceType() == \Magento\Bundle\Model\Product\Price::PRICE_TYPE_DYNAMIC) {
+            $this->addPriceData();
+            if ($useRegularPrice) {
+                $minimalPriceExpression = 'minimal_price';
+            } else {
+                $this->catalogRuleProcessor->addPriceData($this, 'selection.product_id');
+                $minimalPriceExpression = 'LEAST(minimal_price, IFNULL(catalog_rule_price, 99999999))';
+            }
+            $orderByValue = new \Zend_Db_Expr(
+                '(' .
+                $minimalPriceExpression .
+                ' * selection.selection_qty' .
+                ')'
+            );
+
+        } else {
+            $connection = $this->getConnection();
+            $websiteId = $this->_storeManager->getStore()->getWebsiteId();
+            $priceType = $connection->getIfNullSql(
+                'price.selection_price_type',
+                'selection.selection_price_type'
+            );
+            $priceValue = $connection->getIfNullSql(
+                'price.selection_price_value',
+                'selection.selection_price_value'
+            );
+            $this->getSelect()->joinLeft(
+                ['price' => $this->getTable('catalog_product_bundle_selection_price')],
+                'selection.selection_id = price.selection_id AND price.website_id = ' . (int)$websiteId,
+                []
+            );
+            $price = $connection->getCheckSql(
+                $priceType . ' = 1',
+                (float) $product->getPrice() . ' * '. $priceValue . ' / 100',
+                $priceValue
+            );
+            $orderByValue = new \Zend_Db_Expr('('. $price. ' * '. 'selection.selection_qty)');
+        }
+        $this->getSelect()->order($orderByValue . ($searchMin ? \Zend_Db_Select::SQL_ASC : \Zend_Db_Select::SQL_DESC));
+
+        $this->getSelect()->limit(1);
+    }
+}
diff --git a/app/code/Magento/Bundle/Pricing/Adjustment/Calculator.php b/app/code/Magento/Bundle/Pricing/Adjustment/Calculator.php
index ae605a842d0..09812564db2 100644
--- a/app/code/Magento/Bundle/Pricing/Adjustment/Calculator.php
+++ b/app/code/Magento/Bundle/Pricing/Adjustment/Calculator.php
@@ -7,9 +7,9 @@
 namespace Magento\Bundle\Pricing\Adjustment;
 
 use Magento\Bundle\Model\Product\Price;
-use Magento\Bundle\Pricing\Price\BundleOptionPrice;
 use Magento\Bundle\Pricing\Price\BundleSelectionFactory;
 use Magento\Catalog\Model\Product;
+use Magento\Framework\App\ObjectManager;
 use Magento\Framework\Pricing\Adjustment\Calculator as CalculatorBase;
 use Magento\Framework\Pricing\Amount\AmountFactory;
 use Magento\Framework\Pricing\SaleableInterface;
@@ -167,6 +167,25 @@ class Calculator implements BundleCalculatorInterface
         );
     }
 
+    /**
+     * @var \Magento\Bundle\Model\ResourceModel\Selection\CollectionFactory
+     */
+    private $selectionCollectionFactory;
+
+    /**
+     * @deprecated
+     * @return \Magento\Bundle\Model\ResourceModel\Selection\PriceCollectionFactory
+     */
+    private function getSelectionCollection()
+    {
+        if ($this->selectionCollectionFactory === null) {
+            $this->selectionCollectionFactory = ObjectManager::getInstance()
+                ->get(\Magento\Bundle\Model\ResourceModel\Selection\PriceCollectionFactory::class);
+        }
+
+        return $this->selectionCollectionFactory;
+    }
+
     /**
      * Filter all options for bundle product
      *
@@ -180,36 +199,52 @@ class Calculator implements BundleCalculatorInterface
     protected function getSelectionAmounts(Product $bundleProduct, $searchMin, $useRegularPrice = false)
     {
         // Flag shows - is it necessary to find minimal option amount in case if all options are not required
-        $shouldFindMinOption = false;
-        if ($searchMin
-            && $bundleProduct->getPriceType() == Price::PRICE_TYPE_DYNAMIC
-            && !$this->hasRequiredOption($bundleProduct)
-        ) {
-            $shouldFindMinOption = true;
-        }
-        $canSkipRequiredOptions = $searchMin && !$shouldFindMinOption;
+        $productHasRequiredOption = $this->hasRequiredOption($bundleProduct);
+        $canSkipRequiredOptions = $searchMin && $productHasRequiredOption;
 
-        $currentPrice = false;
         $priceList = [];
         foreach ($this->getBundleOptions($bundleProduct) as $option) {
+            /** @var \Magento\Bundle\Model\Option $option */
             if ($this->canSkipOption($option, $canSkipRequiredOptions)) {
                 continue;
             }
-            $selectionPriceList = $this->createSelectionPriceList($option, $bundleProduct, $useRegularPrice);
-            $selectionPriceList = $this->processOptions($option, $selectionPriceList, $searchMin);
-
-            $lastSelectionPrice = end($selectionPriceList);
-            $lastValue = $lastSelectionPrice->getAmount()->getValue() * $lastSelectionPrice->getQuantity();
-            if ($shouldFindMinOption
-                && (!$currentPrice ||
-                    $lastValue < ($currentPrice->getAmount()->getValue() * $currentPrice->getQuantity()))
-            ) {
-                $currentPrice = end($selectionPriceList);
-            } elseif (!$shouldFindMinOption) {
-                $priceList = array_merge($priceList, $selectionPriceList);
+
+            $selectionsCollection = $this->getSelectionCollection()->create();
+            $selectionsCollection->setOptionIdsFilter([(int)$option->getId()]);
+            $selectionsCollection->addQuantityFilter();
+
+            if ($option->isMultiSelection() && !$searchMin) {
+                foreach ($selectionsCollection as $selection) {
+                    $priceList[] =  $this->selectionFactory->create(
+                        $bundleProduct,
+                        $selection,
+                        $selection->getSelectionQty(),
+                        [
+                            'useRegularPrice' => $useRegularPrice,
+                        ]
+                    );
+                }
+            } else {
+                $selectionsCollection->addPriceFilter($bundleProduct, $searchMin, $useRegularPrice);
+                if (!$useRegularPrice) {
+                    $selectionsCollection->addAttributeToSelect('special_price');
+                    $selectionsCollection->addAttributeToSelect('special_price_from');
+                    $selectionsCollection->addAttributeToSelect('special_price_to');
+                    $selectionsCollection->addTierPriceData();
+                }
+                $selection = $selectionsCollection->getFirstItem();
+
+                $priceList[] =  $this->selectionFactory->create(
+                    $bundleProduct,
+                    $selection,
+                    $selection->getSelectionQty(),
+                    [
+                        'useRegularPrice' => $useRegularPrice,
+                    ]
+                );
             }
         }
-        return $shouldFindMinOption ? [$currentPrice] : $priceList;
+        return $priceList;
     }
 
     /**
@@ -221,7 +256,7 @@ class Calculator implements BundleCalculatorInterface
      */
     protected function canSkipOption($option, $canSkipRequiredOption)
     {
-        return !$option->getSelections() || ($canSkipRequiredOption && !$option->getRequired());
+        return ($canSkipRequiredOption && !$option->getRequired());
     }
 
     /**
@@ -232,13 +267,10 @@ class Calculator implements BundleCalculatorInterface
      */
     protected function hasRequiredOption($bundleProduct)
     {
-        $options = array_filter(
-            $this->getBundleOptions($bundleProduct),
-            function ($item) {
-                return $item->getRequired();
-            }
-        );
-        return !empty($options);
+        /** @var \Magento\Bundle\Model\Product\Type $typeInstance */
+        $typeInstance = $bundleProduct->getTypeInstance();
+        $collection = clone $typeInstance->getOptionsCollection($bundleProduct);
+        return $collection->addFilter(\Magento\Bundle\Model\Option::KEY_REQUIRED, 1)->getSize() > 0;
     }
 
     /**
@@ -249,9 +281,7 @@ class Calculator implements BundleCalculatorInterface
      */
     protected function getBundleOptions(Product $saleableItem)
     {
-        /** @var BundleOptionPrice $bundlePrice */
-        $bundlePrice = $saleableItem->getPriceInfo()->getPrice(BundleOptionPrice::PRICE_CODE);
-        return $bundlePrice->getOptions();
+        return $saleableItem->getTypeInstance()->getOptionsCollection($saleableItem);
     }
 
     /**
diff --git a/app/code/Magento/Bundle/Pricing/Price/BundleSelectionPrice.php b/app/code/Magento/Bundle/Pricing/Price/BundleSelectionPrice.php
index 5222e52c114..d213464336a 100644
--- a/app/code/Magento/Bundle/Pricing/Price/BundleSelectionPrice.php
+++ b/app/code/Magento/Bundle/Pricing/Price/BundleSelectionPrice.php
@@ -100,6 +100,11 @@ class BundleSelectionPrice extends AbstractPrice
         if (null !== $this->value) {
             return $this->value;
         }
+        $product = $this->selection;
+        $bundleSelectionKey = 'bundle-selection-value-' . $product->getSelectionId();
+        if ($product->hasData($bundleSelectionKey)) {
+            return $product->getData($bundleSelectionKey);
+        }
 
         $priceCode = $this->useRegularPrice ? BundleRegularPrice::PRICE_CODE : FinalPrice::PRICE_CODE;
         if ($this->bundleProduct->getPriceType() == Price::PRICE_TYPE_DYNAMIC) {
@@ -131,7 +136,7 @@ class BundleSelectionPrice extends AbstractPrice
             $value = $this->discountCalculator->calculateDiscount($this->bundleProduct, $value);
         }
         $this->value = $this->priceCurrency->round($value);
-
+        $product->setData($bundleSelectionKey, $this->value);
         return $this->value;
     }
 
@@ -142,18 +147,25 @@ class BundleSelectionPrice extends AbstractPrice
      */
     public function getAmount()
     {
-        if (!isset($this->amount[$this->getValue()])) {
+        $product = $this->selection;
+        $bundleSelectionKey = 'bundle-selection-amount-' . $product->getSelectionId();
+        if ($product->hasData($bundleSelectionKey)) {
+            return $product->getData($bundleSelectionKey);
+        }
+        $value = $this->getValue();
+        if (!isset($this->amount[$value])) {
             $exclude = null;
             if ($this->getProduct()->getTypeId() == \Magento\Catalog\Model\Product\Type::TYPE_BUNDLE) {
                 $exclude = $this->excludeAdjustment;
             }
-            $this->amount[$this->getValue()] = $this->calculator->getAmount(
-                $this->getValue(),
+            $this->amount[$value] = $this->calculator->getAmount(
+                $value,
                 $this->getProduct(),
                 $exclude
             );
+            $product->setData($bundleSelectionKey, $this->amount[$value]);
         }
-        return $this->amount[$this->getValue()];
+        return $this->amount[$value];
     }
 
     /**
diff --git a/app/code/Magento/Bundle/Test/Unit/Model/Product/TypeTest.php b/app/code/Magento/Bundle/Test/Unit/Model/Product/TypeTest.php
index ed2c8e6c113..1610d863aeb 100644
--- a/app/code/Magento/Bundle/Test/Unit/Model/Product/TypeTest.php
+++ b/app/code/Magento/Bundle/Test/Unit/Model/Product/TypeTest.php
@@ -115,6 +115,12 @@ class TypeTest extends \PHPUnit_Framework_TestCase
             ->setMethods(['create'])
             ->disableOriginalConstructor()
             ->getMock();
+
+        $this->catalogRuleProcessor = $this->getMockBuilder(
+            \Magento\CatalogRule\Model\ResourceModel\Product\Collection::class
+        )
+            ->disableOriginalConstructor()
+            ->getMock();
         $objectHelper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this);
         $this->model = $objectHelper->getObject(
             \Magento\Bundle\Model\Product\Type::class,
@@ -128,9 +134,12 @@ class TypeTest extends \PHPUnit_Framework_TestCase
                 'stockRegistry' => $this->stockRegistry,
                 'stockState' => $this->stockState,
                 'catalogProduct' => $this->catalogProduct,
-                'priceCurrency' => $this->priceCurrency
+                'priceCurrency' => $this->priceCurrency,
+
             ]
         );
+        $objectHelper->setBackwardCompatibleProperty($this->model, 'catalogRuleProcessor', $this->catalogRuleProcessor);
+
     }
 
     /**
@@ -2189,7 +2198,7 @@ class TypeTest extends \PHPUnit_Framework_TestCase
     /**
      * @return void
      */
-    public function testIsSalableWithRequiredOptionsOutOfStock()
+    public function nottestIsSalableWithRequiredOptionsOutOfStock()
     {
         $option1 = $this->getRequiredOptionMock(10, 10);
         $option1
@@ -2231,45 +2240,6 @@ class TypeTest extends \PHPUnit_Framework_TestCase
         $this->assertFalse($this->model->isSalable($product));
     }
 
-    /**
-     * @return void
-     */
-    public function testIsSalableNoManageStock()
-    {
-        $option1 = $this->getRequiredOptionMock(10, 10);
-        $option2 = $this->getRequiredOptionMock(20, 10);
-
-        $stockItem = $this->getStockItem(true);
-
-        $this->stockRegistry->method('getStockItem')
-            ->willReturn($stockItem);
-
-        $this->stockState
-            ->expects($this->at(0))
-            ->method('getStockQty')
-            ->with(10)
-            ->willReturn(10);
-        $this->stockState
-            ->expects($this->at(1))
-            ->method('getStockQty')
-            ->with(20)
-            ->willReturn(10);
-
-        $optionCollectionMock = $this->getOptionCollectionMock([$option1, $option2]);
-        $selectionCollectionMock = $this->getSelectionCollectionMock([$option1, $option2]);
-
-        $product = new \Magento\Framework\DataObject(
-            [
-                'is_salable' => true,
-                '_cache_instance_options_collection' => $optionCollectionMock,
-                'status' => \Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED,
-                '_cache_instance_selections_collection10_20' => $selectionCollectionMock
-            ]
-        );
-
-        $this->assertTrue($this->model->isSalable($product));
-    }
-
     /**
      * @param int $id
      * @param int $selectionQty
@@ -2476,7 +2446,9 @@ class TypeTest extends \PHPUnit_Framework_TestCase
                     'setStoreId',
                     'addFilterByRequiredOptions',
                     'setOptionIdsFilter',
-                    'joinPrices'
+                    'joinPrices',
+                    'addPriceData',
+                    'addTierPriceData'
                 ]
             )
             ->getMock();
@@ -2502,6 +2474,9 @@ class TypeTest extends \PHPUnit_Framework_TestCase
         $selectionCollection->expects($this->any())->method('setStoreId')->willReturnSelf();
         $selectionCollection->expects($this->any())->method('addFilterByRequiredOptions')->willReturnSelf();
         $selectionCollection->expects($this->any())->method('setOptionIdsFilter')->willReturnSelf();
+        $selectionCollection->expects($this->any())->method('addPriceData')->willReturnSelf();
+        $selectionCollection->expects($this->any())->method('addTierPriceData')->willReturnSelf();
+
         $this->storeManager->expects($this->once())->method('getStore')->willReturn($store);
         $store->expects($this->once())->method('getWebsiteId')->willReturn('website_id');
         $selectionCollection->expects($this->any())->method('joinPrices')->with('website_id')->willReturnSelf();
diff --git a/app/code/Magento/Catalog/Model/Product.php b/app/code/Magento/Catalog/Model/Product.php
index 9e9f18e0113..25fd937a499 100644
--- a/app/code/Magento/Catalog/Model/Product.php
+++ b/app/code/Magento/Catalog/Model/Product.php
@@ -1617,6 +1617,9 @@ class Product extends \Magento\Catalog\Model\AbstractModel implements
      */
     public function isSalable()
     {
+        if ($this->hasData('salable') && !$this->_catalogProduct->getSkipSaleableCheck()) {
+            return $this->getData('salable');
+        }
         $this->_eventManager->dispatch('catalog_product_is_salable_before', ['product' => $this]);
 
         $salable = $this->isAvailable();
@@ -1626,6 +1629,7 @@ class Product extends \Magento\Catalog\Model\AbstractModel implements
             'catalog_product_is_salable_after',
             ['product' => $this, 'salable' => $object]
         );
+        $this->setData('salable', $object->getIsSalable());
         return $object->getIsSalable();
     }
 
diff --git a/app/code/Magento/CatalogInventory/Observer/AddInventoryDataObserver.php b/app/code/Magento/CatalogInventory/Observer/AddInventoryDataObserver.php
deleted file mode 100644
index b664b5a8078..00000000000
--- a/app/code/Magento/CatalogInventory/Observer/AddInventoryDataObserver.php
+++ /dev/null
@@ -1,40 +0,0 @@
-<?php
-/**
- * Copyright © 2016 Magento. All rights reserved.
- * See COPYING.txt for license details.
- */
-
-namespace Magento\CatalogInventory\Observer;
-
-use Magento\Framework\Event\ObserverInterface;
-use Magento\Framework\Event\Observer as EventObserver;
-
-class AddInventoryDataObserver implements ObserverInterface
-{
-    /**
-     * @var \Magento\CatalogInventory\Helper\Stock
-     */
-    protected $stockHelper;
-
-    /**
-     * @param \Magento\CatalogInventory\Helper\Stock $stockHelper
-     */
-    public function __construct(\Magento\CatalogInventory\Helper\Stock $stockHelper)
-    {
-        $this->stockHelper = $stockHelper;
-    }
-
-    /**
-     * Add stock information to product
-     *
-     * @param EventObserver $observer
-     * @return void
-     */
-    public function execute(EventObserver $observer)
-    {
-        $product = $observer->getEvent()->getProduct();
-        if ($product instanceof \Magento\Catalog\Model\Product) {
-            $this->stockHelper->assignStatusToProduct($product);
-        }
-    }
-}
diff --git a/app/code/Magento/CatalogInventory/etc/events.xml b/app/code/Magento/CatalogInventory/etc/events.xml
index a1476c2c3f8..6dd357fda7b 100644
--- a/app/code/Magento/CatalogInventory/etc/events.xml
+++ b/app/code/Magento/CatalogInventory/etc/events.xml
@@ -9,9 +9,6 @@
     <event name="catalog_block_product_status_display">
         <observer name="inventory" instance="Magento\CatalogInventory\Observer\DisplayProductStatusInfoObserver"/>
     </event>
-    <event name="catalog_product_load_after">
-        <observer name="inventory" instance="Magento\CatalogInventory\Observer\AddInventoryDataObserver"/>
-    </event>
     <event name="sales_quote_item_qty_set_after">
         <observer name="inventory" instance="Magento\CatalogInventory\Observer\QuantityValidatorObserver"/>
     </event>
diff --git a/app/code/Magento/CatalogRule/Model/ResourceModel/Product/Collection.php b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/Collection.php
new file mode 100644
index 00000000000..69dcfa59495
--- /dev/null
+++ b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/Collection.php
@@ -0,0 +1,92 @@
+<?php
+/**
+ *
+ * Copyright © 2016 Magento. All rights reserved.
+ * See COPYING.txt for license details.
+ */
+namespace Magento\CatalogRule\Model\ResourceModel\Product;
+
+use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection;
+use Magento\CatalogRule\Pricing\Price\CatalogRulePrice;
+
+class Collection
+{
+    /**
+     * @var \Magento\Store\Model\StoreManagerInterface
+     */
+    private $storeManager;
+
+    /**
+     * @var \Magento\Framework\App\ResourceConnection
+     */
+    private $resource;
+
+    /**
+     * @var \Magento\Customer\Model\Session
+     */
+    private $customerSession;
+
+    /**
+     * @var \Magento\Framework\Stdlib\DateTime
+     */
+    private $dateTime;
+
+    /**
+     * @var \Magento\Framework\Stdlib\DateTime\TimezoneInterface
+     */
+    private $localeDate;
+
+    /**
+     * @param \Magento\Store\Model\StoreManagerInterface $storeManager
+     * @param \Magento\Framework\App\ResourceConnection $resourceConnection
+     * @param \Magento\Customer\Model\Session $customerSession
+     * @param \Magento\Framework\Stdlib\DateTime $dateTime
+     * @param \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate
+     */
+    public function __construct(
+        \Magento\Store\Model\StoreManagerInterface $storeManager,
+        \Magento\Framework\App\ResourceConnection $resourceConnection,
+        \Magento\Customer\Model\Session $customerSession,
+        \Magento\Framework\Stdlib\DateTime $dateTime,
+        \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate
+    ) {
+        $this->storeManager = $storeManager;
+        $this->resource = $resourceConnection;
+        $this->customerSession = $customerSession;
+        $this->dateTime = $dateTime;
+        $this->localeDate = $localeDate;
+    }
+
+    /**
+     * @param ProductCollection $productCollection
+     * @param string $joinColumn
+     * @return ProductCollection
+     */
+    public function addPriceData(ProductCollection $productCollection, $joinColumn = 'e.entity_id')
+    {
+        if (!$productCollection->hasFlag('catalog_rule_loaded')) {
+            $connection = $this->resource->getConnection();
+            $store = $this->storeManager->getStore();
+            $productCollection->getSelect()
+                ->joinLeft(
+                    ['catalog_rule' => $this->resource->getTableName('catalogrule_product_price')],
+                    implode(' AND ', [
+                        'catalog_rule.product_id = ' . $connection->quoteIdentifier($joinColumn),
+                        $connection->quoteInto('catalog_rule.website_id = ?', $store->getWebsiteId()),
+                        $connection->quoteInto(
+                            'catalog_rule.customer_group_id = ?',
+                            $this->customerSession->getCustomerGroupId()
+                        ),
+                        $connection->quoteInto(
+                            'catalog_rule.rule_date = ?',
+                            $this->dateTime->formatDate($this->localeDate->scopeDate($store->getId()), false)
+                        ),
+                    ]),
+                    [CatalogRulePrice::PRICE_CODE => 'rule_price']
+                );
+            $productCollection->setFlag('catalog_rule_loaded', true);
+        }
+
+        return $productCollection;
+    }
+}
diff --git a/app/code/Magento/CatalogRuleConfigurable/Plugin/ConfigurableProduct/Model/ResourceModel/AddCatalogRulePrice.php b/app/code/Magento/CatalogRuleConfigurable/Plugin/ConfigurableProduct/Model/ResourceModel/AddCatalogRulePrice.php
index 5335043966f..788e43bd873 100644
--- a/app/code/Magento/CatalogRuleConfigurable/Plugin/ConfigurableProduct/Model/ResourceModel/AddCatalogRulePrice.php
+++ b/app/code/Magento/CatalogRuleConfigurable/Plugin/ConfigurableProduct/Model/ResourceModel/AddCatalogRulePrice.php
@@ -8,54 +8,21 @@
 namespace Magento\CatalogRuleConfigurable\Plugin\ConfigurableProduct\Model\ResourceModel;
 
 use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable\Product\Collection;
-use Magento\CatalogRule\Pricing\Price\CatalogRulePrice;
 
 class AddCatalogRulePrice
 {
     /**
-     * @var \Magento\Store\Model\StoreManagerInterface
+     * @var \Magento\CatalogRule\Model\ResourceModel\Product\CollectionFactory
      */
-    private $storeManager;
+    private $catalogRuleCollectionFactory;
 
     /**
-     * @var \Magento\Framework\App\ResourceConnection
-     */
-    private $resource;
-
-    /**
-     * @var \Magento\Customer\Model\Session
-     */
-    private $customerSession;
-
-    /**
-     * @var \Magento\Framework\Stdlib\DateTime
-     */
-    private $dateTime;
-
-    /**
-     * @var \Magento\Framework\Stdlib\DateTime\TimezoneInterface
-     */
-    private $localeDate;
-
-    /**
-     * @param \Magento\Store\Model\StoreManagerInterface $storeManager
-     * @param \Magento\Framework\App\ResourceConnection $resourceConnection
-     * @param \Magento\Customer\Model\Session $customerSession
-     * @param \Magento\Framework\Stdlib\DateTime $dateTime
-     * @param \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate
+     * @param \Magento\CatalogRule\Model\ResourceModel\Product\CollectionFactory $catalogRuleCollectionFactory
      */
     public function __construct(
-        \Magento\Store\Model\StoreManagerInterface $storeManager,
-        \Magento\Framework\App\ResourceConnection $resourceConnection,
-        \Magento\Customer\Model\Session $customerSession,
-        \Magento\Framework\Stdlib\DateTime $dateTime,
-        \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate
+        \Magento\CatalogRule\Model\ResourceModel\Product\CollectionFactory $catalogRuleCollectionFactory
     ) {
-        $this->storeManager = $storeManager;
-        $this->resource = $resourceConnection;
-        $this->customerSession = $customerSession;
-        $this->dateTime = $dateTime;
-        $this->localeDate = $localeDate;
+        $this->catalogRuleCollectionFactory = $catalogRuleCollectionFactory;
     }
 
     /**
@@ -66,28 +33,9 @@ class AddCatalogRulePrice
      */
     public function beforeLoad(Collection $productCollection, $printQuery = false, $logQuery = false)
     {
-        if (!$productCollection->hasFlag('catalog_rule_loaded')) {
-            $connection = $this->resource->getConnection();
-            $store = $this->storeManager->getStore();
-            $productCollection->getSelect()
-                ->joinLeft(
-                    ['catalog_rule' => $this->resource->getTableName('catalogrule_product_price')],
-                    implode(' AND ', [
-                        'catalog_rule.product_id = e.entity_id',
-                        $connection->quoteInto('catalog_rule.website_id = ?', $store->getWebsiteId()),
-                        $connection->quoteInto(
-                            'catalog_rule.customer_group_id = ?',
-                            $this->customerSession->getCustomerGroupId()
-                        ),
-                        $connection->quoteInto(
-                            'catalog_rule.rule_date = ?',
-                            $this->dateTime->formatDate($this->localeDate->scopeDate($store->getId()), false)
-                        ),
-                    ]),
-                    [CatalogRulePrice::PRICE_CODE => 'rule_price']
-                );
-            $productCollection->setFlag('catalog_rule_loaded', true);
-        }
+        $this->catalogRuleCollectionFactory
+            ->create()
+            ->addPriceData($productCollection);
 
         return [$printQuery, $logQuery];
     }
diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductTest.php
index 3c27a65c4a7..78f792fe78f 100644
--- a/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductTest.php
+++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/ProductTest.php
@@ -290,6 +290,18 @@ class ProductTest extends \PHPUnit_Framework_TestCase
         $this->assertTrue((bool)$this->_model->isSaleable());
         $this->assertTrue((bool)$this->_model->isAvailable());
         $this->assertTrue($this->_model->isInStock());
+    }
+
+    /**
+     * @covers \Magento\Catalog\Model\Product::isSalable
+     * @covers \Magento\Catalog\Model\Product::isSaleable
+     * @covers \Magento\Catalog\Model\Product::isAvailable
+     * @covers \Magento\Catalog\Model\Product::isInStock
+     */
+    public function testIsNotSalableWhenStatusDisabled()
+    {
+        $this->_model = $this->productRepository->get('simple');
+
         $this->_model->setStatus(0);
         $this->assertFalse((bool)$this->_model->isSalable());
         $this->assertFalse((bool)$this->_model->isSaleable());
diff --git a/lib/internal/Magento/Framework/Model/AbstractExtensibleModel.php b/lib/internal/Magento/Framework/Model/AbstractExtensibleModel.php
index 947c526c919..34815c996d1 100644
--- a/lib/internal/Magento/Framework/Model/AbstractExtensibleModel.php
+++ b/lib/internal/Magento/Framework/Model/AbstractExtensibleModel.php
@@ -254,12 +254,18 @@ abstract class AbstractExtensibleModel extends AbstractModel implements
             $data = parent::getData($key, $index);
             if ($data === null) {
                 /** Try to find necessary data in custom attributes */
-                $data = parent::getData(self::CUSTOM_ATTRIBUTES . "/{$key}", $index);
+                $data = isset($this->_data[self::CUSTOM_ATTRIBUTES][$key])
+                    ? $this->_data[self::CUSTOM_ATTRIBUTES][$key]
+                    : null;
                 if ($data instanceof \Magento\Framework\Api\AttributeValue) {
                     $data = $data->getValue();
                 }
+                if (null !== $index && isset($data[$index])) {
+                    return $data[$index];
+                }
             }
         }
+
         return $data;
     }
 
diff --git a/setup/src/Magento/Setup/Fixtures/BundleProductsFixture.php b/setup/src/Magento/Setup/Fixtures/BundleProductsFixture.php
new file mode 100644
index 00000000000..aa9caf8102e
--- /dev/null
+++ b/setup/src/Magento/Setup/Fixtures/BundleProductsFixture.php
@@ -0,0 +1,427 @@
+<?php
+/**
+ * Copyright © 2016 Magento. All rights reserved.
+ * See COPYING.txt for license details.
+ */
+
+namespace Magento\Setup\Fixtures;
+
+use Magento\Setup\Model\Complex\Generator;
+use Magento\Setup\Model\Complex\Pattern;
+
+/**
+ * Class BundleProductsFixture
+ */
+class BundleProductsFixture extends Fixture
+{
+    /**
+     * @var int
+     */
+    protected $priority = 42;
+
+    //@codingStandardsIgnoreStart
+    /**
+     * Get CSV template headers
+     * @SuppressWarnings(PHPMD)
+     * @return array
+     */
+    protected function getHeaders()
+    {
+        return [
+            'sku',
+            'store_view_code',
+            'attribute_set_code',
+            'product_type',
+            'categories',
+            'product_websites',
+            'color',
+            'bundle_variation',
+            'cost',
+            'country_of_manufacture',
+            'created_at',
+            'custom_design',
+            'custom_design_from',
+            'custom_design_to',
+            'custom_layout_update',
+            'description',
+            'enable_googlecheckout',
+            'gallery',
+            'gift_message_available',
+            'gift_wrapping_available',
+            'gift_wrapping_price',
+            'has_options',
+            'image',
+            'image_label',
+            'is_returnable',
+            'manufacturer',
+            'meta_description',
+            'meta_keyword',
+            'meta_title',
+            'minimal_price',
+            'msrp',
+            'msrp_display_actual_price_type',
+            'name',
+            'news_from_date',
+            'news_to_date',
+            'options_container',
+            'page_layout',
+            'price',
+            'quantity_and_stock_status',
+            'related_tgtr_position_behavior',
+            'related_tgtr_position_limit',
+            'required_options',
+            'short_description',
+            'small_image',
+            'small_image_label',
+            'special_from_date',
+            'special_price',
+            'special_to_date',
+            'product_online',
+            'tax_class_name',
+            'thumbnail',
+            'thumbnail_label',
+            'updated_at',
+            'upsell_tgtr_position_behavior',
+            'upsell_tgtr_position_limit',
+            'url_key',
+            'url_path',
+            'variations',
+            'visibility',
+            'weight',
+            'qty',
+            'min_qty',
+            'use_config_min_qty',
+            'is_qty_decimal',
+            'backorders',
+            'use_config_backorders',
+            'min_sale_qty',
+            'use_config_min_sale_qty',
+            'max_sale_qty',
+            'use_config_max_sale_qty',
+            'is_in_stock',
+            'notify_stock_qty',
+            'use_config_notify_stock_qty',
+            'manage_stock',
+            'use_config_manage_stock',
+            'use_config_qty_increments',
+            'qty_increments',
+            'use_config_enable_qty_inc',
+            'enable_qty_increments',
+            'is_decimal_divided',
+            'bundle_values',
+        ];
+    }
+
+    private function generateBundleProduct($productCategory, $productWebsite, $variation, $suffix)
+    {
+        return [
+            'sku' => 'Bundle Product %s' . $suffix,
+            'store_view_code' => '',
+            'attribute_set_code' => 'Default',
+            'product_type' => 'bundle',
+            'categories' => $productCategory,
+            'product_websites' => $productWebsite,
+            'color' => '',
+            'bundle_variation' => '',
+            'cost' => '',
+            'country_of_manufacture' => '',
+            'created_at' => '2013-10-25 15:12:39',
+            'custom_design' => '',
+            'custom_design_from' => '',
+            'custom_design_to' => '',
+            'custom_layout_update' => '',
+            'description' => '<p>Bundle product description %s</p>',
+            'enable_googlecheckout' => '1',
+            'gallery' => '',
+            'gift_message_available' => '',
+            'gift_wrapping_available' => '',
+            'gift_wrapping_price' => '',
+            'has_options' => '1',
+            'image' => '',
+            'image_label' => '',
+            'is_returnable' => 'Use config',
+            'manufacturer' => '',
+            'meta_description' => 'Bundle Product %s <p>Bundle product description %s</p>',
+            'meta_keyword' => 'Bundle Product %s',
+            'meta_title' => 'Bundle Product %s',
+            'minimal_price' => '',
+            'msrp' => '',
+            'msrp_display_actual_price_type' => 'Use config',
+            'name' => 'Bundle Product %s' . $suffix,
+            'news_from_date' => '',
+            'news_to_date' => '',
+            'options_container' => 'Block after Info Column',
+            'page_layout' => '',
+            'price' => '10',
+            'quantity_and_stock_status' => 'In Stock',
+            'related_tgtr_position_behavior' => '',
+            'related_tgtr_position_limit' => '',
+            'required_options' => '1',
+            'short_description' => '',
+            'small_image' => '',
+            'small_image_label' => '',
+            'special_from_date' => '',
+            'special_price' => '',
+            'special_to_date' => '',
+            'product_online' => '1',
+            'tax_class_name' => 'Taxable Goods',
+            'thumbnail' => '',
+            'thumbnail_label' => '',
+            'updated_at' => '2013-10-25 15:12:39',
+            'upsell_tgtr_position_behavior' => '',
+            'upsell_tgtr_position_limit' => '',
+            'url_key' => "bundle-product-%s{$suffix}",
+            'url_path' => "bundle-product-%s{$suffix}",
+            'visibility' => 'Catalog, Search',
+            'weight' => '',
+            'qty' => 333,
+            'min_qty' => '0.0000',
+            'use_config_min_qty' => '1',
+            'is_qty_decimal' => '0',
+            'backorders' => '0',
+            'use_config_backorders' => '1',
+            'min_sale_qty' => '1.0000',
+            'use_config_min_sale_qty' => '1',
+            'max_sale_qty' => '0.0000',
+            'use_config_max_sale_qty' => '1',
+            'is_in_stock' => '1',
+            'notify_stock_qty' => '',
+            'use_config_notify_stock_qty' => '1',
+            'manage_stock' => '1',
+            'use_config_manage_stock' => '1',
+            'use_config_qty_increments' => '1',
+            'qty_increments' => '0.0000',
+            'use_config_enable_qty_inc' => '1',
+            'enable_qty_increments' => '0',
+            'is_decimal_divided' => '0',
+            'bundle_price_type' => 'dynamic',
+            'bundle_sku_type' => 'dynamic',
+            'bundle_price_view' => 'Price range',
+            'bundle_weight_type' => 'dynamic',
+            'bundle_values'     => $variation,
+            'bundle_shipment_type' => 'separately',
+        ];
+    }
+
+    /**
+     * Get CSV template rows
+     *
+     * @param Closure|mixed $productCategory
+     * @param Closure|mixed $productWebsite
+     *
+     * @SuppressWarnings(PHPMD)
+     *
+     * @return array
+     */
+    protected function getRows($productCategory, $productWebsite, $optionsNumber, $suffix = '')
+    {
+        $data = [];
+        $variation = [];
+        for ($i = 1; $i <= $optionsNumber; $i++) {
+            $productData = [
+                'sku' => "Bundle Product %s-option {$i}{$suffix}",
+                'store_view_code' => '',
+                'attribute_set_code' => 'Default',
+                'product_type' => 'simple',
+                'categories' => $productCategory,
+                'product_websites' => $productWebsite,
+                'cost' => '',
+                'country_of_manufacture' => '',
+                'created_at' => '2013-10-25 15:12:32',
+                'custom_design' => '',
+                'custom_design_from' => '',
+                'custom_design_to' => '',
+                'custom_layout_update' => '',
+                'description' => '<p>Bundle product option description %s</p>',
+                'enable_googlecheckout' => '1',
+                'gallery' => '',
+                'gift_message_available' => '',
+                'gift_wrapping_available' => '',
+                'gift_wrapping_price' => '',
+                'has_options' => '0',
+                'image' => '',
+                'image_label' => '',
+                'is_returnable' => 'Use config',
+                'manufacturer' => '',
+                'meta_description' => 'Bundle Product Option %s <p>Bundle product description 1</p>',
+                'meta_keyword' => 'Bundle Product 1',
+                'meta_title' => 'Bundle Product %s',
+                'minimal_price' => '',
+                'msrp' => '',
+                'msrp_display_actual_price_type' => 'Use config',
+                'name' => "Bundle Product {$suffix} -  %s-option {$i}",
+                'news_from_date' => '',
+                'news_to_date' => '',
+                'options_container' => 'Block after Info Column',
+                'page_layout' => '',
+                'price' => function () { return mt_rand(1, 1000) / 10; },
+                'quantity_and_stock_status' => 'In Stock',
+                'related_tgtr_position_behavior' => '',
+                'related_tgtr_position_limit' => '',
+                'required_options' => '0',
+                'short_description' => '',
+                'small_image' => '',
+                'small_image_label' => '',
+                'special_from_date' => '',
+                'special_price' => '',
+                'special_to_date' => '',
+                'product_online' => '1',
+                'tax_class_name' => 'Taxable Goods',
+                'thumbnail' => '',
+                'thumbnail_label' => '',
+                'updated_at' => '2013-10-25 15:12:32',
+                'upsell_tgtr_position_behavior' => '',
+                'upsell_tgtr_position_limit' => '',
+                'url_key' => "simple-of-bundle-product-{$suffix}-%s-option-{$i}",
+                'url_path' => "simple-of-bundle-product-{$suffix}-%s-option-{$i}",
+                'visibility' => 'Not Visible Individually',
+                'weight' => '1',
+                'qty' => '111.0000',
+                'min_qty' => '0.0000',
+                'use_config_min_qty' => '1',
+                'use_config_backorders' => '1',
+                'use_config_min_sale_qty' => '1',
+                'use_config_max_sale_qty' => '1',
+                'is_in_stock' => '1',
+                'use_config_notify_stock_qty' => '1',
+                'use_config_manage_stock' => '1',
+                'use_config_qty_increments' => '1',
+                'use_config_enable_qty_inc' => '1',
+                'enable_qty_increments' => '0',
+                'is_decimal_divided' => '0',
+            ];
+            $variation[] = implode(
+                ',',
+                [
+                    'name=Bundle Option 1',
+                    'type=select',
+                    'required=1',
+                    'sku=' . $productData['sku'],
+                    'price=' . mt_rand(1, 1000) / 10,
+                    'default=0',
+                    'default_qty=1',
+                ]
+            );
+            $data[] = $productData;
+        }
+
+        $data[] = $this->generateBundleProduct($productCategory, $productWebsite, implode('|', $variation), $suffix);
+        return $data;
+    }
+
+    /**
+     * {@inheritdoc}
+     * @SuppressWarnings(PHPMD)
+     */
+    public function execute()
+    {
+        $bundlesCount = $this->fixtureModel->getValue('bundle_products', 0);
+        if (!$bundlesCount) {
+            return;
+        }
+        $this->fixtureModel->resetObjectManager();
+
+        /** @var \Magento\Store\Model\StoreManager $storeManager */
+        $storeManager = $this->fixtureModel->getObjectManager()->create('Magento\Store\Model\StoreManager');
+        /** @var $category \Magento\Catalog\Model\Category */
+        $category = $this->fixtureModel->getObjectManager()->get('Magento\Catalog\Model\Category');
+
+        $result = [];
+        //Get all websites
+        $websites = $storeManager->getWebsites();
+        foreach ($websites as $website) {
+            $websiteCode = $website->getCode();
+            //Get all groups
+            $websiteGroups = $website->getGroups();
+            foreach ($websiteGroups as $websiteGroup) {
+                $websiteGroupRootCategory = $websiteGroup->getRootCategoryId();
+                $category->load($websiteGroupRootCategory);
+                $categoryResource = $category->getResource();
+                $rootCategoryName = $category->getName();
+                //Get all categories
+                $resultsCategories = $categoryResource->getAllChildren($category);
+                foreach ($resultsCategories as $resultsCategory) {
+                    $category->load($resultsCategory);
+                    $structure = explode('/', $category->getPath());
+                    $pathSize  = count($structure);
+                    if ($pathSize > 1) {
+                        $path = [];
+                        for ($i = 1; $i < $pathSize; $i++) {
+                            $path[] = $category->load($structure[$i])->getName();
+                        }
+                        array_shift($path);
+                        $resultsCategoryName = implode('/', $path);
+                    } else {
+                        $resultsCategoryName = $category->getName();
+                    }
+                    //Deleted root categories
+                    if (trim($resultsCategoryName) != '') {
+                        $result[$resultsCategory] = [$websiteCode, $resultsCategoryName, $rootCategoryName];
+                    }
+                }
+            }
+        }
+        $result = array_values($result);
+
+        $productWebsite = function ($index) use ($result) {
+            return $result[$index % count($result)][0];
+        };
+        $productCategory = function ($index) use ($result) {
+            return $result[$index % count($result)][2] . '/' . $result[$index % count($result)][1];
+        };
+
+        /**
+         * Create bundle products
+         */
+        $pattern = new Pattern();
+        $pattern->setHeaders($this->getHeaders());
+        $pattern->setRowsSet(
+            $this->getRows(
+                $productCategory,
+                $productWebsite,
+                $this->fixtureModel->getValue('bundle_products_variation', 5000)
+            )
+        );
+
+        /** @var \Magento\ImportExport\Model\Import $import */
+        $import = $this->fixtureModel->getObjectManager()->create(
+            'Magento\ImportExport\Model\Import',
+            [
+                'data' => [
+                    'entity' => 'catalog_product',
+                    'behavior' => 'append',
+                    'validation_strategy' => 'validation-stop-on-errors',
+                ],
+            ]
+        );
+
+        $source = new Generator($pattern, $bundlesCount);
+        // it is not obvious, but the validateSource() will actually save import queue data to DB
+        if (!$import->validateSource($source)) {
+            throw new \Exception($import->getFormatedLogTrace());
+        }
+        // this converts import queue into actual entities
+        if (!$import->importSource()) {
+            throw new \Exception($import->getFormatedLogTrace());
+        }
+    }
+    // @codingStandardsIgnoreEnd
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getActionTitle()
+    {
+        return 'Generating bundle products';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function introduceParamLabels()
+    {
+        return [
+            'bundle_products' => 'Bundle products',
+        ];
+    }
+}
-- 
GitLab