diff --git a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php
index 5d81c1405efe0..c53277a58157d 100644
--- a/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php
+++ b/app/code/Magento/Catalog/Model/Indexer/Category/Product/Action/Rows.php
@@ -13,15 +13,17 @@
use Magento\Store\Model\StoreManagerInterface;
use Magento\Framework\DB\Query\Generator as QueryGenerator;
use Magento\Framework\EntityManager\MetadataPool;
+use Magento\Framework\Indexer\IndexerRegistry;
use Magento\Catalog\Model\Config;
use Magento\Catalog\Model\Category;
-use Magento\Framework\Indexer\IndexerRegistry;
use Magento\Catalog\Model\Indexer\Product\Category as ProductCategoryIndexer;
+use Magento\Catalog\Model\Indexer\Category\Product as CategoryProductIndexer;
+use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer;
+use Magento\Indexer\Model\WorkingStateProvider;
/**
* Reindex multiple rows action.
*
- * @package Magento\Catalog\Model\Indexer\Category\Product\Action
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class Rows extends \Magento\Catalog\Model\Indexer\Category\Product\AbstractAction
@@ -48,15 +50,23 @@ class Rows extends \Magento\Catalog\Model\Indexer\Category\Product\AbstractActio
*/
private $indexerRegistry;
+ /**
+ * @var WorkingStateProvider
+ */
+ private $workingStateProvider;
+
/**
* @param ResourceConnection $resource
* @param StoreManagerInterface $storeManager
* @param Config $config
* @param QueryGenerator|null $queryGenerator
* @param MetadataPool|null $metadataPool
+ * @param TableMaintainer|null $tableMaintainer
* @param CacheContext|null $cacheContext
* @param EventManagerInterface|null $eventManager
* @param IndexerRegistry|null $indexerRegistry
+ * @param WorkingStateProvider|null $workingStateProvider
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList) Preserve compatibility with the parent class
*/
public function __construct(
ResourceConnection $resource,
@@ -64,14 +74,18 @@ public function __construct(
Config $config,
QueryGenerator $queryGenerator = null,
MetadataPool $metadataPool = null,
+ ?TableMaintainer $tableMaintainer = null,
CacheContext $cacheContext = null,
EventManagerInterface $eventManager = null,
- IndexerRegistry $indexerRegistry = null
+ IndexerRegistry $indexerRegistry = null,
+ ?WorkingStateProvider $workingStateProvider = null
) {
- parent::__construct($resource, $storeManager, $config, $queryGenerator, $metadataPool);
+ parent::__construct($resource, $storeManager, $config, $queryGenerator, $metadataPool, $tableMaintainer);
$this->cacheContext = $cacheContext ?: ObjectManager::getInstance()->get(CacheContext::class);
$this->eventManager = $eventManager ?: ObjectManager::getInstance()->get(EventManagerInterface::class);
$this->indexerRegistry = $indexerRegistry ?: ObjectManager::getInstance()->get(IndexerRegistry::class);
+ $this->workingStateProvider = $workingStateProvider ?:
+ ObjectManager::getInstance()->get(WorkingStateProvider::class);
}
/**
@@ -97,44 +111,64 @@ public function execute(array $entityIds = [], $useTempTable = false)
$this->limitationByCategories = array_unique($this->limitationByCategories);
$this->useTempTable = $useTempTable;
$indexer = $this->indexerRegistry->get(ProductCategoryIndexer::INDEXER_ID);
- $workingState = $indexer->isWorking();
+ $workingState = $this->isWorkingState();
- if ($useTempTable && !$workingState && $indexer->isScheduled()) {
- foreach ($this->storeManager->getStores() as $store) {
- $this->connection->truncateTable($this->getIndexTable($store->getId()));
+ if (!$indexer->isScheduled()
+ || ($indexer->isScheduled() && !$useTempTable)
+ || ($indexer->isScheduled() && $useTempTable && !$workingState)) {
+ if ($useTempTable && !$workingState && $indexer->isScheduled()) {
+ foreach ($this->storeManager->getStores() as $store) {
+ $this->connection->truncateTable($this->getIndexTable($store->getId()));
+ }
+ } else {
+ $this->removeEntries();
}
- } else {
- $this->removeEntries();
- }
- $this->reindex();
-
- if ($useTempTable && !$workingState && $indexer->isScheduled()) {
- foreach ($this->storeManager->getStores() as $store) {
- $removalCategoryIds = array_diff($this->limitationByCategories, [$this->getRootCategoryId($store)]);
- $this->connection->delete(
- $this->tableMaintainer->getMainTable($store->getId()),
- ['category_id IN (?)' => $removalCategoryIds]
- );
- $select = $this->connection->select()
- ->from($this->tableMaintainer->getMainReplicaTable($store->getId()));
- $this->connection->query(
- $this->connection->insertFromSelect(
- $select,
+ $this->reindex();
+
+ // get actual state
+ $workingState = $this->isWorkingState();
+
+ if ($useTempTable && !$workingState && $indexer->isScheduled()) {
+ foreach ($this->storeManager->getStores() as $store) {
+ $removalCategoryIds = array_diff($this->limitationByCategories, [$this->getRootCategoryId($store)]);
+ $this->connection->delete(
$this->tableMaintainer->getMainTable($store->getId()),
- [],
- AdapterInterface::INSERT_ON_DUPLICATE
- )
- );
+ ['category_id IN (?)' => $removalCategoryIds]
+ );
+ $select = $this->connection->select()
+ ->from($this->tableMaintainer->getMainReplicaTable($store->getId()));
+ $this->connection->query(
+ $this->connection->insertFromSelect(
+ $select,
+ $this->tableMaintainer->getMainTable($store->getId()),
+ [],
+ AdapterInterface::INSERT_ON_DUPLICATE
+ )
+ );
+ }
}
- }
- $this->registerCategories($entityIds);
- $this->eventManager->dispatch('clean_cache_by_tags', ['object' => $this->cacheContext]);
+ $this->registerCategories($entityIds);
+ $this->eventManager->dispatch('clean_cache_by_tags', ['object' => $this->cacheContext]);
+ }
return $this;
}
+ /**
+ * Get state for current and shared indexer
+ *
+ * @return bool
+ */
+ private function isWorkingState() : bool
+ {
+ $indexer = $this->indexerRegistry->get(ProductCategoryIndexer::INDEXER_ID);
+ $sharedIndexer = $this->indexerRegistry->get(CategoryProductIndexer::INDEXER_ID);
+ return $this->workingStateProvider->isWorking($indexer->getId())
+ || $this->workingStateProvider->isWorking($sharedIndexer->getId());
+ }
+
/**
* Register categories assigned to products
*
diff --git a/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php b/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php
index 861f7c9c1c50e..ab04f7c56c3db 100644
--- a/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php
+++ b/app/code/Magento/Catalog/Model/Indexer/Product/Category/Action/Rows.php
@@ -17,7 +17,10 @@
use Magento\Store\Model\StoreManagerInterface;
use Magento\Framework\DB\Adapter\AdapterInterface;
use Magento\Framework\Indexer\IndexerRegistry;
+use Magento\Catalog\Model\Indexer\Product\Category as ProductCategoryIndexer;
use Magento\Catalog\Model\Indexer\Category\Product as CategoryProductIndexer;
+use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer;
+use Magento\Indexer\Model\WorkingStateProvider;
/**
* Category rows indexer.
@@ -48,15 +51,23 @@ class Rows extends \Magento\Catalog\Model\Indexer\Category\Product\AbstractActio
*/
private $indexerRegistry;
+ /**
+ * @var WorkingStateProvider
+ */
+ private $workingStateProvider;
+
/**
* @param ResourceConnection $resource
* @param StoreManagerInterface $storeManager
* @param Config $config
* @param QueryGenerator|null $queryGenerator
* @param MetadataPool|null $metadataPool
+ * @param TableMaintainer|null $tableMaintainer
* @param CacheContext|null $cacheContext
* @param EventManagerInterface|null $eventManager
* @param IndexerRegistry|null $indexerRegistry
+ * @param WorkingStateProvider|null $workingStateProvider
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList) Preserve compatibility with the parent class
*/
public function __construct(
ResourceConnection $resource,
@@ -64,14 +75,18 @@ public function __construct(
Config $config,
QueryGenerator $queryGenerator = null,
MetadataPool $metadataPool = null,
+ ?TableMaintainer $tableMaintainer = null,
CacheContext $cacheContext = null,
EventManagerInterface $eventManager = null,
- IndexerRegistry $indexerRegistry = null
+ IndexerRegistry $indexerRegistry = null,
+ ?WorkingStateProvider $workingStateProvider = null
) {
- parent::__construct($resource, $storeManager, $config, $queryGenerator, $metadataPool);
+ parent::__construct($resource, $storeManager, $config, $queryGenerator, $metadataPool, $tableMaintainer);
$this->cacheContext = $cacheContext ?: ObjectManager::getInstance()->get(CacheContext::class);
$this->eventManager = $eventManager ?: ObjectManager::getInstance()->get(EventManagerInterface::class);
$this->indexerRegistry = $indexerRegistry ?: ObjectManager::getInstance()->get(IndexerRegistry::class);
+ $this->workingStateProvider = $workingStateProvider ?:
+ ObjectManager::getInstance()->get(WorkingStateProvider::class);
}
/**
@@ -82,6 +97,7 @@ public function __construct(
* @return $this
* @throws \Exception if metadataPool doesn't contain metadata for ProductInterface
* @throws \DomainException
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*/
public function execute(array $entityIds = [], $useTempTable = false)
{
@@ -90,46 +106,68 @@ public function execute(array $entityIds = [], $useTempTable = false)
$this->limitationByProducts = $idsToBeReIndexed;
$this->useTempTable = $useTempTable;
$indexer = $this->indexerRegistry->get(CategoryProductIndexer::INDEXER_ID);
- $workingState = $indexer->isWorking();
+ $workingState = $this->isWorkingState();
- $affectedCategories = $this->getCategoryIdsFromIndex($idsToBeReIndexed);
+ if (!$indexer->isScheduled()
+ || ($indexer->isScheduled() && !$useTempTable)
+ || ($indexer->isScheduled() && $useTempTable && !$workingState)) {
- if ($useTempTable && !$workingState && $indexer->isScheduled()) {
- foreach ($this->storeManager->getStores() as $store) {
- $this->connection->truncateTable($this->getIndexTable($store->getId()));
+ $affectedCategories = $this->getCategoryIdsFromIndex($idsToBeReIndexed);
+
+ if ($useTempTable && !$workingState && $indexer->isScheduled()) {
+ foreach ($this->storeManager->getStores() as $store) {
+ $this->connection->truncateTable($this->getIndexTable($store->getId()));
+ }
+ } else {
+ $this->removeEntries();
}
- } else {
- $this->removeEntries();
- }
- $this->reindex();
- if ($useTempTable && !$workingState && $indexer->isScheduled()) {
- foreach ($this->storeManager->getStores() as $store) {
- $this->connection->delete(
- $this->tableMaintainer->getMainTable($store->getId()),
- ['product_id IN (?)' => $this->limitationByProducts]
- );
- $select = $this->connection->select()
- ->from($this->tableMaintainer->getMainReplicaTable($store->getId()));
- $this->connection->query(
- $this->connection->insertFromSelect(
- $select,
+ $this->reindex();
+
+ // get actual state
+ $workingState = $this->isWorkingState();
+
+ if ($useTempTable && !$workingState && $indexer->isScheduled()) {
+ foreach ($this->storeManager->getStores() as $store) {
+ $this->connection->delete(
$this->tableMaintainer->getMainTable($store->getId()),
- [],
- AdapterInterface::INSERT_ON_DUPLICATE
- )
- );
+ ['product_id IN (?)' => $this->limitationByProducts]
+ );
+ $select = $this->connection->select()
+ ->from($this->tableMaintainer->getMainReplicaTable($store->getId()));
+ $this->connection->query(
+ $this->connection->insertFromSelect(
+ $select,
+ $this->tableMaintainer->getMainTable($store->getId()),
+ [],
+ AdapterInterface::INSERT_ON_DUPLICATE
+ )
+ );
+ }
}
- }
- $affectedCategories = array_merge($affectedCategories, $this->getCategoryIdsFromIndex($idsToBeReIndexed));
+ $affectedCategories = array_merge($affectedCategories, $this->getCategoryIdsFromIndex($idsToBeReIndexed));
- $this->registerProducts($idsToBeReIndexed);
- $this->registerCategories($affectedCategories);
- $this->eventManager->dispatch('clean_cache_by_tags', ['object' => $this->cacheContext]);
+ $this->registerProducts($idsToBeReIndexed);
+ $this->registerCategories($affectedCategories);
+ $this->eventManager->dispatch('clean_cache_by_tags', ['object' => $this->cacheContext]);
+ }
return $this;
}
+ /**
+ * Get state for current and shared indexer
+ *
+ * @return bool
+ */
+ private function isWorkingState() : bool
+ {
+ $indexer = $this->indexerRegistry->get(CategoryProductIndexer::INDEXER_ID);
+ $sharedIndexer = $this->indexerRegistry->get(ProductCategoryIndexer::INDEXER_ID);
+ return $this->workingStateProvider->isWorking($indexer->getId())
+ || $this->workingStateProvider->isWorking($sharedIndexer->getId());
+ }
+
/**
* Get IDs of parent products by their child IDs.
*
diff --git a/app/code/Magento/Catalog/Model/Product/Gallery/UpdateHandler.php b/app/code/Magento/Catalog/Model/Product/Gallery/UpdateHandler.php
index 8061422d84288..6a1392d776d31 100644
--- a/app/code/Magento/Catalog/Model/Product/Gallery/UpdateHandler.php
+++ b/app/code/Magento/Catalog/Model/Product/Gallery/UpdateHandler.php
@@ -77,26 +77,26 @@ protected function processDeletedImages($product, array &$images)
{
$filesToDelete = [];
$recordsToDelete = [];
- $picturesInOtherStores = [];
$imagesToDelete = [];
-
- foreach ($this->resourceModel->getProductImages($product, $this->extractStoreIds($product)) as $image) {
- $picturesInOtherStores[$image['filepath']] = true;
+ $imagesToNotDelete = [];
+ foreach ($images as $image) {
+ if (empty($image['removed'])) {
+ $imagesToNotDelete[] = $image['file'];
+ }
}
- foreach ($images as &$image) {
+ foreach ($images as $image) {
if (!empty($image['removed'])) {
if (!empty($image['value_id'])) {
if (preg_match('/\.\.(\\\|\/)/', $image['file'])) {
continue;
}
$recordsToDelete[] = $image['value_id'];
- $imagesToDelete[] = $image['file'];
- $catalogPath = $this->mediaConfig->getBaseMediaPath();
- $isFile = $this->mediaDirectory->isFile($catalogPath . $image['file']);
- // only delete physical files if they are not used by any other products and if this file exist
- if ($isFile && !($this->resourceModel->countImageUses($image['file']) > 1)) {
- $filesToDelete[] = ltrim($image['file'], '/');
+ if (!in_array($image['file'], $imagesToNotDelete)) {
+ $imagesToDelete[] = $image['file'];
+ if ($this->canDeleteImage($image['file'])) {
+ $filesToDelete[] = ltrim($image['file'], '/');
+ }
}
}
}
@@ -107,6 +107,19 @@ protected function processDeletedImages($product, array &$images)
$this->removeDeletedImages($filesToDelete);
}
+ /**
+ * Check if image exists and is not used by any other products
+ *
+ * @param string $file
+ * @return bool
+ */
+ private function canDeleteImage(string $file): bool
+ {
+ $catalogPath = $this->mediaConfig->getBaseMediaPath();
+ return $this->mediaDirectory->isFile($catalogPath . $file)
+ && $this->resourceModel->countImageUses($file) <= 1;
+ }
+
/**
* @inheritdoc
*
diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSetEnableQtyIncrementsActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSetEnableQtyIncrementsActionGroup.xml
new file mode 100644
index 0000000000000..2e211dad6dc81
--- /dev/null
+++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSetEnableQtyIncrementsActionGroup.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ Set "Enable Qty Increments" config in 'Advanced Inventory' panel on the Admin Product creation/edit page.
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSetQtyIncrementsForProductActionGroup.xml b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSetQtyIncrementsForProductActionGroup.xml
new file mode 100644
index 0000000000000..6ea82a2f2a490
--- /dev/null
+++ b/app/code/Magento/Catalog/Test/Mftf/ActionGroup/AdminSetQtyIncrementsForProductActionGroup.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ Fills in the "Qty Increments" option in 'Advanced Inventory' panel on the Admin Product creation/edit page.
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Action/RowsTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Action/RowsTest.php
new file mode 100644
index 0000000000000..f53b05a88c54f
--- /dev/null
+++ b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Category/Product/Action/RowsTest.php
@@ -0,0 +1,260 @@
+workingStateProvider = $this->getMockBuilder(WorkingStateProvider::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->resource = $this->getMockBuilder(ResourceConnection::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->connection = $this->getMockBuilder(AdapterInterface::class)
+ ->getMockForAbstractClass();
+ $this->resource->expects($this->any())
+ ->method('getConnection')
+ ->willReturn($this->connection);
+ $this->select = $this->getMockBuilder(Select::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->select->expects($this->any())
+ ->method('from')
+ ->willReturnSelf();
+ $this->select->expects($this->any())
+ ->method('where')
+ ->willReturnSelf();
+ $this->select->expects($this->any())
+ ->method('joinInner')
+ ->willReturnSelf();
+ $this->select->expects($this->any())
+ ->method('joinLeft')
+ ->willReturnSelf();
+ $this->select->expects($this->any())
+ ->method('columns')
+ ->willReturnSelf();
+ $this->connection->expects($this->any())
+ ->method('select')
+ ->willReturn($this->select);
+ $this->storeManager = $this->getMockBuilder(StoreManagerInterface::class)
+ ->getMockForAbstractClass();
+ $this->config = $this->getMockBuilder(Config::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->queryGenerator = $this->getMockBuilder(QueryGenerator::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->metadataPool = $this->getMockBuilder(MetadataPool::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->cacheContext = $this->getMockBuilder(CacheContext::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->eventManager = $this->getMockBuilder(EventManagerInterface::class)
+ ->getMockForAbstractClass();
+ $this->indexerRegistry = $this->getMockBuilder(IndexerRegistry::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->indexer = $this->getMockBuilder(IndexerInterface::class)
+ ->getMockForAbstractClass();
+ $this->tableMaintainer = $this->getMockBuilder(TableMaintainer::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $this->rowsModel = new Rows(
+ $this->resource,
+ $this->storeManager,
+ $this->config,
+ $this->queryGenerator,
+ $this->metadataPool,
+ $this->tableMaintainer,
+ $this->cacheContext,
+ $this->eventManager,
+ $this->indexerRegistry,
+ $this->workingStateProvider
+ );
+ }
+
+ public function testExecuteWithIndexerWorking() : void
+ {
+ $categoryId = '1';
+ $store = $this->getMockBuilder(Store::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $store->expects($this->any())
+ ->method('getRootCategoryId')
+ ->willReturn($categoryId);
+ $store->expects($this->any())
+ ->method('getId')
+ ->willReturn(1);
+
+ $attribute = $this->getMockBuilder(AbstractAttribute::class)
+ ->disableOriginalConstructor()
+ ->getMockForAbstractClass();
+ $this->config->expects($this->any())
+ ->method('getAttribute')
+ ->willReturn($attribute);
+
+ $table = $this->getMockBuilder(Table::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->connection->expects($this->any())
+ ->method('newTable')
+ ->willReturn($table);
+
+ $metadata = $this->getMockBuilder(EntityMetadataInterface::class)
+ ->getMockForAbstractClass();
+ $this->metadataPool->expects($this->any())
+ ->method('getMetadata')
+ ->willReturn($metadata);
+
+ $this->connection->expects($this->any())
+ ->method('fetchAll')
+ ->willReturn([]);
+
+ $this->connection->expects($this->any())
+ ->method('fetchOne')
+ ->willReturn($categoryId);
+ $this->indexerRegistry->expects($this->at(0))
+ ->method('get')
+ ->with(ProductCategoryIndexer::INDEXER_ID)
+ ->willReturn($this->indexer);
+ $this->indexerRegistry->expects($this->at(1))
+ ->method('get')
+ ->with(ProductCategoryIndexer::INDEXER_ID)
+ ->willReturn($this->indexer);
+ $this->indexerRegistry->expects($this->at(2))
+ ->method('get')
+ ->with(CategoryProductIndexer::INDEXER_ID)
+ ->willReturn($this->indexer);
+ $this->indexerRegistry->expects($this->at(3))
+ ->method('get')
+ ->with(ProductCategoryIndexer::INDEXER_ID)
+ ->willReturn($this->indexer);
+ $this->indexerRegistry->expects($this->at(4))
+ ->method('get')
+ ->with(CategoryProductIndexer::INDEXER_ID)
+ ->willReturn($this->indexer);
+ $this->indexer->expects($this->any())
+ ->method('getId')
+ ->willReturn(ProductCategoryIndexer::INDEXER_ID);
+ $this->workingStateProvider->expects($this->any())
+ ->method('isWorking')
+ ->with(ProductCategoryIndexer::INDEXER_ID)
+ ->willReturn(true);
+ $this->storeManager->expects($this->any())
+ ->method('getStores')
+ ->willReturn([$store]);
+
+ $this->connection->expects($this->once())
+ ->method('delete');
+
+ $result = $this->rowsModel->execute([1, 2, 3]);
+ $this->assertInstanceOf(Rows::class, $result);
+ }
+}
diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Category/Action/RowsTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Category/Action/RowsTest.php
new file mode 100644
index 0000000000000..66eb058c7b0a4
--- /dev/null
+++ b/app/code/Magento/Catalog/Test/Unit/Model/Indexer/Product/Category/Action/RowsTest.php
@@ -0,0 +1,269 @@
+workingStateProvider = $this->getMockBuilder(WorkingStateProvider::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->resource = $this->getMockBuilder(ResourceConnection::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->connection = $this->getMockBuilder(AdapterInterface::class)
+ ->getMockForAbstractClass();
+ $this->resource->expects($this->any())
+ ->method('getConnection')
+ ->willReturn($this->connection);
+ $this->select = $this->getMockBuilder(Select::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->select->expects($this->any())
+ ->method('from')
+ ->willReturnSelf();
+ $this->select->expects($this->any())
+ ->method('where')
+ ->willReturnSelf();
+ $this->select->expects($this->any())
+ ->method('distinct')
+ ->willReturnSelf();
+ $this->select->expects($this->any())
+ ->method('joinInner')
+ ->willReturnSelf();
+ $this->select->expects($this->any())
+ ->method('group')
+ ->willReturnSelf();
+ $this->select->expects($this->any())
+ ->method('joinLeft')
+ ->willReturnSelf();
+ $this->select->expects($this->any())
+ ->method('columns')
+ ->willReturnSelf();
+ $this->connection->expects($this->any())
+ ->method('select')
+ ->willReturn($this->select);
+ $this->storeManager = $this->getMockBuilder(StoreManagerInterface::class)
+ ->getMockForAbstractClass();
+ $this->config = $this->getMockBuilder(Config::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->queryGenerator = $this->getMockBuilder(QueryGenerator::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->metadataPool = $this->getMockBuilder(MetadataPool::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->cacheContext = $this->getMockBuilder(CacheContext::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->eventManager = $this->getMockBuilder(EventManagerInterface::class)
+ ->getMockForAbstractClass();
+ $this->indexerRegistry = $this->getMockBuilder(IndexerRegistry::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->indexer = $this->getMockBuilder(IndexerInterface::class)
+ ->getMockForAbstractClass();
+ $this->tableMaintainer = $this->getMockBuilder(TableMaintainer::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $this->rowsModel = new Rows(
+ $this->resource,
+ $this->storeManager,
+ $this->config,
+ $this->queryGenerator,
+ $this->metadataPool,
+ $this->tableMaintainer,
+ $this->cacheContext,
+ $this->eventManager,
+ $this->indexerRegistry,
+ $this->workingStateProvider
+ );
+ }
+
+ public function testExecuteWithIndexerWorking() : void
+ {
+ $categoryId = '1';
+ $store = $this->getMockBuilder(Store::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $store->expects($this->any())
+ ->method('getRootCategoryId')
+ ->willReturn($categoryId);
+ $store->expects($this->any())
+ ->method('getId')
+ ->willReturn(1);
+
+ $attribute = $this->getMockBuilder(AbstractAttribute::class)
+ ->disableOriginalConstructor()
+ ->getMockForAbstractClass();
+ $this->config->expects($this->any())
+ ->method('getAttribute')
+ ->willReturn($attribute);
+
+ $table = $this->getMockBuilder(Table::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->connection->expects($this->any())
+ ->method('newTable')
+ ->willReturn($table);
+
+ $metadata = $this->getMockBuilder(EntityMetadataInterface::class)
+ ->getMockForAbstractClass();
+ $this->metadataPool->expects($this->any())
+ ->method('getMetadata')
+ ->willReturn($metadata);
+
+ $this->connection->expects($this->any())
+ ->method('fetchAll')
+ ->willReturn([]);
+ $this->connection->expects($this->any())
+ ->method('fetchCol')
+ ->willReturn([]);
+
+ $this->connection->expects($this->any())
+ ->method('fetchOne')
+ ->willReturn($categoryId);
+ $this->indexerRegistry->expects($this->at(0))
+ ->method('get')
+ ->with(CategoryProductIndexer::INDEXER_ID)
+ ->willReturn($this->indexer);
+ $this->indexerRegistry->expects($this->at(1))
+ ->method('get')
+ ->with(CategoryProductIndexer::INDEXER_ID)
+ ->willReturn($this->indexer);
+ $this->indexerRegistry->expects($this->at(2))
+ ->method('get')
+ ->with(ProductCategoryIndexer::INDEXER_ID)
+ ->willReturn($this->indexer);
+ $this->indexerRegistry->expects($this->at(3))
+ ->method('get')
+ ->with(CategoryProductIndexer::INDEXER_ID)
+ ->willReturn($this->indexer);
+ $this->indexerRegistry->expects($this->at(4))
+ ->method('get')
+ ->with(ProductCategoryIndexer::INDEXER_ID)
+ ->willReturn($this->indexer);
+ $this->indexer->expects($this->any())
+ ->method('getId')
+ ->willReturn(CategoryProductIndexer::INDEXER_ID);
+ $this->workingStateProvider->expects($this->any())
+ ->method('isWorking')
+ ->with(CategoryProductIndexer::INDEXER_ID)
+ ->willReturn(true);
+ $this->storeManager->expects($this->any())
+ ->method('getStores')
+ ->willReturn([$store]);
+
+ $this->connection->expects($this->once())
+ ->method('delete');
+
+ $result = $this->rowsModel->execute([1, 2, 3]);
+ $this->assertInstanceOf(Rows::class, $result);
+ }
+}
diff --git a/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CurrencySymbolProviderTest.php b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CurrencySymbolProviderTest.php
new file mode 100644
index 0000000000000..07b3de40c31f8
--- /dev/null
+++ b/app/code/Magento/Catalog/Test/Unit/Ui/DataProvider/Product/Form/Modifier/CurrencySymbolProviderTest.php
@@ -0,0 +1,304 @@
+scopeConfigMock = $this->getMockForAbstractClass(
+ ScopeConfigInterface::class,
+ [],
+ '',
+ true,
+ true,
+ true,
+ ['getValue']
+ );
+ $this->storeManagerMock = $this->getMockForAbstractClass(
+ StoreManagerInterface::class,
+ [],
+ '',
+ true,
+ true,
+ true,
+ ['getWebsites']
+ );
+ $this->currentStoreMock = $this->getMockForAbstractClass(
+ StoreInterface::class,
+ [],
+ '',
+ true,
+ true,
+ true,
+ ['getBaseCurrency']
+ );
+ $this->currencyMock = $this->createMock(CurrencyModel::class);
+ $this->websiteCurrencyMock = $this->createMock(Zend_Currency::class);
+ $this->productMock = $this->createMock(Product::class);
+ $this->locatorMock = $this->getMockForAbstractClass(
+ LocatorInterface::class,
+ [],
+ '',
+ true,
+ true,
+ true,
+ ['getStore', 'getProduct']
+ );
+ $this->localeCurrencyMock = $this->getMockForAbstractClass(
+ CurrencyInterface::class,
+ [],
+ '',
+ true,
+ true,
+ true,
+ ['getWebsites', 'getCurrency']
+ );
+ $this->currencySymbolProvider = $objectManager->getObject(
+ CurrencySymbolProvider::class,
+ [
+ 'scopeConfig' => $this->scopeConfigMock,
+ 'storeManager' => $this->storeManagerMock,
+ 'locator' => $this->locatorMock,
+ 'localeCurrency' => $this->localeCurrencyMock
+ ]
+ );
+ }
+
+ /**
+ * Test for Get option array of currency symbol prefixes.
+ *
+ * @param int $catalogPriceScope
+ * @param string $defaultStoreCurrencySymbol
+ * @param array $listOfWebsites
+ * @param array $productWebsiteIds
+ * @param array $currencySymbols
+ * @param array $actualResult
+ * @dataProvider getWebsiteCurrencySymbolDataProvider
+ */
+ public function testGetCurrenciesPerWebsite(
+ int $catalogPriceScope,
+ string $defaultStoreCurrencySymbol,
+ array $listOfWebsites,
+ array $productWebsiteIds,
+ array $currencySymbols,
+ array $actualResult
+ ): void {
+ $this->locatorMock->expects($this->any())
+ ->method('getStore')
+ ->willReturn($this->currentStoreMock);
+ $this->currentStoreMock->expects($this->any())
+ ->method('getBaseCurrency')
+ ->willReturn($this->currencyMock);
+ $this->currencyMock->expects($this->any())
+ ->method('getCurrencySymbol')
+ ->willReturn($defaultStoreCurrencySymbol);
+ $this->scopeConfigMock
+ ->expects($this->any())
+ ->method('getValue')
+ ->willReturn($catalogPriceScope);
+ $this->locatorMock->expects($this->any())
+ ->method('getProduct')
+ ->willReturn($this->productMock);
+ $this->storeManagerMock->expects($this->any())
+ ->method('getWebsites')
+ ->willReturn($listOfWebsites);
+ $this->productMock->expects($this->any())
+ ->method('getWebsiteIds')
+ ->willReturn($productWebsiteIds);
+ $this->localeCurrencyMock->expects($this->any())
+ ->method('getCurrency')
+ ->willReturn($this->websiteCurrencyMock);
+ foreach ($currencySymbols as $currencySymbol) {
+ $this->websiteCurrencyMock->expects($this->any())
+ ->method('getSymbol')
+ ->willReturn($currencySymbol);
+ }
+ $expectedResult = $this->currencySymbolProvider
+ ->getCurrenciesPerWebsite();
+ $this->assertEquals($expectedResult, $actualResult);
+ }
+
+ /**
+ * DataProvider for getCurrenciesPerWebsite.
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @return array
+ */
+ public function getWebsiteCurrencySymbolDataProvider(): array
+ {
+ return [
+ 'verify website currency with default website and global price scope' => [
+ 'catalogPriceScope' => 0,
+ 'defaultStoreCurrencySymbol' => '$',
+ 'listOfWebsites' => $this->getWebsitesMock(
+ [
+ [
+ 'id' => '1',
+ 'name' => 'Main Website',
+ 'code' => 'main_website',
+ 'base_currency_code' => 'USD',
+ 'currency_symbol' => '$'
+ ]
+ ]
+ ),
+ 'productWebsiteIds' => ['1'],
+ 'currencySymbols' => ['$'],
+ 'actualResult' => ['$']
+ ],
+ 'verify website currency with default website and website price scope' => [
+ 'catalogPriceScope' => 1,
+ 'defaultStoreCurrencySymbol' => '$',
+ 'listOfWebsites' => $this->getWebsitesMock(
+ [
+ [
+ 'id' => '1',
+ 'name' => 'Main Website',
+ 'code' => 'main_website',
+ 'base_currency_code' => 'USD',
+ 'currency_symbol' => '$'
+ ]
+ ]
+ ),
+ 'productWebsiteIds' => ['1'],
+ 'currencySymbols' => ['$'],
+ 'actualResult' => ['$', '$']
+ ],
+ 'verify website currency with two website and website price scope' => [
+ 'catalogPriceScope' => 1,
+ 'defaultStoreCurrencySymbol' => '$',
+ 'listOfWebsites' => $this->getWebsitesMock(
+ [
+ [
+ 'id' => '1',
+ 'name' => 'Main Website',
+ 'code' => 'main_website',
+ 'base_currency_code' => 'USD',
+ 'currency_symbol' => '$'
+ ],
+ [
+ 'id' => '2',
+ 'name' => 'Indian Website',
+ 'code' => 'indian_website',
+ 'base_currency_code' => 'INR',
+ 'currency_symbol' => '₹'
+ ]
+ ]
+ ),
+ 'productWebsiteIds' => ['1', '2'],
+ 'currencySymbols' => ['$', '₹'],
+ 'actualResult' => ['$', '$', '$']
+ ]
+ ];
+ }
+
+ /**
+ * Get list of websites mock
+ *
+ * @param array $websites
+ * @return array
+ */
+ private function getWebsitesMock(array $websites): array
+ {
+ $websitesMock = [];
+ foreach ($websites as $key => $website) {
+ $websitesMock[$key] = $this->getMockForAbstractClass(
+ WebsiteInterface::class,
+ [],
+ '',
+ true,
+ true,
+ true,
+ ['getId', 'getBaseCurrencyCode']
+ );
+ $websitesMock[$key]->expects($this->any())
+ ->method('getId')
+ ->willReturn($website['id']);
+ $websitesMock[$key]->expects($this->any())
+ ->method('getBaseCurrencyCode')
+ ->willReturn($website['base_currency_code']);
+ }
+ return $websitesMock;
+ }
+
+ protected function tearDown(): void
+ {
+ unset($this->scopeConfigMock);
+ unset($this->storeManagerMock);
+ unset($this->currentStoreMock);
+ unset($this->currencyMock);
+ unset($this->websiteCurrencyMock);
+ unset($this->productMock);
+ unset($this->locatorMock);
+ unset($this->localeCurrencyMock);
+ }
+}
diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php
index 174a01b72a109..8c9421b073394 100644
--- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php
+++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/AdvancedPricing.php
@@ -100,6 +100,11 @@ class AdvancedPricing extends AbstractModifier
*/
private $customerGroupSource;
+ /**
+ * @var CurrencySymbolProvider
+ */
+ private $currencySymbolProvider;
+
/**
* @param LocatorInterface $locator
* @param StoreManagerInterface $storeManager
@@ -110,7 +115,8 @@ class AdvancedPricing extends AbstractModifier
* @param Data $directoryHelper
* @param ArrayManager $arrayManager
* @param string $scopeName
- * @param GroupSourceInterface $customerGroupSource
+ * @param GroupSourceInterface|null $customerGroupSource
+ * @param CurrencySymbolProvider|null $currencySymbolProvider
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
*/
public function __construct(
@@ -123,7 +129,8 @@ public function __construct(
Data $directoryHelper,
ArrayManager $arrayManager,
$scopeName = '',
- GroupSourceInterface $customerGroupSource = null
+ GroupSourceInterface $customerGroupSource = null,
+ ?CurrencySymbolProvider $currencySymbolProvider = null
) {
$this->locator = $locator;
$this->storeManager = $storeManager;
@@ -136,6 +143,8 @@ public function __construct(
$this->scopeName = $scopeName;
$this->customerGroupSource = $customerGroupSource
?: ObjectManager::getInstance()->get(GroupSourceInterface::class);
+ $this->currencySymbolProvider = $currencySymbolProvider
+ ?: ObjectManager::getInstance()->get(CurrencySymbolProvider::class);
}
/**
@@ -488,6 +497,7 @@ private function getTierPriceStructure($tierPricePath)
'arguments' => [
'data' => [
'config' => [
+ 'component' => 'Magento_Catalog/js/components/website-currency-symbol',
'dataType' => Text::NAME,
'formElement' => Select::NAME,
'componentType' => Field::NAME,
@@ -498,6 +508,10 @@ private function getTierPriceStructure($tierPricePath)
'visible' => $this->isMultiWebsites(),
'disabled' => ($this->isShowWebsiteColumn() && !$this->isAllowChangeWebsite()),
'sortOrder' => 10,
+ 'currenciesForWebsites' => $this->currencySymbolProvider
+ ->getCurrenciesPerWebsite(),
+ 'currency' => $this->currencySymbolProvider
+ ->getDefaultCurrency(),
],
],
],
@@ -548,9 +562,6 @@ private function getTierPriceStructure($tierPricePath)
'label' => __('Price'),
'enableLabel' => true,
'dataScope' => 'price',
- 'addbefore' => $this->locator->getStore()
- ->getBaseCurrency()
- ->getCurrencySymbol(),
'sortOrder' => 40,
'validation' => [
'required-entry' => true,
@@ -559,8 +570,12 @@ private function getTierPriceStructure($tierPricePath)
],
'imports' => [
'priceValue' => '${ $.provider }:data.product.price',
- '__disableTmpl' => ['priceValue' => false],
+ '__disableTmpl' => ['priceValue' => false, 'addbefore' => false],
+ 'addbefore' => '${ $.parentName }:currency'
],
+ 'tracks' => [
+ 'addbefore' => true
+ ]
],
],
],
diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/CurrencySymbolProvider.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/CurrencySymbolProvider.php
new file mode 100644
index 0000000000000..b46ca682e576a
--- /dev/null
+++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/CurrencySymbolProvider.php
@@ -0,0 +1,139 @@
+scopeConfig = $scopeConfig;
+ $this->storeManager = $storeManager;
+ $this->locator = $locator;
+ $this->localeCurrency = $localeCurrency;
+ }
+
+ /**
+ * Get option array of currency symbol prefixes.
+ *
+ * @return array
+ */
+ public function getCurrenciesPerWebsite(): array
+ {
+ $baseCurrency = $this->locator->getStore()
+ ->getBaseCurrency();
+ $websitesCurrencySymbol[0] = $baseCurrency->getCurrencySymbol() ??
+ $baseCurrency->getCurrencyCode();
+ $catalogPriceScope = $this->getCatalogPriceScope();
+ $product = $this->locator->getProduct();
+ $websitesList = $this->storeManager->getWebsites();
+ $productWebsiteIds = $product->getWebsiteIds();
+ if ($catalogPriceScope!=0) {
+ foreach ($websitesList as $website) {
+ /** @var Website $website */
+ if (!in_array($website->getId(), $productWebsiteIds)) {
+ continue;
+ }
+ $websitesCurrencySymbol[$website->getId()] = $this
+ ->getCurrencySymbol(
+ $website->getBaseCurrencyCode()
+ );
+ }
+ }
+ return $websitesCurrencySymbol;
+ }
+
+ /**
+ * Get default store currency symbol
+ *
+ * @return string
+ */
+ public function getDefaultCurrency(): string
+ {
+ $baseCurrency = $this->locator->getStore()
+ ->getBaseCurrency();
+ return $baseCurrency->getCurrencySymbol() ??
+ $baseCurrency->getCurrencyCode();
+ }
+
+ /**
+ * Get catalog price scope from the admin config
+ *
+ * @return int
+ */
+ public function getCatalogPriceScope(): int
+ {
+ return (int) $this->scopeConfig->getValue(
+ Store::XML_PATH_PRICE_SCOPE,
+ ScopeInterface::SCOPE_WEBSITE
+ );
+ }
+
+ /**
+ * Retrieve currency name by code
+ *
+ * @param string $code
+ * @return string
+ */
+ private function getCurrencySymbol(string $code): string
+ {
+ $currency = $this->localeCurrency->getCurrency($code);
+ return $currency->getSymbol() ?
+ $currency->getSymbol() : $currency->getShortName();
+ }
+}
diff --git a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/TierPrice.php b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/TierPrice.php
index e9e8229e581ba..25e04302bd33c 100644
--- a/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/TierPrice.php
+++ b/app/code/Magento/Catalog/Ui/DataProvider/Product/Form/Modifier/TierPrice.php
@@ -118,6 +118,10 @@ private function getUpdatedTierPriceStructure(array $priceMeta)
'showLabel' => false,
'dataScope' => '',
'additionalClasses' => 'control-grouped',
+ 'imports' => [
+ 'currency' => '${ $.parentName }.website_id:currency',
+ '__disableTmpl' => ['currency' => false],
+ ],
'sortOrder' => isset($priceMeta['arguments']['data']['config']['sortOrder'])
? $priceMeta['arguments']['data']['config']['sortOrder'] : 40,
],
diff --git a/app/code/Magento/Catalog/view/adminhtml/web/js/components/website-currency-symbol.js b/app/code/Magento/Catalog/view/adminhtml/web/js/components/website-currency-symbol.js
new file mode 100644
index 0000000000000..069bd9baed86f
--- /dev/null
+++ b/app/code/Magento/Catalog/view/adminhtml/web/js/components/website-currency-symbol.js
@@ -0,0 +1,30 @@
+/**
+ * Copyright © Magento, Inc. All rights reserved.
+ * See COPYING.txt for license details.
+ */
+
+define([
+ 'Magento_Ui/js/form/element/select'
+], function (Select) {
+ 'use strict';
+
+ return Select.extend({
+ defaults: {
+ currenciesForWebsites: {},
+ tracks: {
+ currency: true
+ }
+ },
+
+ /**
+ * Set currency symbol per website
+ *
+ * @param {String} value - currency symbol
+ */
+ setDifferedFromDefault: function (value) {
+ this.currency = this.currenciesForWebsites[value];
+
+ return this._super();
+ }
+ });
+});
diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/image.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/image.phtml
index 98d17045a1b2d..86b332679bcb4 100644
--- a/app/code/Magento/Catalog/view/frontend/templates/product/image.phtml
+++ b/app/code/Magento/Catalog/view/frontend/templates/product/image.phtml
@@ -11,7 +11,7 @@
getCustomAttributes() as $name => $value): ?>
- = $escaper->escapeHtmlAttr($name) ?>="= $escaper->escapeHtmlAttr($value) ?>"
+ = $escaper->escapeHtmlAttr($name) ?>="= $escaper->escapeHtml($value) ?>"
src="= $escaper->escapeUrl($block->getImageUrl()) ?>"
loading="lazy"
diff --git a/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml b/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml
index 8abfe368909e4..cc1a7276c70b8 100644
--- a/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml
+++ b/app/code/Magento/Catalog/view/frontend/templates/product/image_with_borders.phtml
@@ -15,7 +15,7 @@ $paddingBottom = $block->getRatio() * 100;
getCustomAttributes() as $name => $value): ?>
- = $escaper->escapeHtmlAttr($name) ?>="= $escaper->escapeHtmlAttr($value) ?>"
+ = $escaper->escapeHtmlAttr($name) ?>="= $escaper->escapeHtml($value) ?>"
src="= $escaper->escapeUrl($block->getImageUrl()) ?>"
loading="lazy"
diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Test/StorefrontValidateQuantityIncrementsWithDecimalInventoryTest.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Test/StorefrontValidateQuantityIncrementsWithDecimalInventoryTest.xml
new file mode 100644
index 0000000000000..e17c8fe65d4cf
--- /dev/null
+++ b/app/code/Magento/CatalogInventory/Test/Mftf/Test/StorefrontValidateQuantityIncrementsWithDecimalInventoryTest.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/ConfigurableProduct/Plugin/Model/Order/Invoice/UpdateConfigurableProductTotalQty.php b/app/code/Magento/ConfigurableProduct/Plugin/Model/Order/Invoice/UpdateConfigurableProductTotalQty.php
new file mode 100644
index 0000000000000..28237ca71b07a
--- /dev/null
+++ b/app/code/Magento/ConfigurableProduct/Plugin/Model/Order/Invoice/UpdateConfigurableProductTotalQty.php
@@ -0,0 +1,43 @@
+getOrder();
+ $productTotalQty = 0;
+ $hasConfigurableProduct = false;
+ foreach ($order->getAllItems() as $orderItem) {
+ if ($orderItem->getParentItemId() === null &&
+ $orderItem->getProductType() == Configurable::TYPE_CODE
+ ) {
+ $hasConfigurableProduct = true;
+ continue;
+ }
+ $productTotalQty += (float) $orderItem->getQtyOrdered();
+ }
+ return $hasConfigurableProduct ? $productTotalQty : $totalQty;
+ }
+}
diff --git a/app/code/Magento/ConfigurableProduct/Test/Unit/Plugin/Model/Order/Invoice/UpdateConfigurableProductTotalQtyTest.php b/app/code/Magento/ConfigurableProduct/Test/Unit/Plugin/Model/Order/Invoice/UpdateConfigurableProductTotalQtyTest.php
new file mode 100644
index 0000000000000..bff629fd94ac2
--- /dev/null
+++ b/app/code/Magento/ConfigurableProduct/Test/Unit/Plugin/Model/Order/Invoice/UpdateConfigurableProductTotalQtyTest.php
@@ -0,0 +1,182 @@
+invoiceMock = $this->createMock(Invoice::class);
+ $this->orderMock = $this->createMock(Order::class);
+ $this->orderItemsMock = $this->getMockBuilder(Item::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->objectManagerHelper = new ObjectManagerHelper($this);
+ $this->model = $this->objectManagerHelper->getObject(
+ UpdateConfigurableProductTotalQty::class,
+ []
+ );
+ }
+
+ /**
+ * Test Set total quantity for configurable product invoice
+ *
+ * @param array $orderItems
+ * @param float $totalQty
+ * @param float $productTotalQty
+ * @dataProvider getOrdersForConfigurableProducts
+ */
+ public function testBeforeSetTotalQty(
+ array $orderItems,
+ float $totalQty,
+ float $productTotalQty
+ ): void {
+ $this->invoiceMock->expects($this->any())
+ ->method('getOrder')
+ ->willReturn($this->orderMock);
+ $this->orderMock->expects($this->any())
+ ->method('getAllItems')
+ ->willReturn($orderItems);
+ $expectedQty= $this->model->beforeSetTotalQty($this->invoiceMock, $totalQty);
+ $this->assertEquals($expectedQty, $productTotalQty);
+ }
+
+ /**
+ * DataProvider for beforeSetTotalQty.
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @return array
+ */
+ public function getOrdersForConfigurableProducts(): array
+ {
+
+ return [
+ 'verify productQty for simple products' => [
+ 'orderItems' => $this->getOrderItems(
+ [
+ [
+ 'parent_item_id' => null,
+ 'product_type' => 'simple',
+ 'qty_ordered' => 10
+ ]
+ ]
+ ),
+ 'totalQty' => 10.00,
+ 'productTotalQty' => 10.00
+ ],
+ 'verify productQty for configurable products' => [
+ 'orderItems' => $this->getOrderItems(
+ [
+ [
+ 'parent_item_id' => '2',
+ 'product_type' => Configurable::TYPE_CODE,
+ 'qty_ordered' => 10
+ ]
+ ]
+ ),
+ 'totalQty' => 10.00,
+ 'productTotalQty' => 10.00
+ ],
+ 'verify productQty for simple configurable products' => [
+ 'orderItems' => $this->getOrderItems(
+ [
+ [
+ 'parent_item_id' => null,
+ 'product_type' => 'simple',
+ 'qty_ordered' => 10
+ ],
+ [
+ 'parent_item_id' => '2',
+ 'product_type' => Configurable::TYPE_CODE,
+ 'qty_ordered' => 10
+ ],
+ [
+ 'parent_item_id' => '2',
+ 'product_type' => Bundle::TYPE_CODE,
+ 'qty_ordered' => 10
+ ]
+ ]
+ ),
+ 'totalQty' => 30.00,
+ 'productTotalQty' => 30.00
+ ]
+ ];
+ }
+
+ /**
+ * Get Order Items.
+ *
+ * @param array $orderItems
+ * @return array
+ */
+ public function getOrderItems(array $orderItems): array
+ {
+ $orderItemsMock = [];
+ foreach ($orderItems as $key => $orderItem) {
+ $orderItemsMock[$key] = $this->getMockBuilder(Item::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $orderItemsMock[$key]->expects($this->any())
+ ->method('getParentItemId')
+ ->willReturn($orderItem['parent_item_id']);
+ $orderItemsMock[$key]->expects($this->any())
+ ->method('getProductType')
+ ->willReturn($orderItem['product_type']);
+ $orderItemsMock[$key]->expects($this->any())
+ ->method('getQtyOrdered')
+ ->willReturn($orderItem['qty_ordered']);
+ }
+ return $orderItemsMock;
+ }
+
+ protected function tearDown(): void
+ {
+ unset($this->invoiceMock);
+ unset($this->orderMock);
+ unset($this->orderItemsMock);
+ }
+}
diff --git a/app/code/Magento/ConfigurableProduct/etc/adminhtml/di.xml b/app/code/Magento/ConfigurableProduct/etc/adminhtml/di.xml
index de6765138fce6..60ad9e03fc17e 100644
--- a/app/code/Magento/ConfigurableProduct/etc/adminhtml/di.xml
+++ b/app/code/Magento/ConfigurableProduct/etc/adminhtml/di.xml
@@ -78,4 +78,7 @@
+
+
+
diff --git a/app/code/Magento/DownloadableImportExport/Model/Export/RowCustomizer.php b/app/code/Magento/DownloadableImportExport/Model/Export/RowCustomizer.php
index 3b805fef73434..5dc98f2d150f4 100644
--- a/app/code/Magento/DownloadableImportExport/Model/Export/RowCustomizer.php
+++ b/app/code/Magento/DownloadableImportExport/Model/Export/RowCustomizer.php
@@ -82,7 +82,9 @@ public function prepareData($collection, $productIds): void
->addAttributeToSelect('samples_title');
// set global scope during export
$this->storeManager->setCurrentStore(Store::DEFAULT_STORE_ID);
- foreach ($productCollection as $product) {
+
+ while ($product = $productCollection->fetchItem()) {
+ /** @var $product \Magento\Catalog\Api\Data\ProductInterface */
$productLinks = $this->linkRepository->getLinksByProduct($product);
$productSamples = $this->sampleRepository->getSamplesByProduct($product);
$this->downloadableData[$product->getId()] = [];
diff --git a/app/code/Magento/DownloadableImportExport/Test/Unit/Model/Export/Product/RowCustomizerTest.php b/app/code/Magento/DownloadableImportExport/Test/Unit/Model/Export/Product/RowCustomizerTest.php
deleted file mode 100644
index 110451aa19f1a..0000000000000
--- a/app/code/Magento/DownloadableImportExport/Test/Unit/Model/Export/Product/RowCustomizerTest.php
+++ /dev/null
@@ -1,85 +0,0 @@
-linkRepository = $this->createMock(LinkRepository::class);
- $this->sampleRepository = $this->createMock(SampleRepository::class);
- $this->storeManager = $this->createMock(StoreManagerInterface::class);
-
- $this->rowCustomizer = new RowCustomizer(
- $this->storeManager,
- $this->linkRepository,
- $this->sampleRepository
- );
- }
-
- /**
- * Test to Prepare downloadable data for export
- */
- public function testPrepareData()
- {
- $productIds = [1, 2, 3];
- $collection = $this->createMock(ProductCollection::class);
- $collection->expects($this->at(0))
- ->method('addAttributeToFilter')
- ->with('entity_id', ['in' => $productIds])
- ->willReturnSelf();
- $collection->expects($this->at(1))
- ->method('addAttributeToFilter')
- ->with('type_id', ['eq' => Type::TYPE_DOWNLOADABLE])
- ->willReturnSelf();
- $collection->method('addAttributeToSelect')->willReturnSelf();
- $collection->method('getIterator')->willReturn(new \ArrayIterator([]));
-
- $this->storeManager->expects($this->once())
- ->method('setCurrentStore')
- ->with(Store::DEFAULT_STORE_ID);
-
- $this->rowCustomizer->prepareData($collection, $productIds);
- }
-}
diff --git a/app/code/Magento/DownloadableImportExport/Test/Unit/Model/Export/RowCustomizerTest.php b/app/code/Magento/DownloadableImportExport/Test/Unit/Model/Export/RowCustomizerTest.php
new file mode 100644
index 0000000000000..f36676c1a8749
--- /dev/null
+++ b/app/code/Magento/DownloadableImportExport/Test/Unit/Model/Export/RowCustomizerTest.php
@@ -0,0 +1,113 @@
+storeManagerMock = $this->getMockBuilder(StoreManagerInterface::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $this->linkRepositoryMock = $this->getMockBuilder(LinkRepository::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $this->sampleRepositoryMock = $this->getMockBuilder(SampleRepository::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $objectManagerHelper = new ObjectManagerHelper($this);
+ $this->model = $objectManagerHelper->getObject(
+ \Magento\DownloadableImportExport\Model\Export\RowCustomizer::class,
+ [
+ 'storeManager' => $this->storeManagerMock,
+ 'linkRepository' => $this->linkRepositoryMock,
+ 'sampleRepository' => $this->sampleRepositoryMock,
+ ]
+ );
+ }
+
+ /**
+ * Test Prepare configurable data for export
+ */
+ public function testPrepareData()
+ {
+ $product1 = $this->getMockBuilder(ProductInterface::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $product1->expects($this->any())
+ ->method('getId')
+ ->willReturn(1);
+ $product2 = $this->getMockBuilder(ProductInterface::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $product2->expects($this->any())
+ ->method('getId')
+ ->willReturn(2);
+ $collection = $this->getMockBuilder(Collection::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $collection->expects($this->atLeastOnce())
+ ->method('fetchItem')
+ ->willReturn($product1, $product2);
+
+ $collection->expects($this->exactly(2))
+ ->method('addAttributeToFilter')
+ ->willReturnSelf();
+ $collection->expects($this->exactly(2))
+ ->method('addAttributeToSelect')
+ ->willReturnSelf();
+ $this->linkRepositoryMock->expects($this->exactly(2))
+ ->method('getLinksByProduct')
+ ->will($this->returnValue([]));
+ $this->sampleRepositoryMock->expects($this->exactly(2))
+ ->method('getSamplesByProduct')
+ ->will($this->returnValue([]));
+
+ $this->model->prepareData($collection, []);
+ }
+}
diff --git a/app/code/Magento/Indexer/Model/Indexer.php b/app/code/Magento/Indexer/Model/Indexer.php
index 2821a46f29416..ac8b9590e58f4 100644
--- a/app/code/Magento/Indexer/Model/Indexer.php
+++ b/app/code/Magento/Indexer/Model/Indexer.php
@@ -13,6 +13,7 @@
use Magento\Framework\Indexer\IndexStructureInterface;
use Magento\Framework\Indexer\StateInterface;
use Magento\Framework\Indexer\StructureFactory;
+use Magento\Framework\Indexer\IndexerInterfaceFactory;
/**
* Indexer model.
@@ -61,6 +62,16 @@ class Indexer extends \Magento\Framework\DataObject implements IndexerInterface
*/
protected $indexersFactory;
+ /**
+ * @var WorkingStateProvider
+ */
+ private $workingStateProvider;
+
+ /**
+ * @var IndexerInterfaceFactory
+ */
+ private $indexerFactory;
+
/**
* @param ConfigInterface $config
* @param ActionFactory $actionFactory
@@ -68,6 +79,8 @@ class Indexer extends \Magento\Framework\DataObject implements IndexerInterface
* @param \Magento\Framework\Mview\ViewInterface $view
* @param Indexer\StateFactory $stateFactory
* @param Indexer\CollectionFactory $indexersFactory
+ * @param WorkingStateProvider $workingStateProvider
+ * @param IndexerInterfaceFactory $indexerFactory
* @param array $data
*/
public function __construct(
@@ -77,6 +90,8 @@ public function __construct(
\Magento\Framework\Mview\ViewInterface $view,
Indexer\StateFactory $stateFactory,
Indexer\CollectionFactory $indexersFactory,
+ WorkingStateProvider $workingStateProvider,
+ IndexerInterfaceFactory $indexerFactory,
array $data = []
) {
$this->config = $config;
@@ -85,6 +100,8 @@ public function __construct(
$this->view = $view;
$this->stateFactory = $stateFactory;
$this->indexersFactory = $indexersFactory;
+ $this->workingStateProvider = $workingStateProvider;
+ $this->indexerFactory = $indexerFactory;
parent::__construct($data);
}
@@ -405,10 +422,20 @@ protected function getStructureInstance()
*/
public function reindexAll()
{
- if ($this->getState()->getStatus() != StateInterface::STATUS_WORKING) {
+ if (!$this->workingStateProvider->isWorking($this->getId())) {
$state = $this->getState();
$state->setStatus(StateInterface::STATUS_WORKING);
$state->save();
+
+ $sharedIndexers = [];
+ $indexerConfig = $this->config->getIndexer($this->getId());
+ if ($indexerConfig['shared_index'] !== null) {
+ $sharedIndexers = $this->getSharedIndexers($indexerConfig['shared_index']);
+ }
+ if (!empty($sharedIndexers)) {
+ $this->suspendSharedViews($sharedIndexers);
+ }
+
if ($this->getView()->isEnabled()) {
$this->getView()->suspend();
}
@@ -416,16 +443,73 @@ public function reindexAll()
$this->getActionInstance()->executeFull();
$state->setStatus(StateInterface::STATUS_VALID);
$state->save();
+ if (!empty($sharedIndexers)) {
+ $this->resumeSharedViews($sharedIndexers);
+ }
$this->getView()->resume();
} catch (\Throwable $exception) {
$state->setStatus(StateInterface::STATUS_INVALID);
$state->save();
+ if (!empty($sharedIndexers)) {
+ $this->resumeSharedViews($sharedIndexers);
+ }
$this->getView()->resume();
throw $exception;
}
}
}
+ /**
+ * Get indexer ids that uses same index
+ *
+ * @param string $sharedIndex
+ * @return array
+ */
+ private function getSharedIndexers(string $sharedIndex) : array
+ {
+ $result = [];
+ foreach (array_keys($this->config->getIndexers()) as $indexerId) {
+ if ($indexerId === $this->getId()) {
+ continue;
+ }
+ $indexerConfig = $this->config->getIndexer($indexerId);
+ if ($indexerConfig['shared_index'] === $sharedIndex) {
+ $indexer = $this->indexerFactory->create();
+ $indexer->load($indexerId);
+ $result[] = $indexer;
+ }
+ }
+ return $result;
+ }
+
+ /**
+ * Suspend views of shared indexers
+ *
+ * @param array $sharedIndexers
+ * @return void
+ */
+ private function suspendSharedViews(array $sharedIndexers) : void
+ {
+ foreach ($sharedIndexers as $indexer) {
+ if ($indexer->getView()->isEnabled()) {
+ $indexer->getView()->suspend();
+ }
+ }
+ }
+
+ /**
+ * Suspend views of shared indexers
+ *
+ * @param array $sharedIndexers
+ * @return void
+ */
+ private function resumeSharedViews(array $sharedIndexers) : void
+ {
+ foreach ($sharedIndexers as $indexer) {
+ $indexer->getView()->resume();
+ }
+ }
+
/**
* Regenerate one row in index by ID
*
diff --git a/app/code/Magento/Indexer/Model/WorkingStateProvider.php b/app/code/Magento/Indexer/Model/WorkingStateProvider.php
new file mode 100644
index 0000000000000..d77c1b67ecfd7
--- /dev/null
+++ b/app/code/Magento/Indexer/Model/WorkingStateProvider.php
@@ -0,0 +1,45 @@
+stateFactory = $stateFactory;
+ }
+
+ /**
+ * Execute user functions
+ *
+ * @param string $indexerId
+ * @return bool
+ */
+ public function isWorking(string $indexerId) : bool
+ {
+ $state = $this->stateFactory->create();
+ $state->loadByIndexer($indexerId);
+
+ return $state->getStatus() === StateInterface::STATUS_WORKING;
+ }
+}
diff --git a/app/code/Magento/Indexer/Test/Unit/Model/IndexerTest.php b/app/code/Magento/Indexer/Test/Unit/Model/IndexerTest.php
index 662856e2187d5..bcdfbea78b0b3 100644
--- a/app/code/Magento/Indexer/Test/Unit/Model/IndexerTest.php
+++ b/app/code/Magento/Indexer/Test/Unit/Model/IndexerTest.php
@@ -12,11 +12,13 @@
use Magento\Framework\Indexer\ConfigInterface;
use Magento\Framework\Indexer\StateInterface;
use Magento\Framework\Indexer\StructureFactory;
+use Magento\Framework\Indexer\IndexerInterfaceFactory;
use Magento\Framework\Mview\ViewInterface;
use Magento\Indexer\Model\Indexer;
use Magento\Indexer\Model\Indexer\CollectionFactory;
use Magento\Indexer\Model\Indexer\State;
use Magento\Indexer\Model\Indexer\StateFactory;
+use Magento\Indexer\Model\WorkingStateProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
@@ -55,8 +57,21 @@ class IndexerTest extends TestCase
*/
protected $indexFactoryMock;
+ /**
+ * @var WorkingStateProvider|MockObject
+ */
+ private $workingStateProvider;
+
+ /**
+ * @var IndexerInterfaceFactory|MockObject
+ */
+ private $indexerFactoryMock;
+
protected function setUp(): void
{
+ $this->workingStateProvider = $this->getMockBuilder(WorkingStateProvider::class)
+ ->disableOriginalConstructor()
+ ->getMock();
$this->configMock = $this->getMockForAbstractClass(
ConfigInterface::class,
[],
@@ -70,6 +85,10 @@ protected function setUp(): void
ActionFactory::class,
['create']
);
+ $this->indexerFactoryMock = $this->createPartialMock(
+ IndexerInterfaceFactory::class,
+ ['create']
+ );
$this->viewMock = $this->getMockForAbstractClass(
ViewInterface::class,
[],
@@ -99,7 +118,9 @@ protected function setUp(): void
$structureFactory,
$this->viewMock,
$this->stateFactoryMock,
- $this->indexFactoryMock
+ $this->indexFactoryMock,
+ $this->workingStateProvider,
+ $this->indexerFactoryMock
);
}
@@ -211,7 +232,7 @@ public function testReindexAll()
$stateMock->expects($this->never())->method('setIndexerId');
$stateMock->expects($this->once())->method('getId')->willReturn(1);
$stateMock->expects($this->exactly(2))->method('setStatus')->willReturnSelf();
- $stateMock->expects($this->once())->method('getStatus')->willReturn('idle');
+ $stateMock->expects($this->any())->method('getStatus')->willReturn('idle');
$stateMock->expects($this->exactly(2))->method('save')->willReturnSelf();
$this->stateFactoryMock->expects($this->once())->method('create')->willReturn($stateMock);
@@ -251,7 +272,7 @@ public function testReindexAllWithException()
$stateMock->expects($this->never())->method('setIndexerId');
$stateMock->expects($this->once())->method('getId')->willReturn(1);
$stateMock->expects($this->exactly(2))->method('setStatus')->willReturnSelf();
- $stateMock->expects($this->once())->method('getStatus')->willReturn('idle');
+ $stateMock->expects($this->any())->method('getStatus')->willReturn('idle');
$stateMock->expects($this->exactly(2))->method('save')->willReturnSelf();
$this->stateFactoryMock->expects($this->once())->method('create')->willReturn($stateMock);
@@ -296,7 +317,7 @@ public function testReindexAllWithError()
$stateMock->expects($this->never())->method('setIndexerId');
$stateMock->expects($this->once())->method('getId')->willReturn(1);
$stateMock->expects($this->exactly(2))->method('setStatus')->willReturnSelf();
- $stateMock->expects($this->once())->method('getStatus')->willReturn('idle');
+ $stateMock->expects($this->any())->method('getStatus')->willReturn('idle');
$stateMock->expects($this->exactly(2))->method('save')->willReturnSelf();
$this->stateFactoryMock->expects($this->once())->method('create')->willReturn($stateMock);
@@ -336,7 +357,8 @@ protected function getIndexerData()
'view_id' => 'view_test',
'action_class' => 'Some\Class\Name',
'title' => 'Indexer public name',
- 'description' => 'Indexer public description'
+ 'description' => 'Indexer public description',
+ 'shared_index' => null
];
}
@@ -346,7 +368,7 @@ protected function getIndexerData()
protected function loadIndexer($indexId)
{
$this->configMock->expects(
- $this->once()
+ $this->any()
)->method(
'getIndexer'
)->with(
diff --git a/app/code/Magento/Indexer/Test/Unit/Model/ProcessorTest.php b/app/code/Magento/Indexer/Test/Unit/Model/ProcessorTest.php
index 9f9b4c2157bb7..bbb74812d99a3 100644
--- a/app/code/Magento/Indexer/Test/Unit/Model/ProcessorTest.php
+++ b/app/code/Magento/Indexer/Test/Unit/Model/ProcessorTest.php
@@ -73,11 +73,19 @@ protected function setUp(): void
'',
false
);
+
+ $indexerRegistryMock = $this->getIndexRegistryMock([]);
+ $makeSharedValidMock = new MakeSharedIndexValid(
+ $this->configMock,
+ $indexerRegistryMock
+ );
+
$this->model = new Processor(
$this->configMock,
$this->indexerFactoryMock,
$this->indexersFactoryMock,
- $this->viewProcessorMock
+ $this->viewProcessorMock,
+ $makeSharedValidMock
);
}
diff --git a/app/code/Magento/Sales/etc/db_schema.xml b/app/code/Magento/Sales/etc/db_schema.xml
index ab524a0f552f6..491772e7e65a0 100644
--- a/app/code/Magento/Sales/etc/db_schema.xml
+++ b/app/code/Magento/Sales/etc/db_schema.xml
@@ -825,6 +825,9 @@
+
+
+
diff --git a/app/code/Magento/Sales/etc/db_schema_whitelist.json b/app/code/Magento/Sales/etc/db_schema_whitelist.json
index 087fe6c9eb5ac..02efd7d5a0050 100644
--- a/app/code/Magento/Sales/etc/db_schema_whitelist.json
+++ b/app/code/Magento/Sales/etc/db_schema_whitelist.json
@@ -479,6 +479,7 @@
"SALES_SHIPMENT_GRID_ORDER_CREATED_AT": true,
"SALES_SHIPMENT_GRID_SHIPPING_NAME": true,
"SALES_SHIPMENT_GRID_BILLING_NAME": true,
+ "SALES_SHIPMENT_GRID_ORDER_ID": true,
"FTI_086B40C8955F167B8EA76653437879B4": true
},
"constraint": {
diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Gallery/UpdateHandlerTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Gallery/UpdateHandlerTest.php
index 568859c1c83f0..481ec6aeac0f2 100644
--- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Gallery/UpdateHandlerTest.php
+++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/Gallery/UpdateHandlerTest.php
@@ -507,6 +507,92 @@ public function testDeleteWithMultiWebsites(): void
$this->assertArrayNotHasKey($secondStoreId, $imageRolesPerStore);
}
+ /**
+ * Check that product images should be updated successfully regardless if the existing images exist or not
+ *
+ * @magentoDataFixture Magento/Catalog/_files/product_with_image.php
+ * @dataProvider updateImageDataProvider
+ * @param string $newFile
+ * @param string $expectedFile
+ * @param bool $exist
+ * @return void
+ */
+ public function testUpdateImage(string $newFile, string $expectedFile, bool $exist): void
+ {
+ $product = $this->getProduct(Store::DEFAULT_STORE_ID);
+ $images = $product->getData('media_gallery')['images'];
+ $this->assertCount(1, $images);
+ $oldImage = reset($images) ?: [];
+ $this->assertEquals($oldImage['file'], $product->getImage());
+ $this->assertEquals($oldImage['file'], $product->getSmallImage());
+ $this->assertEquals($oldImage['file'], $product->getThumbnail());
+ $path = $this->mediaDirectory->getAbsolutePath($this->config->getBaseMediaPath() . $oldImage['file']);
+ $tmpPath = $this->mediaDirectory->getAbsolutePath($this->config->getBaseTmpMediaPath() . $oldImage['file']);
+ $this->assertFileExists($path);
+ $this->mediaDirectory->getDriver()->copy($path, $tmpPath);
+ if (!$exist) {
+ $this->mediaDirectory->getDriver()->deleteFile($path);
+ $this->assertFileDoesNotExist($path);
+ }
+ // delete old image
+ $oldImage['removed'] = 1;
+ $newImage = [
+ 'file' => $newFile,
+ 'position' => 1,
+ 'label' => 'New Image Alt Text',
+ 'disabled' => 0,
+ 'media_type' => 'image'
+ ];
+ $newImageRoles = [
+ 'image' => $newFile,
+ 'small_image' => 'no_selection',
+ 'thumbnail' => 'no_selection',
+ ];
+ $product->setData('media_gallery', ['images' => [$oldImage, $newImage]]);
+ $product->addData($newImageRoles);
+ $this->updateHandler->execute($product);
+ $product = $this->getProduct(Store::DEFAULT_STORE_ID);
+ $images = $product->getData('media_gallery')['images'];
+ $this->assertCount(1, $images);
+ $image = reset($images) ?: [];
+ $this->assertEquals($newImage['label'], $image['label']);
+ $this->assertEquals($expectedFile, $product->getImage());
+ $this->assertEquals($newImageRoles['small_image'], $product->getSmallImage());
+ $this->assertEquals($newImageRoles['thumbnail'], $product->getThumbnail());
+ $path = $this->mediaDirectory->getAbsolutePath($this->config->getBaseMediaPath() . $product->getImage());
+ // Assert that the image exists on disk.
+ $this->assertFileExists($path);
+ }
+
+ /**
+ * @return array[]
+ */
+ public function updateImageDataProvider(): array
+ {
+ return [
+ [
+ '/m/a/magento_image.jpg',
+ '/m/a/magento_image_1.jpg',
+ true
+ ],
+ [
+ '/m/a/magento_image.jpg',
+ '/m/a/magento_image.jpg',
+ false
+ ],
+ [
+ '/m/a/magento_small_image.jpg',
+ '/m/a/magento_small_image.jpg',
+ true
+ ],
+ [
+ '/m/a/magento_small_image.jpg',
+ '/m/a/magento_small_image.jpg',
+ false
+ ]
+ ];
+ }
+
/**
* Check product image link and product image exist
*
diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/UpdateProductWebsiteTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/UpdateProductWebsiteTest.php
index c63a3c8249e77..e973a25d07354 100644
--- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/UpdateProductWebsiteTest.php
+++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/UpdateProductWebsiteTest.php
@@ -10,7 +10,7 @@
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\ResourceModel\Product\Website\Link;
-use Magento\Framework\Exception\NoSuchEntityException;
+use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\ObjectManagerInterface;
use Magento\Store\Api\WebsiteRepositoryInterface;
use Magento\TestFramework\Helper\Bootstrap;
@@ -82,10 +82,10 @@ public function testUnassignProductFromWebsite(): void
*/
public function testAssignNonExistingWebsite(): void
{
- $messageFormat = 'The website with id %s that was requested wasn\'t found. Verify the website and try again.';
+ $messageFormat = 'The product was unable to be saved. Please try again.';
$nonExistingWebsiteId = 921564;
- $this->expectException(NoSuchEntityException::class);
- $this->expectExceptionMessage((string)__(sprintf($messageFormat, $nonExistingWebsiteId)));
+ $this->expectException(CouldNotSaveException::class);
+ $this->expectExceptionMessage((string)__($messageFormat));
$this->updateProductWebsites('simple2', [$nonExistingWebsiteId]);
}
diff --git a/lib/web/mage/validation.js b/lib/web/mage/validation.js
index de40e3afa40ab..ae8dad5865709 100644
--- a/lib/web/mage/validation.js
+++ b/lib/web/mage/validation.js
@@ -204,12 +204,24 @@ define([
* @returns {float}
*/
function resolveModulo(qty, qtyIncrements) {
+ var divideEpsilon = 10000,
+ epsilon,
+ remainder;
+
while (qtyIncrements < 1) {
qty *= 10;
qtyIncrements *= 10;
}
- return qty % qtyIncrements;
+ epsilon = qtyIncrements / divideEpsilon;
+ remainder = qty % qtyIncrements;
+
+ if (Math.abs(remainder - qtyIncrements) < epsilon ||
+ Math.abs(remainder) < epsilon) {
+ remainder = 0;
+ }
+
+ return remainder;
}
/**