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): ?> - escapeHtmlAttr($name) ?>="escapeHtmlAttr($value) ?>" + escapeHtmlAttr($name) ?>="escapeHtml($value) ?>" src="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): ?> - escapeHtmlAttr($name) ?>="escapeHtmlAttr($value) ?>" + escapeHtmlAttr($name) ?>="escapeHtml($value) ?>" src="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 @@ + + + + + + + + + + <description value="Validate qty increments for decimal fraction quantity works"/> + <severity value="MAJOR"/> + <useCaseId value="MC-38242"/> + <testCaseId value="MC-38883"/> + <group value="catalogInventory"/> + </annotations> + <before> + <createData entity="_defaultCategory" stepKey="createPreReqCategory"/> + <createData entity="SimpleProduct" stepKey="createPreReqSimpleProduct"> + <requiredEntity createDataKey="createPreReqCategory"/> + </createData> + </before> + <after> + <!--Clear Filters--> + <actionGroup ref="AdminClearFiltersActionGroup" stepKey="ClearFiltersAfter"/> + <deleteData createDataKey="createPreReqCategory" stepKey="deletePreReqCategory"/> + <deleteData createDataKey="createPreReqSimpleProduct" stepKey="deletePreReqSimpleProduct"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + <!--Step1. Login as admin. Go to Catalog > Products page. Filtering *prod1*. Open *prod1* to edit--> + <actionGroup ref="AdminLoginActionGroup" stepKey="LoginAsAdmin" /> + <actionGroup ref="SearchForProductOnBackendActionGroup" stepKey="filterGroupedProductOptions"> + <argument name="product" value="SimpleProduct"/> + </actionGroup> + <!-- Step2. Update product Advanced Inventory Setting. + Set *Qty Uses Decimals* to *Yes* and *Enable Qty Increments* to *Yes* and *Qty Increments* to *3.33*. --> + <actionGroup ref="OpenProductForEditByClickingRowXColumnYInProductGridActionGroup" stepKey="openProduct"/> + <actionGroup ref="AdminClickOnAdvancedInventoryLinkActionGroup" stepKey="clickOnAdvancedInventoryLink"/> + <actionGroup ref="AdminSetQtyUsesDecimalsConfigActionGroup" stepKey="setQtyUsesDecimalsConfig"> + <argument name="value" value="Yes"/> + </actionGroup> + <actionGroup ref="AdminSetEnableQtyIncrementsActionGroup" stepKey="setEnableQtyIncrements"> + <argument name="value" value="Yes"/> + </actionGroup> + <actionGroup ref="AdminSetQtyIncrementsForProductActionGroup" stepKey="setQtyIncrementsValue"> + <argument name="qty" value="3.33"/> + </actionGroup> + <actionGroup ref="AdminSubmitAdvancedInventoryFormActionGroup" stepKey="clickOnDoneButton"/> + + <!--Step3. Save the product--> + <actionGroup ref="AdminProductFormSaveActionGroup" stepKey="clickOnSaveButton2"/> + <!--Step4. Open *Customer view* (Go to *Store Front*). Open *prod1* page (Find via search and click on product name) --> + <amOnPage url="{{StorefrontHomePage.url}}$$createPreReqSimpleProduct.custom_attributes[url_key]$$.html" stepKey="amOnProductPage"/> + <!--Step5. Fill *23.31* in *Qty*. Click on button *Add to Cart*--> + <fillField selector="{{StorefrontProductPageSection.qtyInput}}" userInput="23.31" stepKey="fillQty"/> + <click selector="{{StorefrontProductPageSection.addToCartBtn}}" stepKey="clickOnAddToCart"/> + <waitForElementVisible selector="{{StorefrontProductPageSection.successMsg}}" time="30" stepKey="waitForProductAdded"/> + <!--Step6. Verify the product is successfully added to the cart with success message--> + <see selector="{{StorefrontCategoryMainSection.SuccessMsg}}" userInput="You added $$createPreReqSimpleProduct.name$$ to your shopping cart." stepKey="seeAddedToCartMessage"/> + </test> +</tests> 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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProduct\Plugin\Model\Order\Invoice; + +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\Sales\Model\Order\Invoice; + +/** + * Update total quantity for configurable product invoice + */ +class UpdateConfigurableProductTotalQty +{ + /** + * Set total quantity for configurable product invoice + * + * @param Invoice $invoice + * @param float $totalQty + * @return float + */ + public function beforeSetTotalQty( + Invoice $invoice, + float $totalQty + ): float { + $order = $invoice->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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProduct\Test\Unit\Plugin\Model\Order\Invoice; + +use Magento\Bundle\Model\Product\Type as Bundle; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\ConfigurableProduct\Plugin\Model\Order\Invoice\UpdateConfigurableProductTotalQty; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Magento\Sales\Model\Order\Invoice; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Item; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Test for class UpdateConfigurableProductTotalQty. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class UpdateConfigurableProductTotalQtyTest extends TestCase +{ + /** + * @var UpdateConfigurableProductTotalQty + */ + private $model; + + /** + * @var ObjectManagerHelper|null + */ + private $objectManagerHelper; + + /** + * @var Invoice|MockObject + */ + private $invoiceMock; + + /** + * @var Order|MockObject + */ + private $orderMock; + + /** + * @var Item[]|MockObject + */ + private $orderItemsMock; + + protected function setUp(): void + { + $this->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 @@ </argument> </arguments> </virtualType> + <type name="Magento\Sales\Model\Order\Invoice"> + <plugin name="update_configurable_product_total_qty" type="Magento\ConfigurableProduct\Plugin\Model\Order\Invoice\UpdateConfigurableProductTotalQty"/> + </type> </config> 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 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\DownloadableImportExport\Test\Unit\Model\Export\Product; - -use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection; -use Magento\Downloadable\Model\LinkRepository; -use Magento\Downloadable\Model\Product\Type as Type; -use Magento\Downloadable\Model\SampleRepository; -use Magento\DownloadableImportExport\Model\Export\RowCustomizer; -use Magento\Store\Model\Store; -use Magento\Store\Model\StoreManagerInterface; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; - -/** - * Class to test Customizes output during export - */ -class RowCustomizerTest extends TestCase -{ - /** - * @var LinkRepository|MockObject - */ - private $linkRepository; - - /** - * @var SampleRepository|MockObject - */ - private $sampleRepository; - - /** - * @var StoreManagerInterface|MockObject - */ - private $storeManager; - - /** - * @var RowCustomizer - */ - private $rowCustomizer; - - /** - * @inheritdoc - */ - protected function setUp(): void - { - $this->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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\DownloadableImportExport\Test\Unit\Model\Export; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\ResourceModel\Product\Collection; +use Magento\Downloadable\Model\LinkRepository; +use Magento\Downloadable\Model\SampleRepository; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; +use Magento\Store\Model\StoreManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Class RowCustomizerTest for export RowCustomizer + */ +class RowCustomizerTest extends TestCase +{ + /** + * @var StoreManagerInterface|MockObject + */ + private $storeManagerMock; + + /** + * @var LinkRepository|MockObject + */ + private $linkRepositoryMock; + + /** + * @var SampleRepository|MockObject + */ + private $sampleRepositoryMock; + + /** + * @var \Magento\DownloadableImportExport\Model\Export\RowCustomizer + */ + private $model; + + /** + * Setup + * + * @return void + */ + protected function setUp(): void + { + $this->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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Indexer\Model; + +use Magento\Indexer\Model\Indexer\StateFactory; +use Magento\Framework\Indexer\StateInterface; + +/** + * Provide actual working status of the indexer + */ +class WorkingStateProvider +{ + /** + * @var StateFactory + */ + private $stateFactory; + + /** + * @param StateFactory $stateFactory + */ + public function __construct( + StateFactory $stateFactory + ) { + $this->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 @@ <index referenceId="SALES_SHIPMENT_GRID_BILLING_NAME" indexType="btree"> <column name="billing_name"/> </index> + <index referenceId="SALES_SHIPMENT_GRID_ORDER_ID" indexType="btree"> + <column name="order_id"/> + </index> <index referenceId="FTI_086B40C8955F167B8EA76653437879B4" indexType="fulltext"> <column name="increment_id"/> <column name="order_increment_id"/> 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; } /**