diff --git a/app/code/Magento/Catalog/Cron/DeleteOutdatedPriceValuesTest.php b/app/code/Magento/Catalog/Cron/DeleteOutdatedPriceValuesTest.php
new file mode 100644
index 0000000000000..b9c2b308046dd
--- /dev/null
+++ b/app/code/Magento/Catalog/Cron/DeleteOutdatedPriceValuesTest.php
@@ -0,0 +1,131 @@
+objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager();
+ $this->productRepository = $this->objectManager->create(ProductRepositoryInterface::class);
+ $this->store = $this->objectManager->create(\Magento\Store\Model\Store::class);
+ $this->cron = $this->objectManager->create(\Magento\Catalog\Cron\DeleteOutdatedPriceValues::class);
+ }
+
+ /**
+ * @magentoDataFixture Magento/Catalog/_files/product_simple.php
+ * @magentoDataFixture Magento/Store/_files/second_website_with_two_stores.php
+ * @magentoConfigFixture current_store catalog/price/scope 1
+ * @magentoDbIsolation disabled
+ * @magentoAppIsolation enabled
+ */
+ public function testExecute()
+ {
+ $defaultStorePrice = 10.00;
+ $secondStorePrice = 9.99;
+ $secondStoreId = $this->store->load('fixture_second_store')->getId();
+ /** @var \Magento\Catalog\Model\Product\Action $productAction */
+ $productAction = $this->objectManager->create(
+ \Magento\Catalog\Model\Product\Action::class
+ );
+ /** @var ReinitableConfigInterface $reinitiableConfig */
+ $reinitiableConfig = $this->objectManager->get(ReinitableConfigInterface::class);
+ $reinitiableConfig->setValue(
+ 'catalog/price/scope',
+ \Magento\Store\Model\Store::PRICE_SCOPE_WEBSITE
+ );
+
+ $reflection = new \ReflectionClass(\Magento\Catalog\Model\Attribute\ScopeOverriddenValue::class);
+ $paths = $reflection->getProperty('attributesValues');
+ $paths->setAccessible(true);
+ $paths->setValue($this->objectManager->get(\Magento\Catalog\Model\Attribute\ScopeOverriddenValue::class), null);
+ $paths->setAccessible(false);
+ $product = $this->productRepository->get('simple');
+ $productResource = $this->objectManager->create(\Magento\Catalog\Model\ResourceModel\Product::class);
+ $productId = $product->getId();
+ $productAction->updateWebsites(
+ [$productId],
+ [$this->store->load('fixture_second_store')->getWebsiteId()],
+ 'add'
+ );
+ $product->setStoreId($secondStoreId);
+ $product->setPrice($secondStorePrice);
+ $productResource->save($product);
+ $attribute = $this->objectManager->get(\Magento\Eav\Model\Config::class)
+ ->getAttribute(
+ 'catalog_product',
+ 'price'
+ );
+ $this->assertEquals(
+ $secondStorePrice,
+ $productResource->getAttributeRawValue($productId, $attribute->getId(), $secondStoreId)
+ );
+ /** @var MutableScopeConfigInterface $config */
+ $config = $this->objectManager->get(
+ MutableScopeConfigInterface::class
+ );
+ $config->setValue(
+ \Magento\Store\Model\Store::XML_PATH_PRICE_SCOPE,
+ null,
+ ScopeConfigInterface::SCOPE_TYPE_DEFAULT
+ );
+ $this->cron->execute();
+ $this->assertEquals(
+ $secondStorePrice,
+ $productResource->getAttributeRawValue($productId, $attribute->getId(), $secondStoreId)
+ );
+ $config->setValue(
+ \Magento\Store\Model\Store::XML_PATH_PRICE_SCOPE,
+ \Magento\Store\Model\Store::PRICE_SCOPE_GLOBAL,
+ ScopeConfigInterface::SCOPE_TYPE_DEFAULT
+ );
+ /** @var \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig */
+ $this->cron->execute();
+ $this->assertEquals(
+ $defaultStorePrice,
+ $productResource->getAttributeRawValue($productId, $attribute->getId(), $secondStoreId)
+ );
+ }
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ /** @var ReinitableConfigInterface $reinitiableConfig */
+ $reinitiableConfig = $this->objectManager->get(ReinitableConfigInterface::class);
+ $reinitiableConfig->setValue(
+ 'catalog/price/scope',
+ \Magento\Store\Model\Store::PRICE_SCOPE_GLOBAL
+ );
+ }
+}
diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Price/System/Config/PriceScope.php b/app/code/Magento/Catalog/Model/Indexer/Product/Price/System/Config/PriceScope.php
index bd6f7923441c8..ef84d2d3f7dd9 100644
--- a/app/code/Magento/Catalog/Model/Indexer/Product/Price/System/Config/PriceScope.php
+++ b/app/code/Magento/Catalog/Model/Indexer/Product/Price/System/Config/PriceScope.php
@@ -5,6 +5,8 @@
*/
namespace Magento\Catalog\Model\Indexer\Product\Price\System\Config;
+use Magento\Catalog\Model\Config\PriceScopeChange;
+
/**
* Price scope backend model
*/
@@ -15,15 +17,21 @@ class PriceScope extends \Magento\Framework\App\Config\Value
*/
protected $indexerRegistry;
+ /**
+ * @var PriceScopeChange
+ */
+ private $priceScopeChange;
+
/**
* @param \Magento\Framework\Model\Context $context
* @param \Magento\Framework\Registry $registry
* @param \Magento\Framework\App\Config\ScopeConfigInterface $config
* @param \Magento\Framework\App\Cache\TypeListInterface $cacheTypeList
* @param \Magento\Framework\Indexer\IndexerRegistry $indexerRegistry
- * @param \Magento\Framework\Model\ResourceModel\AbstractResource $resource
- * @param \Magento\Framework\Data\Collection\AbstractDb $resourceCollection
+ * @param \Magento\Framework\Model\ResourceModel\AbstractResource|null $resource
+ * @param \Magento\Framework\Data\Collection\AbstractDb|null $resourceCollection
* @param array $data
+ * @param PriceScopeChange|null $priceScopeChange
*/
public function __construct(
\Magento\Framework\Model\Context $context,
@@ -33,10 +41,13 @@ public function __construct(
\Magento\Framework\Indexer\IndexerRegistry $indexerRegistry,
?\Magento\Framework\Model\ResourceModel\AbstractResource $resource = null,
?\Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null,
- array $data = []
+ array $data = [],
+ PriceScopeChange $priceScopeChange = null
) {
parent::__construct($context, $registry, $config, $cacheTypeList, $resource, $resourceCollection, $data);
$this->indexerRegistry = $indexerRegistry;
+ $this->priceScopeChange = $priceScopeChange ?:
+ \Magento\Framework\App\ObjectManager::getInstance()->get(PriceScopeChange::class);
}
/**
@@ -61,5 +72,6 @@ public function processValue()
$this->indexerRegistry->get(\Magento\Catalog\Model\Indexer\Product\Price\Processor::INDEXER_ID)
->invalidate();
}
+ $this->priceScopeChange->changeScope((int)$this->getValue());
}
}
diff --git a/app/code/Magento/Catalog/Model/Product/Attribute/Backend/PriceTest.php b/app/code/Magento/Catalog/Model/Product/Attribute/Backend/PriceTest.php
new file mode 100644
index 0000000000000..85b4f678f4a8e
--- /dev/null
+++ b/app/code/Magento/Catalog/Model/Product/Attribute/Backend/PriceTest.php
@@ -0,0 +1,324 @@
+objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager();
+ /** @var ReinitableConfigInterface $reinitiableConfig */
+ $reinitiableConfig = $this->objectManager->get(ReinitableConfigInterface::class);
+ $reinitiableConfig->setValue(
+ 'catalog/price/scope',
+ \Magento\Store\Model\Store::PRICE_SCOPE_WEBSITE
+ );
+
+ $this->model = $this->objectManager->create(
+ \Magento\Catalog\Model\Product\Attribute\Backend\Price::class
+ );
+ $this->productRepository = $this->objectManager->create(
+ ProductRepositoryInterface::class
+ );
+ $this->productResource = $this->objectManager->create(
+ Product::class
+ );
+ $this->fixtures = $this->objectManager->get(DataFixtureStorageManager::class)->getStorage();
+ $this->model->setAttribute(
+ $this->objectManager->get(
+ \Magento\Eav\Model\Config::class
+ )->getAttribute(
+ 'catalog_product',
+ 'price'
+ )
+ );
+ }
+
+ /**
+ * @magentoDbIsolation disabled
+ */
+ public function testSetScopeDefault()
+ {
+ /* validate result of setAttribute */
+ $this->assertEquals(
+ \Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface::SCOPE_GLOBAL,
+ $this->model->getAttribute()->getIsGlobal()
+ );
+ $this->model->setScope($this->model->getAttribute());
+ $this->assertEquals(
+ \Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface::SCOPE_GLOBAL,
+ $this->model->getAttribute()->getIsGlobal()
+ );
+ }
+
+ /**
+ * @magentoDbIsolation disabled
+ * @magentoConfigFixture current_store catalog/price/scope 1
+ */
+ public function testSetScope()
+ {
+ $this->model->setScope($this->model->getAttribute());
+ $this->assertEquals(
+ \Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface::SCOPE_WEBSITE,
+ $this->model->getAttribute()->getIsGlobal()
+ );
+ }
+
+ /**
+ * @magentoDbIsolation disabled
+ * @magentoDataFixture Magento/Catalog/_files/product_simple.php
+ * @magentoConfigFixture current_store catalog/price/scope 1
+ * @magentoConfigFixture current_store currency/options/base GBP
+ */
+ public function testAfterSave()
+ {
+ /** @var \Magento\Store\Model\Store $store */
+ $store = $this->objectManager->create(\Magento\Store\Model\Store::class);
+ $globalStoreId = $store->load('admin')->getId();
+ $product = $this->productRepository->get('simple');
+ $product->setPrice('9.99');
+ $product->setStoreId($globalStoreId);
+ $this->productResource->save($product);
+ $product = $this->productRepository->get('simple', false, $globalStoreId, true);
+ $this->assertEquals('9.990000', $product->getPrice());
+ }
+
+ /**
+ * @magentoDataFixture Magento\Store\Test\Fixture\Website as:website2
+ * @magentoDataFixture Magento\Store\Test\Fixture\Group with:{"website_id":"$website2.id$"} as:store_group2
+ * @magentoDataFixture Magento\Store\Test\Fixture\Store with:{"store_group_id":"$store_group2.id$"} as:store2
+ * @magentoDataFixture Magento\Store\Test\Fixture\Store with:{"store_group_id":"$store_group2.id$"} as:store3
+ * @magentoDataFixture Magento\Catalog\Test\Fixture\Product as:product
+ * @magentoConfigFixture current_store catalog/price/scope 1
+ * @magentoDbIsolation disabled
+ * @magentoAppArea adminhtml
+ */
+ public function testAfterSaveWithDifferentStores()
+ {
+ /** @var \Magento\Store\Model\Store $store */
+ $store = $this->objectManager->create(
+ \Magento\Store\Model\Store::class
+ );
+ $globalStoreId = $store->load('admin')->getId();
+ $secondStoreId = $this->fixtures->get('store2')->getId();
+ $thirdStoreId = $this->fixtures->get('store3')->getId();
+ $productSku = $this->fixtures->get('product')->getSku();
+ /** @var \Magento\Catalog\Model\Product\Action $productAction */
+ $productAction = $this->objectManager->create(
+ \Magento\Catalog\Model\Product\Action::class
+ );
+
+ $product = $this->productRepository->get($productSku);
+ $productId = $product->getId();
+ $productAction->updateWebsites([$productId], [$store->load('fixture_second_store')->getWebsiteId()], 'add');
+ $product->setStoreId($secondStoreId);
+ $product->setPrice('9.99');
+ $this->productResource->save($product);
+
+ $product = $this->productRepository->get($productSku, false, $globalStoreId, true);
+ $this->assertEquals(10, $product->getPrice());
+
+ $product = $this->productRepository->get($productSku, false, $secondStoreId, true);
+ $this->assertEquals('9.990000', $product->getPrice());
+
+ $product = $this->productRepository->get($productSku, false, $thirdStoreId, true);
+ $this->assertEquals('9.990000', $product->getPrice());
+ }
+
+ /**
+ * @magentoDataFixture Magento/Catalog/_files/product_simple.php
+ * @magentoDataFixture Magento/Store/_files/second_website_with_two_stores.php
+ * @magentoConfigFixture current_store catalog/price/scope 1
+ * @magentoDbIsolation disabled
+ * @magentoAppArea adminhtml
+ */
+ public function testAfterSaveWithSameCurrency()
+ {
+ /** @var \Magento\Store\Model\Store $store */
+ $store = $this->objectManager->create(
+ \Magento\Store\Model\Store::class
+ );
+ $globalStoreId = $store->load('admin')->getId();
+ $secondStoreId = $store->load('fixture_second_store')->getId();
+ $thirdStoreId = $store->load('fixture_third_store')->getId();
+ /** @var \Magento\Catalog\Model\Product\Action $productAction */
+ $productAction = $this->objectManager->create(
+ \Magento\Catalog\Model\Product\Action::class
+ );
+
+ $product = $this->productRepository->get('simple');
+ $productId = $product->getId();
+ $productAction->updateWebsites([$productId], [$store->load('fixture_second_store')->getWebsiteId()], 'add');
+ $product->setOrigData();
+ $product->setStoreId($secondStoreId);
+ $product->setPrice('9.99');
+ $this->productResource->save($product);
+
+ $product = $this->productRepository->get('simple', false, $globalStoreId, true);
+ $this->assertEquals(10, $product->getPrice());
+
+ $product = $this->productRepository->get('simple', false, $secondStoreId, true);
+ $this->assertEquals('9.990000', $product->getPrice());
+
+ $product = $this->productRepository->get('simple', false, $thirdStoreId, true);
+ $this->assertEquals('9.990000', $product->getPrice());
+ }
+
+ /**
+ * @magentoDbIsolation disabled
+ * @magentoAppArea adminhtml
+ * @magentoDataFixture Magento/Catalog/_files/product_simple.php
+ * @magentoDataFixture Magento/Store/_files/second_website_with_two_stores.php
+ * @magentoConfigFixture current_store catalog/price/scope 1
+ */
+ public function testAfterSaveWithUseDefault()
+ {
+ /** @var \Magento\Store\Model\Store $store */
+ $store = $this->objectManager->create(
+ \Magento\Store\Model\Store::class
+ );
+ $globalStoreId = $store->load('admin')->getId();
+ $secondStoreId = $store->load('fixture_second_store')->getId();
+ $thirdStoreId = $store->load('fixture_third_store')->getId();
+ /** @var \Magento\Catalog\Model\Product\Action $productAction */
+ $productAction = $this->objectManager->create(
+ \Magento\Catalog\Model\Product\Action::class
+ );
+
+ $product = $this->productRepository->get('simple');
+ $productId = $product->getId();
+ $productAction->updateWebsites([$productId], [$store->load('fixture_second_store')->getWebsiteId()], 'add');
+ $product->setOrigData();
+ $product->setStoreId($secondStoreId);
+ $product->setPrice('9.99');
+ $this->productResource->save($product);
+
+ $product = $this->productRepository->get('simple', false, $globalStoreId, true);
+ $this->assertEquals(10, $product->getPrice());
+
+ $product = $this->productRepository->get('simple', false, $secondStoreId, true);
+ $this->assertEquals('9.990000', $product->getPrice());
+
+ $product = $this->productRepository->get('simple', false, $thirdStoreId, true);
+ $this->assertEquals('9.990000', $product->getPrice());
+
+ $product->setStoreId($thirdStoreId);
+ $product->setPrice(null);
+ $this->productResource->save($product);
+
+ $product = $this->productRepository->get('simple', false, $globalStoreId, true);
+ $this->assertEquals(10, $product->getPrice());
+
+ $product = $this->productRepository->get('simple', false, $secondStoreId, true);
+ $this->assertEquals(10, $product->getPrice());
+
+ $product = $this->productRepository->get('simple', false, $thirdStoreId, true);
+ $this->assertEquals(10, $product->getPrice());
+ }
+
+ /**
+ * @magentoDbIsolation disabled
+ * @magentoAppArea adminhtml
+ * @magentoDataFixture Magento/Catalog/_files/product_simple.php
+ * @magentoDataFixture Magento/Store/_files/second_website_with_two_stores.php
+ * @magentoConfigFixture default_store catalog/price/scope 1
+ */
+ public function testAfterSaveForWebsitesWithDifferentCurrencies()
+ {
+ /** @var \Magento\Store\Model\Store $store */
+ $store = $this->objectManager->create(
+ \Magento\Store\Model\Store::class
+ );
+
+ /** @var \Magento\Directory\Model\ResourceModel\Currency $rate */
+ $rate = $this->objectManager->create(\Magento\Directory\Model\ResourceModel\Currency::class);
+ $rate->saveRates([
+ 'USD' => ['EUR' => 2],
+ 'EUR' => ['USD' => 0.5]
+ ]);
+
+ $globalStoreId = $store->load('admin')->getId();
+ $secondStore = $store->load('fixture_second_store');
+ $secondStoreId = $store->load('fixture_second_store')->getId();
+ $thirdStoreId = $store->load('fixture_third_store')->getId();
+
+ /** @var \Magento\Framework\App\Config\ReinitableConfigInterface $config */
+ $config = $this->objectManager->get(\Magento\Framework\App\Config\MutableScopeConfigInterface::class);
+ $config->setValue(
+ 'currency/options/default',
+ 'EUR',
+ \Magento\Store\Model\ScopeInterface::SCOPE_WEBSITES,
+ 'test'
+ );
+
+ $productAction = $this->objectManager->create(
+ \Magento\Catalog\Model\Product\Action::class
+ );
+ $product = $this->productRepository->get('simple');
+ $productId = $product->getId();
+ $productAction->updateWebsites([$productId], [$secondStore->getWebsiteId()], 'add');
+ $product->setOrigData();
+ $product->setStoreId($globalStoreId);
+ $product->setPrice(100);
+ $this->productResource->save($product);
+
+ $product = $this->productRepository->get('simple', false, $globalStoreId, true);
+ $this->assertEquals(100, $product->getPrice());
+
+ $product = $this->productRepository->get('simple', false, $secondStoreId, true);
+ $this->assertEquals(100, $product->getPrice());
+
+ $product = $this->productRepository->get('simple', false, $thirdStoreId, true);
+ $this->assertEquals(100, $product->getPrice());
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ parent::tearDownAfterClass();
+ /** @var ReinitableConfigInterface $reinitiableConfig */
+ $reinitiableConfig = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(
+ ReinitableConfigInterface::class
+ );
+ $reinitiableConfig->setValue(
+ 'catalog/price/scope',
+ \Magento\Store\Model\Store::PRICE_SCOPE_GLOBAL
+ );
+ }
+}
diff --git a/app/code/Magento/Catalog/Observer/SwitchPriceAttributeScopeOnConfigChange.php b/app/code/Magento/Catalog/Observer/SwitchPriceAttributeScopeOnConfigChange.php
index 7d7eb54ac1f7b..9e49bfe67dd7a 100644
--- a/app/code/Magento/Catalog/Observer/SwitchPriceAttributeScopeOnConfigChange.php
+++ b/app/code/Magento/Catalog/Observer/SwitchPriceAttributeScopeOnConfigChange.php
@@ -3,26 +3,20 @@
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
-namespace Magento\Catalog\Observer;
-use Magento\Framework\Event\Observer as EventObserver;
-use Magento\Framework\Event\ObserverInterface;
+namespace Magento\Catalog\Model\Config;
+
use Magento\Catalog\Api\Data\ProductAttributeInterface;
use Magento\Catalog\Api\ProductAttributeRepositoryInterface;
-use Magento\Store\Model\Store;
-use Magento\Framework\App\Config\ReinitableConfigInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
+use Magento\Store\Model\Store;
-/**
- * Observer is responsible for changing scope for all price attributes in system
- * depending on 'Catalog Price Scope' configuration parameter
- */
-class SwitchPriceAttributeScopeOnConfigChange implements ObserverInterface
+class PriceScopeChange
{
/**
- * @var ReinitableConfigInterface
+ * @var SearchCriteriaBuilder
*/
- private $config;
+ private $searchCriteriaBuilder;
/**
* @var ProductAttributeRepositoryInterface
@@ -30,40 +24,33 @@ class SwitchPriceAttributeScopeOnConfigChange implements ObserverInterface
private $productAttributeRepository;
/**
- * @var SearchCriteriaBuilder
- */
- private $searchCriteriaBuilder;
-
- /**
- * @param ReinitableConfigInterface $config
* @param ProductAttributeRepositoryInterface $productAttributeRepository
* @param SearchCriteriaBuilder $searchCriteriaBuilder
*/
public function __construct(
- ReinitableConfigInterface $config,
ProductAttributeRepositoryInterface $productAttributeRepository,
SearchCriteriaBuilder $searchCriteriaBuilder
) {
- $this->config = $config;
$this->productAttributeRepository = $productAttributeRepository;
$this->searchCriteriaBuilder = $searchCriteriaBuilder;
}
/**
- * Change scope for all price attributes according to
- * 'Catalog Price Scope' configuration parameter value
+ * Updates the price attributes scope
+ *
+ * @param int $value
+ * @throws \Magento\Framework\Exception\InputException
+ * @throws \Magento\Framework\Exception\NoSuchEntityException
+ * @throws \Magento\Framework\Exception\StateException
*
- * @param EventObserver $observer
- * @return void
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @retrun void
*/
- public function execute(EventObserver $observer)
+ public function changeScope(int $value): void
{
$this->searchCriteriaBuilder->addFilter('frontend_input', 'price');
$criteria = $this->searchCriteriaBuilder->create();
- $scope = $this->config->getValue(Store::XML_PATH_PRICE_SCOPE);
- $scope = ($scope == Store::PRICE_SCOPE_WEBSITE)
+ $scope = ($value === Store::PRICE_SCOPE_WEBSITE)
? ProductAttributeInterface::SCOPE_WEBSITE_TEXT
: ProductAttributeInterface::SCOPE_GLOBAL_TEXT;
diff --git a/app/code/Magento/Catalog/_files/product_with_price_on_second_website.php b/app/code/Magento/Catalog/_files/product_with_price_on_second_website.php
new file mode 100644
index 0000000000000..3f640d64e30fb
--- /dev/null
+++ b/app/code/Magento/Catalog/_files/product_with_price_on_second_website.php
@@ -0,0 +1,73 @@
+requireDataFixture('Magento/Store/_files/second_website_with_store_group_and_store.php');
+
+$objectManager = Bootstrap::getObjectManager();
+/** @var Config $configResource */
+$configResource = $objectManager->get(Config::class);
+$configResource->saveConfig(Data::XML_PATH_PRICE_SCOPE, Store::PRICE_SCOPE_WEBSITE, 'default', 0);
+$objectManager->get(ReinitableConfigInterface::class)->reinit();
+/** @var ProductInterfaceFactory $productFactory */
+$productFactory = $objectManager->get(ProductInterfaceFactory::class);
+/** @var ProductRepositoryInterface $productRepository */
+$productRepository = $objectManager->get(ProductRepositoryInterface::class);
+/** @var WebsiteRepositoryInterface $websiteRepository */
+$websiteRepository = $objectManager->get(WebsiteRepositoryInterface::class);
+$websiteId = $websiteRepository->get('test')->getId();
+$defaultWebsiteId = $websiteRepository->get('base')->getId();
+/** @var StoreManagerInterface $storeManager */
+$storeManager = $objectManager->get(StoreManagerInterface::class);
+$secondStoreId = $storeManager->getStore('fixture_second_store')->getId();
+/** @var $product \Magento\Catalog\Model\Product */
+$product = $productFactory->create();
+$product->setTypeId(Type::TYPE_SIMPLE)
+ ->setAttributeSetId($product->getDefaultAttributeSetId())
+ ->setWebsiteIds([$defaultWebsiteId, $websiteId])
+ ->setName('Second website price product')
+ ->setSku('second-website-price-product')
+ ->setPrice(20)
+ ->setSpecialPrice(15)
+ ->setWeight(1)
+ ->setMetaTitle('meta title')
+ ->setMetaKeyword('meta keyword')
+ ->setMetaDescription('meta description')
+ ->setVisibility(Visibility::VISIBILITY_BOTH)
+ ->setStatus(Status::STATUS_ENABLED)
+ ->setStockData(
+ [
+ 'use_config_manage_stock' => 1,
+ 'qty' => 100,
+ 'is_in_stock' => 1
+ ]
+ );
+$productRepository->save($product);
+
+try {
+ $currentStoreCode = $storeManager->getStore()->getCode();
+ $storeManager->setCurrentStore('fixture_second_store');
+ $product = $productRepository->get('second-website-price-product', false, $secondStoreId, true);
+ $product->setPrice(10)
+ ->setSpecialPrice(5.99);
+ $productRepository->save($product);
+} finally {
+ $storeManager->setCurrentStore($currentStoreCode);
+}
diff --git a/app/code/Magento/Catalog/_files/product_with_price_on_second_website_rollback.php b/app/code/Magento/Catalog/_files/product_with_price_on_second_website_rollback.php
new file mode 100644
index 0000000000000..10157b8eae4fe
--- /dev/null
+++ b/app/code/Magento/Catalog/_files/product_with_price_on_second_website_rollback.php
@@ -0,0 +1,33 @@
+get(Registry::class);
+$registry->unregister('isSecureArea');
+$registry->register('isSecureArea', true);
+/** @var Config $configResource */
+$configResource = $objectManager->get(Config::class);
+$configResource->deleteConfig(Data::XML_PATH_PRICE_SCOPE, 'default', 0);
+/** @var ProductRepositoryInterface $productRepository */
+$productRepository = $objectManager->get(ProductRepositoryInterface::class);
+try {
+ $productRepository->deleteById('second-website-price-product');
+} catch (NoSuchEntityException $e) {
+ //product already deleted
+}
+$registry->unregister('isSecureArea');
+$registry->register('isSecureArea', false);
+Resolver::getInstance()->requireDataFixture(
+ 'Magento/Store/_files/second_website_with_store_group_and_store_rollback.php'
+);
diff --git a/app/code/Magento/Catalog/etc/events.xml b/app/code/Magento/Catalog/etc/events.xml
index ee5643e9ddb11..657ce1b5f6065 100644
--- a/app/code/Magento/Catalog/etc/events.xml
+++ b/app/code/Magento/Catalog/etc/events.xml
@@ -52,9 +52,6 @@
-
-
-
diff --git a/app/code/Magento/CatalogImportExport/Model/Export/ProductTest.php b/app/code/Magento/CatalogImportExport/Model/Export/ProductTest.php
new file mode 100644
index 0000000000000..0d13cf4c8ea4b
--- /dev/null
+++ b/app/code/Magento/CatalogImportExport/Model/Export/ProductTest.php
@@ -0,0 +1,729 @@
+objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager();
+ $this->fileSystem = $this->objectManager->get(\Magento\Framework\Filesystem::class);
+ $this->model = $this->objectManager->create(
+ \Magento\CatalogImportExport\Model\Export\Product::class
+ );
+ $this->productRepository = $this->objectManager->create(ProductRepositoryInterface::class);
+ }
+
+ /**
+ * @magentoDataFixture Magento/CatalogImportExport/_files/product_export_data.php
+ * @magentoDbIsolation enabled
+ *
+ * @return void
+ */
+ public function testExport(): void
+ {
+ $this->model->setWriter(
+ $this->objectManager->create(
+ \Magento\ImportExport\Model\Export\Adapter\Csv::class
+ )
+ );
+ $exportData = $this->model->export();
+ $this->assertStringContainsString('New Product', $exportData);
+
+ $this->assertStringContainsString('Option 1 & Value 1"', $exportData);
+ $this->assertStringContainsString('Option 1 & Value 2"', $exportData);
+ $this->assertStringContainsString('Option 1 & Value 3"', $exportData);
+ $this->assertStringContainsString('Option 4 ""!@#$%^&*', $exportData);
+ $this->assertStringContainsString('test_option_code_2', $exportData);
+ $this->assertStringContainsString('max_characters=10', $exportData);
+ $this->assertStringContainsString('text_attribute=!@#$%^&*()_+1234567890-=|\\:;""\'<,>.?/', $exportData);
+ $occurrencesCount = substr_count($exportData, 'Hello "" &"" Bring the water bottle when you can!');
+ $this->assertEquals(1, $occurrencesCount);
+ }
+
+ /**
+ * Verify successful export of the product with custom attributes containing json and markup
+ *
+ * @magentoDataFixture Magento/Catalog/_files/product_text_attribute.php
+ * @magentoDataFixture Magento/Catalog/_files/second_product_simple.php
+ * @magentoDbIsolation enabled
+ * @dataProvider exportWithJsonAndMarkupTextAttributeDataProvider
+ * @param string $attributeData
+ * @param string $expectedResult
+ * @return void
+ */
+ public function testExportWithJsonAndMarkupTextAttribute(string $attributeData, string $expectedResult): void
+ {
+ /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */
+ $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager();
+ $productRepository = $objectManager->get(\Magento\Catalog\Api\ProductRepositoryInterface::class);
+ $product = $productRepository->get('simple2');
+
+ /** @var \Magento\Eav\Model\Config $eavConfig */
+ $eavConfig = $objectManager->get(\Magento\Eav\Model\Config::class);
+ $eavConfig->clear();
+ $attribute = $eavConfig->getAttribute(\Magento\Catalog\Model\Product::ENTITY, 'text_attribute');
+ $attribute->setDefaultValue($attributeData);
+ /** @var \Magento\Catalog\Api\ProductAttributeRepositoryInterface $productAttributeRepository */
+ $productAttributeRepository = $objectManager->get(
+ \Magento\Catalog\Api\ProductAttributeRepositoryInterface::class
+ );
+ $productAttributeRepository->save($attribute);
+ $product->setCustomAttribute('text_attribute', $attribute->getDefaultValue());
+ $productRepository->save($product);
+
+ $this->model->setWriter(
+ $this->objectManager->create(
+ \Magento\ImportExport\Model\Export\Adapter\Csv::class
+ )
+ );
+ $exportData = $this->model->export();
+ $this->assertStringContainsString('Simple Product2', $exportData);
+ $this->assertStringContainsString($expectedResult, $exportData);
+ }
+
+ /**
+ * @return array
+ */
+ public function exportWithJsonAndMarkupTextAttributeDataProvider(): array
+ {
+ return [
+ 'json' => [
+ '{"type": "basic", "unit": "inch", "sign": "(")", "size": "1.5""}',
+ '"text_attribute={""type"": ""basic"", ""unit"": ""inch"", ""sign"": ""("")"", ""size"": ""1.5""""}"'
+ ],
+ 'markup' => [
+ '
Element type is basic, measured in inches ' .
+ '(marked with sign (")) with size 1.5", mid-price range
',
+ '"text_attribute=Element type is basic, measured in inches ' .
+ '(marked with sign ("")) with size 1.5"", mid-price range
"'
+ ],
+ ];
+ }
+
+ /**
+ * @magentoDataFixture Magento/CatalogImportExport/_files/product_export_data_special_chars.php
+ * @magentoDbIsolation enabled
+ *
+ * @return void
+ */
+ public function testExportSpecialChars(): void
+ {
+ $this->model->setWriter(
+ $this->objectManager->create(
+ \Magento\ImportExport\Model\Export\Adapter\Csv::class
+ )
+ );
+ $exportData = $this->model->export();
+ $this->assertStringContainsString('simple ""1""', $exportData);
+ $this->assertStringContainsString('Category with slash\/ symbol', $exportData);
+ }
+
+ /**
+ * @magentoDataFixture Magento/CatalogImportExport/_files/product_export_with_product_links_data.php
+ * @magentoDbIsolation enabled
+ *
+ * @return void
+ */
+ public function testExportWithProductLinks(): void
+ {
+ $this->model->setWriter(
+ \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(
+ \Magento\ImportExport\Model\Export\Adapter\Csv::class
+ )
+ );
+ $this->assertNotEmpty($this->model->export());
+ }
+
+ /**
+ * Verify that all stock item attribute values are exported (aren't equal to empty string)
+ *
+ * @magentoAppIsolation enabled
+ * @magentoDbIsolation enabled
+ * @covers \Magento\CatalogImportExport\Model\Export\Product::export
+ * @magentoDataFixture Magento/CatalogImportExport/_files/product_export_data.php
+ *
+ * @return void
+ */
+ public function testExportStockItemAttributesAreFilled(): void
+ {
+ $this->markTestSkipped('Test needs to be skipped.');
+ $fileWrite = $this->createMock(\Magento\Framework\Filesystem\File\Write::class);
+ $directoryMock = $this->createPartialMock(
+ \Magento\Framework\Filesystem\Directory\Write::class,
+ ['getParentDirectory', 'isWritable', 'isFile', 'readFile', 'openFile']
+ );
+ $directoryMock->expects($this->any())->method('getParentDirectory')->willReturn('some#path');
+ $directoryMock->expects($this->any())->method('isWritable')->willReturn(true);
+ $directoryMock->expects($this->any())->method('isFile')->willReturn(true);
+ $directoryMock->expects(
+ $this->any()
+ )->method(
+ 'readFile'
+ )->willReturn(
+ 'some string read from file'
+ );
+ $directoryMock->expects($this->once())->method('openFile')->willReturn($fileWrite);
+
+ $filesystemMock = $this->createPartialMock(\Magento\Framework\Filesystem::class, ['getDirectoryWrite']);
+ $filesystemMock->expects($this->once())->method('getDirectoryWrite')->willReturn($directoryMock);
+
+ $exportAdapter = new \Magento\ImportExport\Model\Export\Adapter\Csv($filesystemMock);
+
+ $this->model->setWriter($exportAdapter)->export();
+ }
+
+ /**
+ * Verify header columns (that stock item attributes column headers are present)
+ *
+ * @param array $headerColumns
+ * @return void
+ */
+ public function verifyHeaderColumns(array $headerColumns): void
+ {
+ foreach (self::$stockItemAttributes as $stockItemAttribute) {
+ $this->assertStringContainsString(
+ $stockItemAttribute,
+ $headerColumns,
+ "Stock item attribute {$stockItemAttribute} is absent among header columns"
+ );
+ }
+ }
+
+ /**
+ * Verify row data (stock item attribute values)
+ *
+ * @param array $rowData
+ * @return void
+ */
+ public function verifyRow(array $rowData): void
+ {
+ foreach (self::$stockItemAttributes as $stockItemAttribute) {
+ $this->assertNotSame(
+ '',
+ $rowData[$stockItemAttribute],
+ "Stock item attribute {$stockItemAttribute} value is empty string"
+ );
+ }
+ }
+
+ /**
+ * Verifies if exception processing works properly
+ * @magentoDbIsolation enabled
+ * @magentoDataFixture Magento/CatalogImportExport/_files/product_export_data.php
+ *
+ * @return void
+ */
+ public function testExceptionInGetExportData(): void
+ {
+ $this->markTestSkipped('Test needs to be skipped.');
+ $exception = new \Exception('Error');
+
+ $rowCustomizerMock =
+ $this->getMockBuilder(\Magento\CatalogImportExport\Model\Export\RowCustomizerInterface::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $loggerMock = $this->getMockBuilder(\Psr\Log\LoggerInterface::class)->getMock();
+
+ $directoryMock = $this->createPartialMock(
+ \Magento\Framework\Filesystem\Directory\Write::class,
+ ['getParentDirectory', 'isWritable']
+ );
+ $directoryMock->expects($this->any())->method('getParentDirectory')->willReturn('some#path');
+ $directoryMock->expects($this->any())->method('isWritable')->willReturn(true);
+
+ $filesystemMock = $this->createPartialMock(\Magento\Framework\Filesystem::class, ['getDirectoryWrite']);
+ $filesystemMock->expects($this->once())->method('getDirectoryWrite')->willReturn($directoryMock);
+
+ $exportAdapter = new \Magento\ImportExport\Model\Export\Adapter\Csv($filesystemMock);
+
+ $rowCustomizerMock->expects($this->once())->method('prepareData')->willThrowException($exception);
+ $loggerMock->expects($this->once())->method('critical')->with($exception);
+
+ $collection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(
+ \Magento\Catalog\Model\ResourceModel\Product\Collection::class
+ );
+
+ /** @var \Magento\CatalogImportExport\Model\Export\Product $model */
+ $model = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(
+ \Magento\CatalogImportExport\Model\Export\Product::class,
+ [
+ 'rowCustomizer' => $rowCustomizerMock,
+ 'logger' => $loggerMock,
+ 'collection' => $collection
+ ]
+ );
+
+ $data = $model->setWriter($exportAdapter)->export();
+ $this->assertEmpty($data);
+ }
+
+ /**
+ * Verify if fields wrapping works correct when "Fields Enclosure" option enabled
+ *
+ * @magentoDataFixture Magento/CatalogImportExport/_files/product_export_data.php
+ *
+ * @return void
+ */
+ public function testExportWithFieldsEnclosure(): void
+ {
+ $this->model->setParameters(
+ [
+ \Magento\ImportExport\Model\Export::FIELDS_ENCLOSURE => 1
+ ]
+ );
+
+ $this->model->setWriter(
+ $this->objectManager->create(
+ \Magento\ImportExport\Model\Export\Adapter\Csv::class
+ )
+ );
+ $exportData = $this->model->export();
+
+ $this->assertStringContainsString('""Option 2""', $exportData);
+ $this->assertStringContainsString('""Option 3""', $exportData);
+ $this->assertStringContainsString('""Option 4 """"!@#$%^&*""', $exportData);
+ $this->assertStringContainsString('text_attribute=""!@#$%^&*()_+1234567890-=|\:;""""\'<,>.?/', $exportData);
+ }
+
+ /**
+ * Verify that "category ids" filter correctly applies to export result
+ *
+ * @magentoDataFixture Magento/CatalogImportExport/_files/product_export_with_categories.php
+ *
+ * @return void
+ */
+ public function testCategoryIdsFilter(): void
+ {
+ $this->model->setWriter(
+ $this->objectManager->create(
+ \Magento\ImportExport\Model\Export\Adapter\Csv::class
+ )
+ );
+
+ $this->model->setParameters(
+ [
+ \Magento\ImportExport\Model\Export::FILTER_ELEMENT_GROUP => [
+ 'category_ids' => '2,13'
+ ]
+ ]
+ );
+
+ $exportData = $this->model->export();
+
+ $this->assertStringContainsString('Simple Product', $exportData);
+ $this->assertStringContainsString('Simple Product Three', $exportData);
+ $this->assertStringNotContainsString('Simple Product Two', $exportData);
+ $this->assertStringNotContainsString('Simple Product Not Visible On Storefront', $exportData);
+ }
+
+ /**
+ * Verify that export processed successfully with wrong category path
+ *
+ * @magentoDataFixture Magento/CatalogImportExport/_files/product_export_with_broken_categories_path.php
+ *
+ * @return void
+ */
+ public function testExportWithWrongCategoryPath(): void
+ {
+ $this->model->setWriter(
+ $this->objectManager->create(
+ \Magento\ImportExport\Model\Export\Adapter\Csv::class
+ )
+ );
+
+ $this->model->export();
+ }
+
+ /**
+ * Test 'hide from product page' export for non-default store.
+ *
+ * @magentoDataFixture Magento/CatalogImportExport/_files/product_export_with_images.php
+ *
+ * @return void
+ */
+ public function testExportWithMedia(): void
+ {
+ /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */
+ $productRepository = $this->objectManager->get(\Magento\Catalog\Api\ProductRepositoryInterface::class);
+ $product = $productRepository->get('simple', 1);
+ $mediaGallery = $product->getData('media_gallery');
+ $image = array_shift($mediaGallery['images']);
+ $this->model->setWriter(
+ $this->objectManager->create(
+ \Magento\ImportExport\Model\Export\Adapter\Csv::class
+ )
+ );
+ $exportData = $this->model->export();
+ /** @var $varDirectory \Magento\Framework\Filesystem\Directory\WriteInterface */
+ $varDirectory = $this->objectManager->get(\Magento\Framework\Filesystem::class)
+ ->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::VAR_DIR);
+ $varDirectory->writeFile('test_product_with_image.csv', $exportData);
+ /** @var \Magento\Framework\File\Csv $csv */
+ $csv = $this->objectManager->get(\Magento\Framework\File\Csv::class);
+ $data = $csv->getData($varDirectory->getAbsolutePath('test_product_with_image.csv'));
+ foreach ($data[0] as $columnNumber => $columnName) {
+ if ($columnName === 'hide_from_product_page') {
+ self::assertSame($image['file'], $data[2][$columnNumber]);
+ }
+ }
+ }
+
+ /**
+ * @magentoDataFixture Magento/CatalogImportExport/_files/product_export_data.php
+ *
+ * @return void
+ */
+ public function testExportWithCustomOptions(): void
+ {
+ $storeCode = 'default';
+ $expectedData = [];
+ /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */
+ $productRepository = $this->objectManager->get(\Magento\Catalog\Api\ProductRepositoryInterface::class);
+ $store = $this->objectManager->create(\Magento\Store\Model\Store::class);
+ $store->load('default', 'code');
+ /** @var \Magento\Catalog\Api\Data\ProductInterface $product */
+ $product = $productRepository->get('simple', 1, $store->getStoreId());
+ $newCustomOptions = [];
+ foreach ($product->getOptions() as $customOption) {
+ $defaultOptionTitle = $customOption->getTitle();
+ $secondStoreOptionTitle = $customOption->getTitle() . '_' . $storeCode;
+ $expectedData['admin_store'][$defaultOptionTitle] = [];
+ $expectedData[$storeCode][$secondStoreOptionTitle] = [];
+ $customOption->setTitle($secondStoreOptionTitle);
+ if ($customOption->getValues()) {
+ $newOptionValues = [];
+ foreach ($customOption->getValues() as $customOptionValue) {
+ $valueTitle = $customOptionValue->getTitle();
+ $expectedData['admin_store'][$defaultOptionTitle][] = $valueTitle;
+ $expectedData[$storeCode][$secondStoreOptionTitle][] = $valueTitle . '_' . $storeCode;
+ $newOptionValues[] = $customOptionValue->setTitle($valueTitle . '_' . $storeCode);
+ }
+ $customOption->setValues($newOptionValues);
+ }
+ $newCustomOptions[] = $customOption;
+ }
+ $product->setOptions($newCustomOptions);
+ $productRepository->save($product);
+ $this->model->setWriter(
+ $this->objectManager->create(\Magento\ImportExport\Model\Export\Adapter\Csv::class)
+ );
+ $exportData = $this->model->export();
+ /** @var $varDirectory \Magento\Framework\Filesystem\Directory\WriteInterface */
+ $varDirectory = $this->objectManager->get(\Magento\Framework\Filesystem::class)
+ ->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::VAR_DIR);
+ $varDirectory->writeFile('test_product_with_custom_options_and_second_store.csv', $exportData);
+ /** @var \Magento\Framework\File\Csv $csv */
+ $csv = $this->objectManager->get(\Magento\Framework\File\Csv::class);
+ $data = $csv->getData($varDirectory->getAbsolutePath('test_product_with_custom_options_and_second_store.csv'));
+ $keys = array_shift($data);
+ $products = [];
+ foreach ($data as $productData) {
+ $products[] = array_combine($keys, $productData);
+ }
+ $products = array_filter($products, function (array $product) {
+ return $product['sku'] === 'simple';
+ });
+ $customOptionData = [];
+
+ foreach ($products as $product) {
+ $storeCode = $product['store_view_code'] ?: 'admin_store';
+ $customOptionData[$storeCode] = $this->parseExportedCustomOption($product['custom_options']);
+ }
+
+ self::assertSame($expectedData, $customOptionData);
+ }
+
+ /**
+ * Check that no duplicate entities when multiple custom options used
+ *
+ * @magentoDataFixture Magento/Catalog/_files/product_simple_with_options.php
+ *
+ * @return void
+ */
+ public function testExportWithMultipleOptions(): void
+ {
+ $expectedCount = 1;
+ $resultsFilename = 'export_results.csv';
+ $this->model->setWriter(
+ $this->objectManager->create(
+ \Magento\ImportExport\Model\Export\Adapter\Csv::class
+ )
+ );
+ $exportData = $this->model->export();
+
+ $varDirectory = $this->objectManager->get(\Magento\Framework\Filesystem::class)
+ ->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::VAR_DIR);
+ $varDirectory->writeFile($resultsFilename, $exportData);
+ /** @var \Magento\Framework\File\Csv $csv */
+ $csv = $this->objectManager->get(\Magento\Framework\File\Csv::class);
+ $data = $csv->getData($varDirectory->getAbsolutePath($resultsFilename));
+ $actualCount = count($data) - 1;
+
+ $this->assertSame($expectedCount, $actualCount);
+ }
+
+ /**
+ * Parse exported custom options
+ *
+ * @param string $exportedCustomOption
+ * @return array
+ */
+ private function parseExportedCustomOption(string $exportedCustomOption): array
+ {
+ $customOptions = explode('|', $exportedCustomOption);
+ $optionItems = [];
+ foreach ($customOptions as $customOption) {
+ $parsedOptions = array_values(
+ array_map(
+ function ($input) {
+ $data = explode('=', $input);
+ return [$data[0] => $data[1]];
+ },
+ explode(',', $customOption)
+ )
+ );
+ $optionName = array_column($parsedOptions, 'name')[0];
+ if (!empty(array_column($parsedOptions, 'option_title'))) {
+ $optionItems[$optionName][] = array_column($parsedOptions, 'option_title')[0];
+ } else {
+ $optionItems[$optionName] = [];
+ }
+ }
+
+ return $optionItems;
+ }
+
+ /**
+ * @magentoDataFixture Magento/Catalog/_files/product_simple.php
+ * @magentoDataFixture Magento/Store/_files/second_website_with_two_stores.php
+ * @magentoConfigFixture current_store catalog/price/scope 1
+ * @magentoDbIsolation disabled
+ * @magentoAppArea adminhtml
+ *
+ * @return void
+ */
+ public function testExportProductWithTwoWebsites(): void
+ {
+ $globalStoreCode = 'admin';
+ $secondStoreCode = 'fixture_second_store';
+
+ $expectedData = [
+ $globalStoreCode => 10.0,
+ $secondStoreCode => 9.99
+ ];
+
+ /** @var \Magento\Store\Model\Store $store */
+ $store = $this->objectManager->create(\Magento\Store\Model\Store::class);
+ $reinitiableConfig = $this->objectManager->get(ReinitableConfigInterface::class);
+ /** @var \Magento\Catalog\Model\Product\Action $productAction */
+ $productAction = $this->objectManager->create(\Magento\Catalog\Model\Product\Action::class);
+ /** @var \Magento\Framework\File\Csv $csv */
+ $csv = $this->objectManager->get(\Magento\Framework\File\Csv::class);
+ /** @var $varDirectory \Magento\Framework\Filesystem\Directory\WriteInterface */
+ $varDirectory = $this->objectManager->get(\Magento\Framework\Filesystem::class)
+ ->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::VAR_DIR);
+ $secondStore = $store->load($secondStoreCode);
+
+ $this->model->setWriter(
+ $this->objectManager->create(
+ \Magento\ImportExport\Model\Export\Adapter\Csv::class
+ )
+ );
+
+ $reinitiableConfig->setValue('catalog/price/scope', \Magento\Store\Model\Store::PRICE_SCOPE_WEBSITE);
+
+ $product = $this->productRepository->get('simple');
+ $productId = $product->getId();
+ $productAction->updateWebsites([$productId], [$secondStore->getWebsiteId()], 'add');
+ $product->setStoreId($secondStore->getId());
+ $product->setPrice('9.99');
+ $this->productRepository->save($product);
+
+ $exportData = $this->model->export();
+
+ $varDirectory->writeFile('test_product_with_two_websites.csv', $exportData);
+ $data = $csv->getData($varDirectory->getAbsolutePath('test_product_with_two_websites.csv'));
+
+ $columnNumber = array_search('price', $data[0]);
+ $this->assertNotFalse($columnNumber);
+
+ $pricesData = [
+ $globalStoreCode => (float)$data[1][$columnNumber],
+ $secondStoreCode => (float)$data[2][$columnNumber],
+ ];
+
+ self::assertSame($expectedData, $pricesData);
+
+ $reinitiableConfig->setValue('catalog/price/scope', \Magento\Store\Model\Store::PRICE_SCOPE_GLOBAL);
+ }
+
+ /**
+ * Verify that "stock status" filter correctly applies to export result
+ *
+ * @magentoDataFixture Magento/Catalog/_files/multiple_products_with_few_out_of_stock.php
+ * @dataProvider filterByQuantityAndStockStatusDataProvider
+ *
+ * @param string $value
+ * @param array $productsIncluded
+ * @param array $productsNotIncluded
+ * @return void
+ */
+ public function testFilterByQuantityAndStockStatus(
+ string $value,
+ array $productsIncluded,
+ array $productsNotIncluded
+ ): void {
+ $exportData = $this->doExport(['quantity_and_stock_status' => $value]);
+ foreach ($productsIncluded as $productName) {
+ $this->assertStringContainsString($productName, $exportData);
+ }
+ foreach ($productsNotIncluded as $productName) {
+ $this->assertStringNotContainsString($productName, $exportData);
+ }
+ }
+ /**
+ * @return array
+ */
+ public function filterByQuantityAndStockStatusDataProvider(): array
+ {
+ return [
+ [
+ '',
+ [
+ 'Simple Product OOS',
+ 'Simple Product Not Visible',
+ 'Simple Product Visible and InStock',
+ ],
+ [
+ ],
+ ],
+ [
+ '1',
+ [
+ 'Simple Product Not Visible',
+ 'Simple Product Visible and InStock',
+ ],
+ [
+ 'Simple Product OOS',
+ ],
+ ],
+ [
+ '0',
+ [
+ 'Simple Product OOS',
+ ],
+ [
+ 'Simple Product Not Visible',
+ 'Simple Product Visible and InStock',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Test that Product Export takes into account filtering by Website
+ *
+ * Fixtures provide two products, one assigned to default website only,
+ * and the other is assigned to to default and custom websites. Only product assigned custom website is exported
+ *
+ * @magentoDataFixture Magento/Catalog/_files/product_simple_with_options.php
+ * @magentoDataFixture Magento/Catalog/_files/product_with_two_websites.php
+ */
+ public function testExportProductWithRestrictedWebsite(): void
+ {
+ $websiteRepository = $this->objectManager->get(\Magento\Store\Api\WebsiteRepositoryInterface::class);
+ $website = $websiteRepository->get('second_website');
+
+ $exportData = $this->doExport(['website_id' => $website->getId()]);
+
+ $this->assertStringContainsString('"Simple Product"', $exportData);
+ $this->assertStringNotContainsString('"Virtual Product With Custom Options"', $exportData);
+ }
+
+ /**
+ * Perform export
+ *
+ * @param array $filters
+ * @return string
+ */
+ private function doExport(array $filters = []): string
+ {
+ $this->model->setWriter(
+ $this->objectManager->create(
+ \Magento\ImportExport\Model\Export\Adapter\Csv::class
+ )
+ );
+ $this->model->setParameters(
+ [
+ \Magento\ImportExport\Model\Export::FILTER_ELEMENT_GROUP => $filters
+ ]
+ );
+ return $this->model->export();
+ }
+}
diff --git a/app/code/Magento/ConfigurableProduct/_files/configurable_product_with_price_on_second_website.php b/app/code/Magento/ConfigurableProduct/_files/configurable_product_with_price_on_second_website.php
new file mode 100644
index 0000000000000..19aab846efa05
--- /dev/null
+++ b/app/code/Magento/ConfigurableProduct/_files/configurable_product_with_price_on_second_website.php
@@ -0,0 +1,122 @@
+requireDataFixture('Magento/Store/_files/second_website_with_store_group_and_store.php');
+Resolver::getInstance()->requireDataFixture('Magento/ConfigurableProduct/_files/configurable_attribute.php');
+Resolver::getInstance()->requireDataFixture('Magento/Catalog/_files/category.php');
+
+$objectManager = Bootstrap::getObjectManager();
+/** @var ProductAttributeRepositoryInterface $productAttributeRepository */
+$productAttributeRepository = $objectManager->get(ProductAttributeRepositoryInterface::class);
+/** @var WebsiteRepositoryInterface $websiteRepository */
+$websiteRepository = $objectManager->get(WebsiteRepositoryInterface::class);
+/** @var ProductFactory $productFactory */
+$productFactory = $objectManager->get(ProductFactory::class);
+/** @var ProductRepositoryInterface $productRepository */
+$productRepository = $objectManager->get(ProductRepositoryInterface::class);
+$productRepository->cleanCache();
+/** @var Factory $optionsFactory */
+$optionsFactory = $objectManager->get(Factory::class);
+/** @var ProductExtensionFactory $extensionAttributesFactory */
+$extensionAttributesFactory = $objectManager->get(ProductExtensionFactory::class);
+/** @var Config $configResource */
+$configResource = $objectManager->get(Config::class);
+/** @var DefaultCategory $categoryHelper */
+$categoryHelper = $objectManager->get(DefaultCategory::class);
+
+$attribute = $productAttributeRepository->get('test_configurable');
+$options = $attribute->getOptions();
+$baseWebsite = $websiteRepository->get('base');
+$secondWebsite = $websiteRepository->get('test');
+$attributeValues = [];
+$associatedProductIds = [];
+array_shift($options);
+foreach ($options as $option) {
+ $product = $productFactory->create();
+ $product->setTypeId(ProductType::TYPE_SIMPLE)
+ ->setAttributeSetId($product->getDefaultAttributeSetId())
+ ->setWebsiteIds([$baseWebsite->getId(), $secondWebsite->getId()])
+ ->setName('Configurable Option ' . $option->getLabel())
+ ->setSku(strtolower(str_replace(' ', '_', 'simple ' . $option->getLabel())))
+ ->setTestConfigurable($option->getValue())
+ ->setVisibility(Visibility::VISIBILITY_NOT_VISIBLE)
+ ->setStatus(Status::STATUS_ENABLED)
+ ->setPrice(150)
+ ->setCategoryIds([$categoryHelper->getId(), 333])
+ ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]);
+ $product = $productRepository->save($product);
+ $associatedProductIds[] = $product->getId();
+ $attributeValues[] = [
+ 'label' => 'test',
+ 'attribute_id' => $attribute->getId(),
+ 'value_index' => $option->getValue(),
+ ];
+}
+$configurableAttributesData = [
+ [
+ 'values' => $attributeValues,
+ 'attribute_id' => $attribute->getId(),
+ 'code' => $attribute->getAttributeCode(),
+ 'label' => $attribute->getStoreLabel(),
+ 'position' => '0',
+ ],
+];
+$configurableOptions = $optionsFactory->create($configurableAttributesData);
+$product = $productFactory->create();
+$extensionConfigurableAttributes = $product->getExtensionAttributes() ?: $extensionAttributesFactory->create();
+$extensionConfigurableAttributes->setConfigurableProductOptions($configurableOptions);
+$extensionConfigurableAttributes->setConfigurableProductLinks($associatedProductIds);
+$product->setExtensionAttributes($extensionConfigurableAttributes);
+$product->setTypeId(Configurable::TYPE_CODE)
+ ->setAttributeSetId($product->getDefaultAttributeSetId())
+ ->setWebsiteIds([$baseWebsite->getId(), $secondWebsite->getId()])
+ ->setStatus(Status::STATUS_ENABLED)
+ ->setCategoryIds([$categoryHelper->getId(), 333])
+ ->setSku('configurable')
+ ->setName('Configurable Product')
+ ->setVisibility(Visibility::VISIBILITY_BOTH)
+ ->setStockData(['use_config_manage_stock' => 1, 'is_in_stock' => 1]);
+$productRepository->save($product);
+
+$configResource->saveConfig(Data::XML_PATH_PRICE_SCOPE, Store::PRICE_SCOPE_WEBSITE, 'default', 0);
+$objectManager->get(ReinitableConfigInterface::class)->reinit();
+/** @var StoreManagerInterface $storeManager */
+$storeManager = $objectManager->get(StoreManagerInterface::class);
+$secondStoreId = $storeManager->getStore('fixture_second_store')->getId();
+try {
+ $currentStoreCode = $storeManager->getStore()->getCode();
+ $storeManager->setCurrentStore('fixture_second_store');
+ $firstChild = $productRepository->get('simple_option_1', false, $secondStoreId, true);
+ $firstChild->setPrice(20)
+ ->setSpecialPrice(10);
+ $productRepository->save($firstChild);
+ $secondChild = $productRepository->get('simple_option_2', false, $secondStoreId, true);
+ $secondChild->setPrice(40)
+ ->setSpecialPrice(30);
+ $productRepository->save($secondChild);
+} finally {
+ $storeManager->setCurrentStore($currentStoreCode);
+}
diff --git a/app/code/Magento/ConfigurableProduct/_files/configurable_product_with_price_on_second_website_rollback.php b/app/code/Magento/ConfigurableProduct/_files/configurable_product_with_price_on_second_website_rollback.php
new file mode 100644
index 0000000000000..c1e2a5f810853
--- /dev/null
+++ b/app/code/Magento/ConfigurableProduct/_files/configurable_product_with_price_on_second_website_rollback.php
@@ -0,0 +1,30 @@
+get(DeleteConfigurableProduct::class);
+$deleteConfigurableProduct->execute('configurable');
+/** @var Config $configResource */
+$configResource = $objectManager->get(Config::class);
+$configResource->deleteConfig(Data::XML_PATH_PRICE_SCOPE, 'default', 0);
+$objectManager->get(ReinitableConfigInterface::class)->reinit();
+
+Resolver::getInstance()->requireDataFixture(
+ 'Magento/Store/_files/second_website_with_store_group_and_store_rollback.php'
+);
+Resolver::getInstance()->requireDataFixture(
+ 'Magento/ConfigurableProduct/_files/configurable_attribute_rollback.php'
+);
+Resolver::getInstance()->requireDataFixture('Magento/Catalog/_files/category_rollback.php');
diff --git a/app/code/Magento/Store/_files/second_website_with_base_second_currency.php b/app/code/Magento/Store/_files/second_website_with_base_second_currency.php
new file mode 100644
index 0000000000000..26ea436495e9d
--- /dev/null
+++ b/app/code/Magento/Store/_files/second_website_with_base_second_currency.php
@@ -0,0 +1,36 @@
+requireDataFixture('Magento/Store/_files/second_website_with_two_stores.php');
+
+$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager();
+/** @var WebsiteRepositoryInterface $websiteRepository */
+$websiteRepository = $objectManager->get(WebsiteRepositoryInterface::class);
+$websiteId = $websiteRepository->get('test')->getId();
+/** @var \Magento\Config\Model\ResourceModel\Config $configResource */
+$configResource = $objectManager->get(\Magento\Config\Model\ResourceModel\Config::class);
+$configResource->saveConfig(
+ \Magento\Directory\Model\Currency::XML_PATH_CURRENCY_BASE,
+ 'EUR',
+ \Magento\Store\Model\ScopeInterface::SCOPE_WEBSITES,
+ $websiteId
+);
+$configResource->saveConfig(
+ \Magento\Catalog\Helper\Data::XML_PATH_PRICE_SCOPE,
+ \Magento\Store\Model\Store::PRICE_SCOPE_WEBSITE,
+ 'default',
+ 0
+);
+
+/**
+ * Configuration cache clean is required to reload currency setting
+ */
+/** @var Magento\Config\App\Config\Type\System $config */
+$config = $objectManager->get(\Magento\Config\App\Config\Type\System::class);
+$config->clean();