diff --git a/app/code/Magento/Bundle/Model/Inventory/ChangeParentStockStatus.php b/app/code/Magento/Bundle/Model/Inventory/ChangeParentStockStatus.php new file mode 100644 index 0000000000000..023893d7317ea --- /dev/null +++ b/app/code/Magento/Bundle/Model/Inventory/ChangeParentStockStatus.php @@ -0,0 +1,160 @@ +bundleType = $bundleType; + $this->criteriaInterfaceFactory = $criteriaInterfaceFactory; + $this->stockItemRepository = $stockItemRepository; + $this->stockConfiguration = $stockConfiguration; + } + + /** + * Update stock status of bundle products based on children products stock status + * + * @param array $childrenIds + * @return void + */ + public function execute(array $childrenIds): void + { + $parentIds = $this->bundleType->getParentIdsByChild($childrenIds); + foreach (array_unique($parentIds) as $productId) { + $this->processStockForParent((int)$productId); + } + } + + /** + * Update stock status of bundle product based on children products stock status + * + * @param int $productId + * @return void + */ + private function processStockForParent(int $productId): void + { + $stockItems = $this->getStockItems([$productId]); + $parentStockItem = $stockItems[$productId] ?? null; + if ($parentStockItem) { + $childrenIsInStock = $this->isChildrenInStock($productId); + if ($this->isNeedToUpdateParent($parentStockItem, $childrenIsInStock)) { + $parentStockItem->setIsInStock($childrenIsInStock); + $parentStockItem->setStockStatusChangedAuto(1); + $this->stockItemRepository->save($parentStockItem); + } + } + } + + /** + * Returns stock status of bundle product based on children stock status + * + * Returns TRUE if any of the following conditions is true: + * - At least one product is in-stock in each required option + * - Any product is in-stock (if all options are optional) + * + * @param int $productId + * @return bool + */ + private function isChildrenInStock(int $productId) : bool + { + $childrenIsInStock = false; + $childrenIds = $this->bundleType->getChildrenIds($productId, true); + $stockItems = $this->getStockItems(array_merge(...array_values($childrenIds))); + foreach ($childrenIds as $childrenIdsPerOption) { + $childrenIsInStock = false; + foreach ($childrenIdsPerOption as $id) { + $stockItem = $stockItems[$id] ?? null; + if ($stockItem && $stockItem->getIsInStock()) { + $childrenIsInStock = true; + break; + } + } + if (!$childrenIsInStock) { + break; + } + } + + return $childrenIsInStock; + } + + /** + * Check if parent item should be updated + * + * @param StockItemInterface $parentStockItem + * @param bool $childrenIsInStock + * @return bool + */ + private function isNeedToUpdateParent( + StockItemInterface $parentStockItem, + bool $childrenIsInStock + ): bool { + return $parentStockItem->getIsInStock() !== $childrenIsInStock && + ($childrenIsInStock === false || $parentStockItem->getStockStatusChangedAuto()); + } + + /** + * Get stock items for provided product IDs + * + * @param array $productIds + * @return StockItemInterface[] + */ + private function getStockItems(array $productIds): array + { + $criteria = $this->criteriaInterfaceFactory->create(); + $criteria->setScopeFilter($this->stockConfiguration->getDefaultScopeId()); + $criteria->setProductsFilter(array_unique($productIds)); + $stockItemCollection = $this->stockItemRepository->getList($criteria); + + $stockItems = []; + foreach ($stockItemCollection->getItems() as $stockItem) { + $stockItems[$stockItem->getProductId()] = $stockItem; + } + + return $stockItems; + } +} diff --git a/app/code/Magento/Bundle/Model/Inventory/ParentItemProcessor.php b/app/code/Magento/Bundle/Model/Inventory/ParentItemProcessor.php new file mode 100644 index 0000000000000..5e013ced75c33 --- /dev/null +++ b/app/code/Magento/Bundle/Model/Inventory/ParentItemProcessor.php @@ -0,0 +1,39 @@ +changeParentStockStatus = $changeParentStockStatus; + } + + /** + * @inheritdoc + */ + public function process(Product $product) + { + $this->changeParentStockStatus->execute([$product->getId()]); + } +} diff --git a/app/code/Magento/Bundle/Test/Mftf/ActionGroup/AdminAssertSpecialPriceAttributeValueOnProductGridPageActionGroup.xml b/app/code/Magento/Bundle/Test/Mftf/ActionGroup/AdminAssertSpecialPriceAttributeValueOnProductGridPageActionGroup.xml new file mode 100644 index 0000000000000..50469d6690de0 --- /dev/null +++ b/app/code/Magento/Bundle/Test/Mftf/ActionGroup/AdminAssertSpecialPriceAttributeValueOnProductGridPageActionGroup.xml @@ -0,0 +1,28 @@ + + + + + + + Assert special price attribute value from the catalog product grid page + + + + + + + + + + + {{expectedValue}} + $getSpecialPrice + + + diff --git a/app/code/Magento/Bundle/Test/Mftf/Test/AdminBundleProductPriceSymbolValidationInGridTest.xml b/app/code/Magento/Bundle/Test/Mftf/Test/AdminBundleProductPriceSymbolValidationInGridTest.xml new file mode 100644 index 0000000000000..307e394913269 --- /dev/null +++ b/app/code/Magento/Bundle/Test/Mftf/Test/AdminBundleProductPriceSymbolValidationInGridTest.xml @@ -0,0 +1,86 @@ + + + + + + + + + + <description value="Admin to validate the bundle products special price column in grid should display percentage symbol instead of currency sign"/> + <severity value="AVERAGE"/> + <testCaseId value="AC-1378"/> + <useCaseId value="ACP2E-64"/> + <group value="Bundle"/> + </annotations> + <before> + <!-- Create a simple product --> + <createData entity="SimpleProduct2" stepKey="simpleProduct1"/> + <!-- Admin login --> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminPanel"/> + <!-- Navigate to catalog product grid page --> + <actionGroup ref="AdminOpenProductIndexPageActionGroup" stepKey="navigateToProductIndexPage"/> + <!-- Open the column dropdown to add the special price from the catalog product grid --> + <actionGroup ref="ToggleAdminProductGridColumnsDropdownActionGroup" stepKey="openColumnsDropdownSpecialPrice"/> + <actionGroup ref="CheckAdminProductGridColumnOptionActionGroup" stepKey="checkSpecialPriceOption"> + <argument name="optionName" value="Special Price"/> + </actionGroup> + <actionGroup ref="ToggleAdminProductGridColumnsDropdownActionGroup" stepKey="closeColumnsDropdownSpecialPrice"/> + <!-- It takes a few seconds for column update to be saved --> + <!-- waitForPageLoad won't work here since saving is happening with a short delay --> + <wait time="5" stepKey="waitForColumnUpdateToSave"/> + </before> + <after> + <!-- Navigate to catalog product grid page --> + <actionGroup ref="AdminOpenProductIndexPageActionGroup" stepKey="navigateToProductIndexPage"/> + <!-- Clean applied product filters before delete --> + <actionGroup ref="AdminClearGridFiltersActionGroup" stepKey="clearAppliedFilters"/> + <!-- Delete all the products from the catalog product grid --> + <actionGroup ref="DeleteProductsIfTheyExistActionGroup" stepKey="deleteAllProducts"/> + <!-- Set product grid to default columns --> + <actionGroup ref="ResetProductGridToDefaultViewActionGroup" stepKey="setProductGridToDefaultColumns"/> + <!-- Logging out --> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + <!-- Go to bundle product creation page --> + <actionGroup ref="AdminOpenNewProductFormPageActionGroup" stepKey="openNewBundleProductPage"> + <argument name="productType" value="{{BundleProduct.type}}"/> + <argument name="attributeSetId" value="{{BundleProduct.set}}"/> + </actionGroup> + <!-- Sets the provided Special Price on the Admin Product creation/edit page. --> + <actionGroup ref="AddSpecialPriceToProductActionGroup" stepKey="addSpecialPrice"> + <argument name="price" value="{{SimpleProductWithSpecialPrice.special_price}}"/> + </actionGroup> + <!-- Fill up the new product form with data --> + <actionGroup ref="CreateBasicBundleProductActionGroup" stepKey="createBundledProduct"> + <argument name="bundleProduct" value="BundleProduct"/> + </actionGroup> + <!-- Add the bundle option to the product --> + <actionGroup ref="AddBundleOptionWithOneProductActionGroup" stepKey="addBundleOption"> + <argument name="x" value="0"/> + <argument name="n" value="1"/> + <argument name="prodOneSku" value="$$simpleProduct1.sku$$"/> + <argument name="prodTwoSku" value=""/> + <argument name="optionTitle" value="{{BundleProduct.optionTitle1}}"/> + <argument name="inputType" value="{{BundleProduct.optionInputType1}}"/> + </actionGroup> + <!-- Save the bundle product --> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveProductForm"/> + <!-- Navigate to catalog product grid page --> + <actionGroup ref="AdminOpenProductIndexPageActionGroup" stepKey="navigateToProductIndexPageAfterProdSave"/> + <!-- Search the created bundle product with sku --> + <actionGroup ref="FilterProductGridBySku2ActionGroup" stepKey="filterBundleProductGridBySku"> + <argument name="sku" value="{{BundleProduct.sku}}"/> + </actionGroup> + <!-- Asserting with the special price value contains the percentage value --> + <actionGroup ref="AdminAssertSpecialPriceAttributeValueOnProductGridPageActionGroup" stepKey="assertSpecialPricePercentageSymbol"> + <argument name="expectedValue" value="90.00%"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Bundle/Ui/DataProvider/Product/Modifier/SpecialPriceAttributes.php b/app/code/Magento/Bundle/Ui/DataProvider/Product/Modifier/SpecialPriceAttributes.php new file mode 100644 index 0000000000000..cbe4dfa748340 --- /dev/null +++ b/app/code/Magento/Bundle/Ui/DataProvider/Product/Modifier/SpecialPriceAttributes.php @@ -0,0 +1,94 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Bundle\Ui\DataProvider\Product\Modifier; + +use Magento\Bundle\Model\Product\Type; +use Magento\Directory\Model\Currency as DirectoryCurrency; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Locale\ResolverInterface; +use Magento\Ui\DataProvider\Modifier\ModifierInterface; +use NumberFormatter; +use Zend_Currency; +use Zend_Currency_Exception; + +/** + * Modify product listing special price attributes + */ +class SpecialPriceAttributes implements ModifierInterface +{ + /** + * @var ResolverInterface + */ + private $localeResolver; + + /** + * @var array + */ + private $priceAttributeList; + + /** + * @var DirectoryCurrency + */ + private $directoryCurrency; + + /** + * PriceAttributes constructor. + * + * @param DirectoryCurrency $directoryCurrency + * @param ResolverInterface $localeResolver + * @param array $priceAttributeList + */ + public function __construct( + DirectoryCurrency $directoryCurrency, + ResolverInterface $localeResolver, + array $priceAttributeList = [] + ) { + $this->directoryCurrency = $directoryCurrency; + $this->localeResolver = $localeResolver; + $this->priceAttributeList = $priceAttributeList; + } + + /** + * @inheritdoc + * @throws NoSuchEntityException + * @throws Zend_Currency_Exception + */ + public function modifyData(array $data): array + { + if (empty($data) || empty($this->priceAttributeList)) { + return $data; + } + $numberFormatter = new NumberFormatter( + $this->localeResolver->getLocale(), + NumberFormatter::PERCENT + ); + $numberFormatter->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, 2); + foreach ($data['items'] as &$item) { + foreach ($this->priceAttributeList as $priceAttribute) { + if (isset($item[$priceAttribute]) && $item['type_id'] == Type::TYPE_CODE) { + $item[$priceAttribute] = + $this->directoryCurrency->format( + $item[$priceAttribute], + ['display' => Zend_Currency::NO_SYMBOL], + false + ); + $item[$priceAttribute] = $numberFormatter->format($item[$priceAttribute] / 100); + } + } + } + return $data; + } + + /** + * @inheritdoc + */ + public function modifyMeta(array $meta): array + { + return $meta; + } +} diff --git a/app/code/Magento/Bundle/composer.json b/app/code/Magento/Bundle/composer.json index 53d8f42ed702f..8eb0125946b8e 100644 --- a/app/code/Magento/Bundle/composer.json +++ b/app/code/Magento/Bundle/composer.json @@ -21,7 +21,8 @@ "magento/module-sales": "*", "magento/module-store": "*", "magento/module-tax": "*", - "magento/module-ui": "*" + "magento/module-ui": "*", + "magento/module-directory": "*" }, "suggest": { "magento/module-webapi": "*", diff --git a/app/code/Magento/Bundle/etc/adminhtml/di.xml b/app/code/Magento/Bundle/etc/adminhtml/di.xml index b23817de3644b..c30b3482d140e 100644 --- a/app/code/Magento/Bundle/etc/adminhtml/di.xml +++ b/app/code/Magento/Bundle/etc/adminhtml/di.xml @@ -49,4 +49,21 @@ </argument> </arguments> </type> + <virtualType name="Magento\Catalog\Ui\DataProvider\Product\Listing\Modifier\Pool"> + <arguments> + <argument name="modifiers" xsi:type="array"> + <item name="specialPriceAttributes" xsi:type="array"> + <item name="class" xsi:type="string">Magento\Bundle\Ui\DataProvider\Product\Modifier\SpecialPriceAttributes</item> + <item name="sortOrder" xsi:type="number">20</item> + </item> + </argument> + </arguments> + </virtualType> + <type name="Magento\Bundle\Ui\DataProvider\Product\Modifier\SpecialPriceAttributes"> + <arguments> + <argument name="priceAttributeList" xsi:type="array"> + <item name="special_price" xsi:type="string">special_price</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/Bundle/etc/di.xml b/app/code/Magento/Bundle/etc/di.xml index 36b2b0b527de4..c5c4a491234ed 100644 --- a/app/code/Magento/Bundle/etc/di.xml +++ b/app/code/Magento/Bundle/etc/di.xml @@ -283,4 +283,11 @@ </argument> </arguments> </type> + <type name="Magento\CatalogInventory\Observer\SaveInventoryDataObserver"> + <arguments> + <argument name="parentItemProcessorPool" xsi:type="array"> + <item name="bundle" xsi:type="object">Magento\Bundle\Model\Inventory\ParentItemProcessor</item> + </argument> + </arguments> + </type> </config> diff --git a/app/code/Magento/BundleGraphQl/Model/Resolver/Links/Collection.php b/app/code/Magento/BundleGraphQl/Model/Resolver/Links/Collection.php index 0f8cdc27d2417..3d479692f719a 100644 --- a/app/code/Magento/BundleGraphQl/Model/Resolver/Links/Collection.php +++ b/app/code/Magento/BundleGraphQl/Model/Resolver/Links/Collection.php @@ -11,8 +11,12 @@ use Magento\Bundle\Model\ResourceModel\Selection\CollectionFactory; use Magento\Bundle\Model\ResourceModel\Selection\Collection as LinkCollection; use Magento\Framework\App\ObjectManager; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Exception\RuntimeException; use Magento\Framework\GraphQl\Query\EnumLookup; use Magento\Framework\GraphQl\Query\Uid; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Zend_Db_Select_Exception; /** * Collection to fetch link data at resolution time. @@ -47,20 +51,29 @@ class Collection /** @var Uid */ private $uidEncoder; + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + /** * @param CollectionFactory $linkCollectionFactory * @param EnumLookup $enumLookup * @param Uid|null $uidEncoder + * @param ProductRepositoryInterface|null $productRepository */ public function __construct( CollectionFactory $linkCollectionFactory, EnumLookup $enumLookup, - Uid $uidEncoder = null + Uid $uidEncoder = null, + ?ProductRepositoryInterface $productRepository = null ) { $this->linkCollectionFactory = $linkCollectionFactory; $this->enumLookup = $enumLookup; $this->uidEncoder = $uidEncoder ?: ObjectManager::getInstance() ->get(Uid::class); + $this->productRepository = $productRepository ?: ObjectManager::getInstance() + ->get(ProductRepositoryInterface::class); } /** @@ -85,6 +98,9 @@ public function addIdFilters(int $optionId, int $parentId) : void * * @param int $optionId * @return array + * @throws NoSuchEntityException + * @throws RuntimeException + * @throws Zend_Db_Select_Exception */ public function getLinksForOptionId(int $optionId) : array { @@ -101,6 +117,10 @@ public function getLinksForOptionId(int $optionId) : array * Fetch link data and return in array format. Keys for links will be their option Ids. * * @return array + * @throws NoSuchEntityException + * @throws RuntimeException + * @throws Zend_Db_Select_Exception + * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ private function fetch() : array { @@ -123,26 +143,33 @@ private function fetch() : array /** @var Selection $link */ foreach ($linkCollection as $link) { + $productDetails = []; $data = $link->getData(); - $formattedLink = [ - 'price' => $link->getSelectionPriceValue(), - 'position' => $link->getPosition(), - 'id' => $link->getSelectionId(), - 'uid' => $this->uidEncoder->encode((string) $link->getSelectionId()), - 'qty' => (float)$link->getSelectionQty(), - 'quantity' => (float)$link->getSelectionQty(), - 'is_default' => (bool)$link->getIsDefault(), - 'price_type' => $this->enumLookup->getEnumValueFromField( - 'PriceTypeEnum', - (string)$link->getSelectionPriceType() - ) ?: 'DYNAMIC', - 'can_change_quantity' => $link->getSelectionCanChangeQty(), - ]; - $data = array_replace($data, $formattedLink); - if (!isset($this->links[$link->getOptionId()])) { - $this->links[$link->getOptionId()] = []; + if (isset($data['product_id'])) { + $productDetails = $this->productRepository->getById($data['product_id']); + } + + if ($productDetails && $productDetails->getIsSalable()) { + $formattedLink = [ + 'price' => $link->getSelectionPriceValue(), + 'position' => $link->getPosition(), + 'id' => $link->getSelectionId(), + 'uid' => $this->uidEncoder->encode((string)$link->getSelectionId()), + 'qty' => (float)$link->getSelectionQty(), + 'quantity' => (float)$link->getSelectionQty(), + 'is_default' => (bool)$link->getIsDefault(), + 'price_type' => $this->enumLookup->getEnumValueFromField( + 'PriceTypeEnum', + (string)$link->getSelectionPriceType() + ) ?: 'DYNAMIC', + 'can_change_quantity' => $link->getSelectionCanChangeQty(), + ]; + $data = array_replace($data, $formattedLink); + if (!isset($this->links[$link->getOptionId()])) { + $this->links[$link->getOptionId()] = []; + } + $this->links[$link->getOptionId()][] = $data; } - $this->links[$link->getOptionId()][] = $data; } return $this->links; diff --git a/app/code/Magento/BundleImportExport/Plugin/Import/Product/UpdateBundleProductsStockItemStatusPlugin.php b/app/code/Magento/BundleImportExport/Plugin/Import/Product/UpdateBundleProductsStockItemStatusPlugin.php new file mode 100644 index 0000000000000..45013372a4401 --- /dev/null +++ b/app/code/Magento/BundleImportExport/Plugin/Import/Product/UpdateBundleProductsStockItemStatusPlugin.php @@ -0,0 +1,49 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\BundleImportExport\Plugin\Import\Product; + +use Magento\CatalogImportExport\Model\StockItemImporterInterface; +use Magento\Bundle\Model\Inventory\ChangeParentStockStatus; + +/** + * Update bundle products stock item status based on children products stock status after import + */ +class UpdateBundleProductsStockItemStatusPlugin +{ + /** + * @var ChangeParentStockStatus + */ + private $changeParentStockStatus; + + /** + * @param ChangeParentStockStatus $changeParentStockStatus + */ + public function __construct( + ChangeParentStockStatus $changeParentStockStatus + ) { + $this->changeParentStockStatus = $changeParentStockStatus; + } + + /** + * Update bundle products stock item status based on children products stock status after import + * + * @param StockItemImporterInterface $subject + * @param mixed $result + * @param array $stockData + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterImport( + StockItemImporterInterface $subject, + $result, + array $stockData + ): void { + if ($stockData) { + $this->changeParentStockStatus->execute(array_column($stockData, 'product_id')); + } + } +} diff --git a/app/code/Magento/BundleImportExport/etc/di.xml b/app/code/Magento/BundleImportExport/etc/di.xml index 2bcbbedcc9103..d697b52f628ab 100644 --- a/app/code/Magento/BundleImportExport/etc/di.xml +++ b/app/code/Magento/BundleImportExport/etc/di.xml @@ -13,4 +13,9 @@ </argument> </arguments> </type> + <type name="Magento\CatalogImportExport\Model\StockItemImporterInterface"> + <plugin name="update_bundle_products_stock_item_status" + type="Magento\BundleImportExport\Plugin\Import\Product\UpdateBundleProductsStockItemStatusPlugin" + sortOrder="200"/> + </type> </config> diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ProductCategoryCondition.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ProductCategoryCondition.php index 66a9132ae44b8..dfbb80fd9d6c3 100644 --- a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ProductCategoryCondition.php +++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ProductCategoryCondition.php @@ -10,7 +10,6 @@ use Magento\Framework\Api\SearchCriteria\CollectionProcessor\ConditionProcessor\CustomConditionInterface; use Magento\Catalog\Model\ResourceModel\Product\Collection; use Magento\Framework\Api\Filter; -use Magento\Framework\Data\Collection\AbstractDb; use Magento\Framework\Exception\NoSuchEntityException as CategoryDoesNotExistException; /** @@ -63,12 +62,12 @@ public function build(Filter $filter): string )->where( $this->resourceConnection->getConnection()->prepareSqlCondition( 'cat.category_id', - [$this->mapConditionType($filter->getConditionType()) => $this->getCategoryIds($filter)] + ['in' => $this->getCategoryIds($filter)] ) ); $selectCondition = [ - 'in' => $categorySelect + $this->mapConditionType($filter->getConditionType()) => $categorySelect ]; return $this->resourceConnection->getConnection() @@ -116,12 +115,7 @@ private function getCategoryIds(Filter $filter): array */ private function mapConditionType(string $conditionType): string { - $conditionsMap = [ - 'eq' => 'in', - 'neq' => 'nin', - 'like' => 'in', - 'nlike' => 'nin', - ]; - return $conditionsMap[$conditionType] ?? $conditionType; + $ninConditions = ['nin', 'neq', 'nlike']; + return in_array($conditionType, $ninConditions, true) ? 'nin' : 'in'; } } diff --git a/app/code/Magento/Catalog/Model/Product/Image.php b/app/code/Magento/Catalog/Model/Product/Image.php index 34ce5cad70b04..11d70583ec5a8 100644 --- a/app/code/Magento/Catalog/Model/Product/Image.php +++ b/app/code/Magento/Catalog/Model/Product/Image.php @@ -29,7 +29,7 @@ class Image extends \Magento\Framework\Model\AbstractModel /** * Config path for the jpeg image quality value */ - const XML_PATH_JPEG_QUALITY = 'system/upload_configuration/jpeg_quality'; + public const XML_PATH_JPEG_QUALITY = 'system/upload_configuration/jpeg_quality'; /** * @var int @@ -836,7 +836,37 @@ public function getWatermarkHeight() public function clearCache() { $directory = $this->_catalogProductMediaConfig->getBaseMediaPath() . '/cache'; - $this->_mediaDirectory->delete($directory); + $directoryToDelete = $directory; + // Fixes issue when deleting cache directory at the same time that images are being + // lazy-loaded on storefront leading to new directories and files generation in the cache directory + // that would prevent deletion of the cache directory. + // RCA: the method delete() recursively enumerates and delete all subdirectories and files before deleting + // the target directory, which gives other processes time to create directories and files in the same directory. + // Solution: Rename the directory to simulate deletion and delete the destination directory afterward + + try { + //generate name in format: \.[0-9A-ZA-z-_]{3} (e.g .QX3) + $tmpDirBasename = strrev(strtr(base64_encode(random_bytes(2)), '+/=', '-_.')); + $tmpDirectory = $this->_catalogProductMediaConfig->getBaseMediaPath() . '/' . $tmpDirBasename; + //delete temporary directory if exists + if ($this->_mediaDirectory->isDirectory($tmpDirectory)) { + $this->_mediaDirectory->delete($tmpDirectory); + } + //rename the directory to simulate deletion and delete the destination directory + if ($this->_mediaDirectory->isDirectory($directory) && + true === $this->_mediaDirectory->getDriver()->rename( + $this->_mediaDirectory->getAbsolutePath($directory), + $this->_mediaDirectory->getAbsolutePath($tmpDirectory) + ) + ) { + $directoryToDelete = $tmpDirectory; + } + } catch (\Throwable $exception) { + //ignore exceptions thrown during renaming + $directoryToDelete = $directory; + } + + $this->_mediaDirectory->delete($directoryToDelete); $this->_coreFileStorageDatabase->deleteFolder($this->_mediaDirectory->getAbsolutePath($directory)); $this->clearImageInfoFromCache(); @@ -870,6 +900,7 @@ protected function _fileExists($filename) public function getResizedImageInfo() { try { + $image = null; if ($this->isBaseFilePlaceholder() == true) { $image = $this->imageAsset->getSourceFile(); } else { @@ -920,6 +951,7 @@ private function getImageSize($imagePath) { $imageInfo = $this->loadImageInfoFromCache($imagePath); if (!isset($imageInfo['size'])) { + // phpcs:ignore Magento2.Functions.DiscouragedFunction $imageSize = getimagesize($imagePath); $this->saveImageInfoToCache(['size' => $imageSize], $imagePath); return $imageSize; diff --git a/app/code/Magento/Catalog/Model/Theme/CustomerData/MessagesProvider.php b/app/code/Magento/Catalog/Model/Theme/CustomerData/MessagesProvider.php new file mode 100644 index 0000000000000..b332a8134527f --- /dev/null +++ b/app/code/Magento/Catalog/Model/Theme/CustomerData/MessagesProvider.php @@ -0,0 +1,69 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Catalog\Model\Theme\CustomerData; + +use Magento\Catalog\Model\Product\ProductFrontendAction\Synchronizer; +use Magento\Framework\App\Config; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Message\Collection; +use Magento\Framework\Message\ManagerInterface as MessageManager; +use Magento\Theme\CustomerData\MessagesProviderInterface; + +class MessagesProvider implements MessagesProviderInterface +{ + /** + * + * @var Config + */ + private $appConfig; + + /** + * @var RequestInterface + */ + private $request; + + /** + * Manager messages + * + * @var MessageManager + */ + private $messageManager; + + /** + * Constructor + * + * @param Config $appConfig + * @param RequestInterface $request + * @param MessageManager $messageManager + */ + public function __construct( + Config $appConfig, + RequestInterface $request, + MessageManager $messageManager + ) { + $this->appConfig = $appConfig; + $this->request = $request; + $this->messageManager = $messageManager; + } + + /** + * Verify flag value for synchronize product actions with backend or not + * + * @return Collection + */ + public function getMessages(): Collection + { + $clearSessionMessages = true; + + if ((bool) $this->appConfig->getValue(Synchronizer::ALLOW_SYNC_WITH_BACKEND_PATH)) { + $clearSessionMessages = $this->request->getParam('force_new_section_timestamp') === 'true'; + } + + return $this->messageManager->getMessages($clearSessionMessages); + } +} diff --git a/app/code/Magento/Catalog/Test/Mftf/Data/CatalogConfigurationData.xml b/app/code/Magento/Catalog/Test/Mftf/Data/CatalogConfigurationData.xml index cb2bacfd2f2da..274e44473967d 100644 --- a/app/code/Magento/Catalog/Test/Mftf/Data/CatalogConfigurationData.xml +++ b/app/code/Magento/Catalog/Test/Mftf/Data/CatalogConfigurationData.xml @@ -22,4 +22,18 @@ <data key="label">Website</data> <data key="value">1</data> </entity> + <!-- Catalog > Recently Viewed/Compared Products > Synchronize Widget Products With Backend Storage --> + <entity name="DisableSynchronizeWidgetProductsWithBackendStorage"> + <!-- Default configuration --> + <data key="path">catalog/recently_products/synchronize_with_backend</data> + <data key="scope_id">0</data> + <data key="label">No</data> + <data key="value">0</data> + </entity> + <entity name="EnableSynchronizeWidgetProductsWithBackendStorage"> + <data key="path">catalog/recently_products/synchronize_with_backend</data> + <data key="scope_id">0</data> + <data key="label">Yes</data> + <data key="value">1</data> + </entity> </entities> diff --git a/app/code/Magento/Catalog/Test/Unit/Model/Product/ImageTest.php b/app/code/Magento/Catalog/Test/Unit/Model/Product/ImageTest.php index 3628494269b59..c0c2452a2a91e 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/Product/ImageTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/Product/ImageTest.php @@ -17,6 +17,7 @@ use Magento\Framework\Filesystem; use Magento\Framework\Filesystem\Directory\Write; use Magento\Framework\Filesystem\Directory\WriteInterface; +use Magento\Framework\Filesystem\DriverInterface; use Magento\Framework\Image\Factory; use Magento\Framework\Model\Context; use Magento\Framework\Serialize\SerializerInterface; @@ -140,7 +141,7 @@ protected function setUp(): void $this->mediaDirectory = $this->getMockBuilder(Write::class) ->disableOriginalConstructor() - ->onlyMethods(['create', 'isFile', 'isExist', 'getAbsolutePath']) + ->onlyMethods(['create', 'isFile', 'isExist', 'getAbsolutePath', 'isDirectory', 'getDriver', 'delete']) ->getMock(); $this->filesystem = $this->createMock(Filesystem::class); @@ -503,15 +504,56 @@ public function testIsCached(): void } /** + * @param bool $isRenameSuccessful + * @param string $expectedDirectoryToDelete * @return void - */ - public function testClearCache(): void - { + * @throws \Magento\Framework\Exception\FileSystemException + * @dataProvider clearCacheDataProvider + */ + public function testClearCache( + bool $isRenameSuccessful, + string $expectedDirectoryToDelete + ): void { + $driver = $this->createMock(DriverInterface::class); + $this->mediaDirectory->method('getAbsolutePath') + ->willReturnCallback( + function (string $path) { + return 'path/to/media/' . $path; + } + ); + $this->mediaDirectory->expects($this->exactly(2)) + ->method('isDirectory') + ->willReturnOnConsecutiveCalls(false, true); + $this->mediaDirectory->expects($this->once()) + ->method('getDriver') + ->willReturn($driver); + $driver->expects($this->once()) + ->method('rename') + ->with( + 'path/to/media/catalog/product/cache', + $this->matchesRegularExpression('/^path\/to\/media\/catalog\/product\/\.[0-9A-ZA-z-_]{3}$/') + ) + ->willReturn($isRenameSuccessful); + $this->mediaDirectory->expects($this->once()) + ->method('delete') + ->with($this->matchesRegularExpression($expectedDirectoryToDelete)); + $this->coreFileHelper->expects($this->once())->method('deleteFolder')->willReturn(true); $this->cacheManager->expects($this->once())->method('clean'); $this->image->clearCache(); } + /** + * @return array + */ + public function clearCacheDataProvider(): array + { + return [ + [true, '/^catalog\/product\/\.[0-9A-ZA-z-_]{3}$/'], + [false, '/^catalog\/product\/cache$/'], + ]; + } + /** * @return void */ diff --git a/app/code/Magento/Catalog/etc/di.xml b/app/code/Magento/Catalog/etc/di.xml index d72c06022791e..e817bcbb42d25 100644 --- a/app/code/Magento/Catalog/etc/di.xml +++ b/app/code/Magento/Catalog/etc/di.xml @@ -76,6 +76,7 @@ <preference for="Magento\Catalog\Api\Data\MassActionInterface" type="Magento\Catalog\Model\MassAction" /> <preference for="Magento\Catalog\Model\ProductLink\Data\ListCriteriaInterface" type="Magento\Catalog\Model\ProductLink\Data\ListCriteria" /> <preference for="Magento\Catalog\Api\CategoryListDeleteBySkuInterface" type="Magento\Catalog\Model\CategoryLinkRepository"/> + <preference for="Magento\Theme\CustomerData\MessagesProviderInterface" type="Magento\Catalog\Model\Theme\CustomerData\MessagesProvider"/> <type name="Magento\Customer\Model\ResourceModel\Visitor"> <plugin name="catalogLog" type="Magento\Catalog\Model\Plugin\Log" /> </type> diff --git a/app/code/Magento/Catalog/view/frontend/web/js/storage-manager.js b/app/code/Magento/Catalog/view/frontend/web/js/storage-manager.js index e3f5e04bdcb1b..886963db67480 100644 --- a/app/code/Magento/Catalog/view/frontend/web/js/storage-manager.js +++ b/app/code/Magento/Catalog/view/frontend/web/js/storage-manager.js @@ -233,6 +233,7 @@ define([ delete params.typeId; delete params.url; + this.requestSent = 1; return utils.ajaxSubmit({ url: url, diff --git a/app/code/Magento/CatalogRuleConfigurable/Plugin/CatalogRule/Model/Rule/ConfigurableProductHandler.php b/app/code/Magento/CatalogRuleConfigurable/Plugin/CatalogRule/Model/Rule/ConfigurableProductHandler.php index d27c424ed9ea3..231696f259f59 100644 --- a/app/code/Magento/CatalogRuleConfigurable/Plugin/CatalogRule/Model/Rule/ConfigurableProductHandler.php +++ b/app/code/Magento/CatalogRuleConfigurable/Plugin/CatalogRule/Model/Rule/ConfigurableProductHandler.php @@ -42,13 +42,26 @@ public function __construct( } /** + * Match configurable child products if configurable product match the condition + * * @param \Magento\CatalogRule\Model\Rule $rule - * @param array $productIds + * @param \Closure $proceed * @return array * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - public function afterGetMatchingProductIds(\Magento\CatalogRule\Model\Rule $rule, array $productIds) - { + public function aroundGetMatchingProductIds( + \Magento\CatalogRule\Model\Rule $rule, + \Closure $proceed + ) { + $productsFilter = $rule->getProductsFilter() ? (array) $rule->getProductsFilter() : []; + if ($productsFilter) { + $parentProductIds = $this->configurable->getParentIdsByChild($productsFilter); + $rule->setProductsFilter(array_unique(array_merge($productsFilter, $parentProductIds))); + } + + $productIds = $proceed(); + $configurableProductIds = $this->configurableProductsProvider->getIds(array_keys($productIds)); foreach ($configurableProductIds as $productId) { if (!isset($this->childrenProducts[$productId])) { @@ -58,11 +71,15 @@ public function afterGetMatchingProductIds(\Magento\CatalogRule\Model\Rule $rule $parentValidationResult = isset($productIds[$productId]) ? array_filter($productIds[$productId]) : []; + $processAllChildren = !$productsFilter || in_array($productId, $productsFilter); foreach ($subProductIds as $subProductId) { - $childValidationResult = isset($productIds[$subProductId]) - ? array_filter($productIds[$subProductId]) - : []; - $productIds[$subProductId] = $parentValidationResult + $childValidationResult; + if ($processAllChildren || in_array($subProductId, $productsFilter)) { + $childValidationResult = isset($productIds[$subProductId]) + ? array_filter($productIds[$subProductId]) + : []; + $productIds[$subProductId] = $parentValidationResult + $childValidationResult; + } + } unset($productIds[$productId]); } diff --git a/app/code/Magento/CatalogRuleConfigurable/Test/Unit/Plugin/CatalogRule/Model/Rule/ConfigurableProductHandlerTest.php b/app/code/Magento/CatalogRuleConfigurable/Test/Unit/Plugin/CatalogRule/Model/Rule/ConfigurableProductHandlerTest.php index 4d508853643ee..77c9904a22c6b 100644 --- a/app/code/Magento/CatalogRuleConfigurable/Test/Unit/Plugin/CatalogRule/Model/Rule/ConfigurableProductHandlerTest.php +++ b/app/code/Magento/CatalogRuleConfigurable/Test/Unit/Plugin/CatalogRule/Model/Rule/ConfigurableProductHandlerTest.php @@ -44,7 +44,7 @@ protected function setUp(): void { $this->configurableMock = $this->createPartialMock( Configurable::class, - ['getChildrenIds'] + ['getChildrenIds', 'getParentIdsByChild'] ); $this->configurableProductsProviderMock = $this->createPartialMock( ConfigurableProductsProvider::class, @@ -61,22 +61,29 @@ protected function setUp(): void /** * @return void */ - public function testAfterGetMatchingProductIdsWithSimpleProduct() + public function testAroundGetMatchingProductIdsWithSimpleProduct() { $this->configurableProductsProviderMock->expects($this->once())->method('getIds')->willReturn([]); $this->configurableMock->expects($this->never())->method('getChildrenIds'); + $this->ruleMock->expects($this->never()) + ->method('setProductsFilter'); $productIds = ['product' => 'valid results']; $this->assertEquals( $productIds, - $this->configurableProductHandler->afterGetMatchingProductIds($this->ruleMock, $productIds) + $this->configurableProductHandler->aroundGetMatchingProductIds( + $this->ruleMock, + function () { + return ['product' => 'valid results']; + } + ) ); } /** * @return void */ - public function testAfterGetMatchingProductIdsWithConfigurableProduct() + public function testAroundGetMatchingProductIdsWithConfigurableProduct() { $this->configurableProductsProviderMock->expects($this->once())->method('getIds') ->willReturn(['conf1', 'conf2']); @@ -84,6 +91,8 @@ public function testAfterGetMatchingProductIdsWithConfigurableProduct() ['conf1', true, [ 0 => ['simple1']]], ['conf2', true, [ 0 => ['simple1', 'simple2']]], ]); + $this->ruleMock->expects($this->never()) + ->method('setProductsFilter'); $this->assertEquals( [ @@ -96,21 +105,118 @@ public function testAfterGetMatchingProductIdsWithConfigurableProduct() 3 => true, ] ], - $this->configurableProductHandler->afterGetMatchingProductIds( + $this->configurableProductHandler->aroundGetMatchingProductIds( $this->ruleMock, - [ - 'conf1' => [ - 0 => true, - 1 => true, - ], - 'conf2' => [ - 0 => false, - 1 => false, - 3 => true, - 4 => false, - ], - ] + function () { + return [ + 'conf1' => [ + 0 => true, + 1 => true, + ], + 'conf2' => [ + 0 => false, + 1 => false, + 3 => true, + 4 => false, + ], + ]; + } ) ); } + + /** + * @param array $productsFilter + * @param array $expectedProductsFilter + * @param array $matchingProductIds + * @param array $expectedMatchingProductIds + * @return void + * @dataProvider aroundGetMatchingProductIdsDataProvider + */ + public function testAroundGetMatchingProductIdsWithProductsFilter( + array $productsFilter, + array $expectedProductsFilter, + array $matchingProductIds, + array $expectedMatchingProductIds + ): void { + $configurableProducts = [ + 'conf1' => ['simple11', 'simple12'], + 'conf2' => ['simple21', 'simple22'], + ]; + $this->configurableProductsProviderMock->method('getIds') + ->willReturnCallback( + function ($ids) use ($configurableProducts) { + return array_intersect($ids, array_keys($configurableProducts)); + } + ); + $this->configurableMock->method('getChildrenIds') + ->willReturnCallback( + function ($id) use ($configurableProducts) { + return [0 => $configurableProducts[$id] ?? []]; + } + ); + + $this->configurableMock->method('getParentIdsByChild') + ->willReturnCallback( + function ($ids) use ($configurableProducts) { + $result = []; + foreach ($configurableProducts as $configurableProduct => $childProducts) { + if (array_intersect($ids, $childProducts)) { + $result[] = $configurableProduct; + } + } + return $result; + } + ); + + $this->ruleMock->method('getProductsFilter') + ->willReturn($productsFilter); + + $this->ruleMock->expects($this->once()) + ->method('setProductsFilter') + ->willReturn($expectedProductsFilter); + + $this->assertEquals( + $expectedMatchingProductIds, + $this->configurableProductHandler->aroundGetMatchingProductIds( + $this->ruleMock, + function () use ($matchingProductIds) { + return $matchingProductIds; + } + ) + ); + } + + /** + * @return array[] + */ + public function aroundGetMatchingProductIdsDataProvider(): array + { + return [ + [ + ['simple1',], + ['simple1',], + ['simple1' => [1 => false]], + ['simple1' => [1 => false],], + ], + [ + ['simple11',], + ['simple11', 'conf1',], + ['simple11' => [1 => false], 'conf1' => [1 => true],], + ['simple11' => [1 => true],], + ], + [ + ['simple11', 'simple12',], + ['simple11', 'conf1',], + ['simple11' => [1 => false], 'conf1' => [1 => true],], + ['simple11' => [1 => true], 'simple12' => [1 => true],], + ], + [ + ['conf1', 'simple11', 'simple12'], + ['conf1', 'simple11', 'simple12'], + ['conf1' => [1 => true], 'simple11' => [1 => false], 'simple12' => [1 => false]], + ['simple11' => [1 => true], 'simple12' => [1 => true]], + ], + ]; + } } diff --git a/app/code/Magento/Checkout/Model/TotalsInformationManagement.php b/app/code/Magento/Checkout/Model/TotalsInformationManagement.php index 7328f8845545c..25e2f0ba4e005 100644 --- a/app/code/Magento/Checkout/Model/TotalsInformationManagement.php +++ b/app/code/Magento/Checkout/Model/TotalsInformationManagement.php @@ -5,14 +5,14 @@ */ namespace Magento\Checkout\Model; +use Magento\Checkout\Api\Data\TotalsInformationInterface; + /** * Class for management of totals information. */ class TotalsInformationManagement implements \Magento\Checkout\Api\TotalsInformationManagementInterface { /** - * Cart total repository. - * * @var \Magento\Quote\Api\CartTotalRepositoryInterface */ protected $cartTotalRepository; @@ -42,7 +42,7 @@ public function __construct( */ public function calculate( $cartId, - \Magento\Checkout\Api\Data\TotalsInformationInterface $addressInformation + TotalsInformationInterface $addressInformation ) { /** @var \Magento\Quote\Model\Quote $quote */ $quote = $this->cartRepository->get($cartId); @@ -53,9 +53,19 @@ public function calculate( } else { $quote->setShippingAddress($addressInformation->getAddress()); if ($addressInformation->getShippingCarrierCode() && $addressInformation->getShippingMethodCode()) { - $quote->getShippingAddress()->setCollectShippingRates(true)->setShippingMethod( - $addressInformation->getShippingCarrierCode().'_'.$addressInformation->getShippingMethodCode() + $shippingMethod = implode( + '_', + [$addressInformation->getShippingCarrierCode(), $addressInformation->getShippingMethodCode()] ); + $quoteShippingAddress = $quote->getShippingAddress(); + if ($quoteShippingAddress->getShippingMethod() && + $quoteShippingAddress->getShippingMethod() !== $shippingMethod + ) { + $quoteShippingAddress->setShippingAmount(0); + $quoteShippingAddress->setBaseShippingAmount(0); + } + $quoteShippingAddress->setCollectShippingRates(true) + ->setShippingMethod($shippingMethod); } } $quote->collectTotals(); diff --git a/app/code/Magento/Checkout/Test/Unit/Model/TotalsInformationManagementTest.php b/app/code/Magento/Checkout/Test/Unit/Model/TotalsInformationManagementTest.php index 61049b4893476..d6feb38dc6012 100644 --- a/app/code/Magento/Checkout/Test/Unit/Model/TotalsInformationManagementTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Model/TotalsInformationManagementTest.php @@ -12,6 +12,7 @@ use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\Quote\Api\CartRepositoryInterface; use Magento\Quote\Api\CartTotalRepositoryInterface; +use Magento\Quote\Model\Quote; use Magento\Quote\Model\Quote\Address; class TotalsInformationManagementTest extends \PHPUnit\Framework\TestCase @@ -67,7 +68,7 @@ public function testCalculate(?string $carrierCode, ?string $carrierMethod, int { $cartId = 1; $cartMock = $this->createMock( - \Magento\Quote\Model\Quote::class + Quote::class ); $cartMock->expects($this->once())->method('getItemsCount')->willReturn(1); $cartMock->expects($this->once())->method('getIsVirtual')->willReturn(false); @@ -101,6 +102,72 @@ public function testCalculate(?string $carrierCode, ?string $carrierMethod, int $this->totalsInformationManagement->calculate($cartId, $addressInformationMock); } + /** + * Test case when shipping amount must be reset to 0 because of changed shipping method. + */ + public function testResetShippingAmount() + { + $cartId = 1; + $carrierCode = 'carrier_code'; + $carrierMethod = 'carrier_method'; + + $cartMock = $this->createMock(Quote::class); + $cartMock->method('getItemsCount') + ->willReturn(1); + $cartMock->method('getIsVirtual') + ->willReturn(false); + $this->cartRepositoryMock->method('get')->with($cartId)->willReturn($cartMock); + $this->cartTotalRepositoryMock->method('get')->with($cartId); + + $addressInformationMock = $this->createMock(TotalsInformationInterface::class); + $addressMock = $this->getMockBuilder(Address::class) + ->addMethods( + [ + 'setShippingMethod', + 'setCollectShippingRates' + ] + )->onlyMethods( + [ + 'getShippingMethod', + 'setShippingAmount', + 'setBaseShippingAmount', + ] + ) + ->disableOriginalConstructor() + ->getMock(); + $addressMock->method('getShippingMethod') + ->willReturn('flatrate_flatrate'); + $addressInformationMock->method('getAddress') + ->willReturn($addressMock); + $addressInformationMock->method('getShippingCarrierCode') + ->willReturn($carrierCode); + $addressInformationMock->method('getShippingMethodCode') + ->willReturn($carrierMethod); + $cartMock->method('setShippingAddress') + ->with($addressMock); + $cartMock->method('getShippingAddress') + ->willReturn($addressMock); + $addressMock->expects($this->once()) + ->method('setCollectShippingRates') + ->with(true) + ->willReturn($addressMock); + $addressMock->expects($this->once()) + ->method('setShippingAmount') + ->with(0) + ->willReturn($addressMock); + $addressMock->expects($this->once()) + ->method('setBaseShippingAmount') + ->with(0) + ->willReturn($addressMock); + $addressMock->expects($this->once()) + ->method('setShippingMethod') + ->with($carrierCode . '_' . $carrierMethod); + $cartMock->expects($this->once()) + ->method('collectTotals'); + + $this->totalsInformationManagement->calculate($cartId, $addressInformationMock); + } + /** * Data provider for testCalculate. * diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/BaseStockStatusSelectProcessor.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/BaseStockStatusSelectProcessor.php index cbeaf2cea90e0..73aeee118e603 100644 --- a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/BaseStockStatusSelectProcessor.php +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/BaseStockStatusSelectProcessor.php @@ -49,18 +49,18 @@ public function process(Select $select) { // Does not make sense to extend query if out of stock products won't appear in tables for indexing if ($this->stockConfig->isShowOutOfStock()) { - $select->join( - ['si' => $this->resource->getTableName('cataloginventory_stock_item')], - 'si.product_id = l.product_id', + $stockIndexTableName = $this->resource->getTableName('cataloginventory_stock_status'); + $select->joinInner( + ['child_stock_default' => $stockIndexTableName], + 'child_stock_default.product_id = l.product_id', [] - ); - $select->join( - ['si_parent' => $this->resource->getTableName('cataloginventory_stock_item')], - 'si_parent.product_id = l.parent_id', + )->joinInner( + ['parent_stock_default' => $stockIndexTableName], + 'parent_stock_default.product_id = le.entity_id', [] + )->where( + 'child_stock_default.stock_status = 1 OR parent_stock_default.stock_status = 0' ); - $select->where('si.is_in_stock = ?', Stock::STOCK_IN_STOCK); - $select->orWhere('si_parent.is_in_stock = ?', Stock::STOCK_OUT_OF_STOCK); } return $select; diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php index d00e5c72a4622..73810e8d4cf8b 100644 --- a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/Configurable.php @@ -9,7 +9,6 @@ use Magento\Catalog\Model\ResourceModel\Product\BaseSelectProcessorInterface; use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\BasePriceModifier; -use Magento\Framework\DB\Select; use Magento\Framework\Indexer\DimensionalIndexerInterface; use Magento\Framework\EntityManager\MetadataPool; use Magento\Catalog\Model\Indexer\Product\Price\TableMaintainer; @@ -18,7 +17,6 @@ use Magento\Catalog\Model\ResourceModel\Product\Indexer\Price\IndexTableStructure; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\ObjectManager; -use Magento\CatalogInventory\Model\Stock; /** * Configurable Products Price Indexer Resource model @@ -82,6 +80,11 @@ class Configurable implements DimensionalIndexerInterface */ private $baseSelectProcessor; + /** + * @var OptionsIndexerInterface + */ + private $optionsIndexer; + /** * @param BaseFinalPrice $baseFinalPrice * @param IndexTableStructureFactory $indexTableStructureFactory @@ -91,9 +94,9 @@ class Configurable implements DimensionalIndexerInterface * @param BasePriceModifier $basePriceModifier * @param bool $fullReindexAction * @param string $connectionName - * @param ScopeConfigInterface $scopeConfig + * @param ScopeConfigInterface|null $scopeConfig * @param BaseSelectProcessorInterface|null $baseSelectProcessor - * + * @param OptionsIndexerInterface|null $optionsIndexer * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -106,7 +109,8 @@ public function __construct( $fullReindexAction = false, $connectionName = 'indexer', ScopeConfigInterface $scopeConfig = null, - ?BaseSelectProcessorInterface $baseSelectProcessor = null + ?BaseSelectProcessorInterface $baseSelectProcessor = null, + ?OptionsIndexerInterface $optionsIndexer = null ) { $this->baseFinalPrice = $baseFinalPrice; $this->indexTableStructureFactory = $indexTableStructureFactory; @@ -119,6 +123,8 @@ public function __construct( $this->scopeConfig = $scopeConfig ?: ObjectManager::getInstance()->get(ScopeConfigInterface::class); $this->baseSelectProcessor = $baseSelectProcessor ?: ObjectManager::getInstance()->get(BaseSelectProcessorInterface::class); + $this->optionsIndexer = $optionsIndexer + ?: ObjectManager::getInstance()->get(OptionsIndexerInterface::class); } /** @@ -175,7 +181,8 @@ private function applyConfigurableOption( true ); - $this->fillTemporaryOptionsTable($temporaryOptionsTableName, $dimensions, $entityIds); + $indexTableName = $this->getMainTable($dimensions); + $this->optionsIndexer->execute($indexTableName, $temporaryOptionsTableName, $entityIds); $this->updateTemporaryTable($temporaryPriceTable->getTableName(), $temporaryOptionsTableName); $this->getConnection()->delete($temporaryOptionsTableName); @@ -183,54 +190,6 @@ private function applyConfigurableOption( return $this; } - /** - * Put data into catalog product price indexer config option temp table - * - * @param string $temporaryOptionsTableName - * @param array $dimensions - * @param array $entityIds - * - * @return void - * @throws \Exception - */ - private function fillTemporaryOptionsTable(string $temporaryOptionsTableName, array $dimensions, array $entityIds) - { - $metadata = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); - $linkField = $metadata->getLinkField(); - - $select = $this->getConnection()->select()->from( - ['i' => $this->getMainTable($dimensions)], - [] - )->join( - ['l' => $this->getTable('catalog_product_super_link')], - 'l.product_id = i.entity_id', - [] - )->join( - ['le' => $this->getTable('catalog_product_entity')], - 'le.' . $linkField . ' = l.parent_id', - [] - ); - - $this->baseSelectProcessor->process($select); - - $select->columns( - [ - 'le.entity_id', - 'customer_group_id', - 'website_id', - 'MIN(final_price)', - 'MAX(final_price)', - 'MIN(tier_price)', - ] - )->group( - ['le.entity_id', 'customer_group_id', 'website_id'] - ); - if ($entityIds !== null) { - $select->where('le.entity_id IN (?)', $entityIds, \Zend_Db::INT_TYPE); - } - $this->tableMaintainer->insertFromSelect($select, $temporaryOptionsTableName, []); - } - /** * Update data in the catalog product price indexer temp table * diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/OptionsIndexer.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/OptionsIndexer.php new file mode 100644 index 0000000000000..bceb95b00ccba --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/OptionsIndexer.php @@ -0,0 +1,47 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price; + +use Magento\Catalog\Model\Indexer\Product\Price\TableMaintainer; + +/** + * Configurable product options prices aggregator + */ +class OptionsIndexer implements OptionsIndexerInterface +{ + /** + * @var OptionsSelectBuilderInterface + */ + private $selectBuilder; + + /** + * @var TableMaintainer + */ + private $tableMaintainer; + + /** + * @param OptionsSelectBuilderInterface $selectBuilder + * @param TableMaintainer $tableMaintainer + */ + public function __construct( + OptionsSelectBuilderInterface $selectBuilder, + TableMaintainer $tableMaintainer + ) { + $this->selectBuilder = $selectBuilder; + $this->tableMaintainer = $tableMaintainer; + } + + /** + * @inheritdoc + */ + public function execute(string $indexTable, string $tempIndexTable, ?array $entityIds = null): void + { + $select = $this->selectBuilder->execute($indexTable, $entityIds); + $this->tableMaintainer->insertFromSelect($select, $tempIndexTable, []); + } +} diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/OptionsIndexerInterface.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/OptionsIndexerInterface.php new file mode 100644 index 0000000000000..401451b2b68f7 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/OptionsIndexerInterface.php @@ -0,0 +1,25 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price; + +use Magento\Framework\DB\Select; + +/** + * Configurable product options prices aggregator + */ +interface OptionsIndexerInterface +{ + /** + * Aggregate configurable product options prices and save it in a temporary index table + * + * @param string $indexTable + * @param string $tempIndexTable + * @param array|null $entityIds + */ + public function execute(string $indexTable, string $tempIndexTable, ?array $entityIds = null): void; +} diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/OptionsSelectBuilder.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/OptionsSelectBuilder.php new file mode 100644 index 0000000000000..8bd1c2054c88b --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/OptionsSelectBuilder.php @@ -0,0 +1,100 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price; + +use Magento\Catalog\Model\ResourceModel\Product\BaseSelectProcessorInterface; +use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Select; +use Magento\Framework\EntityManager\MetadataPool; + +/** + * Build select for aggregating configurable product options prices + */ +class OptionsSelectBuilder implements OptionsSelectBuilderInterface +{ + /** + * @var MetadataPool + */ + private $metadataPool; + + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @var string + */ + private $connectionName; + + /** + * @var BaseSelectProcessorInterface + */ + private $selectProcessor; + + /** + * @param BaseSelectProcessorInterface $selectProcessor + * @param MetadataPool $metadataPool + * @param ResourceConnection $resourceConnection + * @param string $connectionName + */ + public function __construct( + BaseSelectProcessorInterface $selectProcessor, + MetadataPool $metadataPool, + ResourceConnection $resourceConnection, + string $connectionName = 'indexer' + ) { + $this->selectProcessor = $selectProcessor; + $this->metadataPool = $metadataPool; + $this->resourceConnection = $resourceConnection; + $this->connectionName = $connectionName; + } + + /** + * @inheritdoc + */ + public function execute(string $indexTable, ?array $entityIds = null): Select + { + $connection = $this->resourceConnection->getConnection($this->connectionName); + $metadata = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); + $linkField = $metadata->getLinkField(); + + $select = $connection->select() + ->from( + ['i' => $indexTable], + [] + ) + ->join( + ['l' => $this->resourceConnection->getTableName('catalog_product_super_link', $this->connectionName)], + 'l.product_id = i.entity_id', + [] + ) + ->join( + ['le' => $this->resourceConnection->getTableName('catalog_product_entity', $this->connectionName)], + 'le.' . $linkField . ' = l.parent_id', + [] + ); + + $select->columns( + [ + 'le.entity_id', + 'customer_group_id', + 'website_id', + 'MIN(final_price)', + 'MAX(final_price)', + 'MIN(tier_price)', + ] + )->group( + ['le.entity_id', 'customer_group_id', 'website_id'] + ); + if ($entityIds !== null) { + $select->where('le.entity_id IN (?)', $entityIds, \Zend_Db::INT_TYPE); + } + return $this->selectProcessor->process($select); + } +} diff --git a/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/OptionsSelectBuilderInterface.php b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/OptionsSelectBuilderInterface.php new file mode 100644 index 0000000000000..bc5084671d5b3 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Model/ResourceModel/Product/Indexer/Price/OptionsSelectBuilderInterface.php @@ -0,0 +1,25 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price; + +use Magento\Framework\DB\Select; + +/** + * Aggregate configurable product options prices and save it in a temporary index table + */ +interface OptionsSelectBuilderInterface +{ + /** + * Return select with aggregated configurable product options prices + * + * @param string $indexTable + * @param array|null $entityIds + * @return Select + */ + public function execute(string $indexTable, ?array $entityIds = null): Select; +} diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductChangeOptionQtyActionGroup.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductChangeOptionQtyActionGroup.xml new file mode 100644 index 0000000000000..d9e450ffe1257 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductChangeOptionQtyActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminConfigurableProductChangeOptionQtyActionGroup"> + <arguments> + <argument name="optionLabel" type="string" defaultValue="{{colorConfigurableProductAttribute1.name}}"/> + <argument name="qty" type="string" defaultValue="1"/> + </arguments> + <fillField userInput="{{qty}}" selector="{{AdminProductFormConfigurationsSection.confProductQuantityCell(optionLabel)}}" stepKey="fillFieldQuantityForSecondAttributeOption"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductChangeOptionWeightActionGroup.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductChangeOptionWeightActionGroup.xml new file mode 100644 index 0000000000000..dce6659095705 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductChangeOptionWeightActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminConfigurableProductChangeOptionWeightActionGroup"> + <arguments> + <argument name="optionLabel" type="string" defaultValue="{{colorConfigurableProductAttribute1.name}}"/> + <argument name="weight" type="string" defaultValue="1"/> + </arguments> + <fillField userInput="{{weight}}" selector="{{AdminProductFormConfigurationsSection.confProductWeightCell(optionLabel)}}" stepKey="fillFieldQuantityForSecondAttributeOption"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductCreateConfigurationsAndSkipBulkByAttributeCodeActionGroup.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductCreateConfigurationsAndSkipBulkByAttributeCodeActionGroup.xml new file mode 100644 index 0000000000000..dd2e8a82f217b --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductCreateConfigurationsAndSkipBulkByAttributeCodeActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminConfigurableProductCreateConfigurationsAndSkipBulkByAttributeCodeActionGroup" extends="GenerateConfigurationsByAttributeCodeActionGroup"> + <annotations> + <description>EXTENDS: generateConfigurationsByAttributeCode. Skip quantity, price and images.</description> + </annotations> + <arguments> + <argument name="attributeCode" type="string" defaultValue="SomeString"/> + </arguments> + + <remove keyForRemoval="clickOnApplySingleQuantityToEachSku"/> + <remove keyForRemoval="enterAttributeQuantity"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductDisableConfigurationsActionGroup.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductDisableConfigurationsActionGroup.xml index 69b2c37b6e850..c8d4438835da8 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductDisableConfigurationsActionGroup.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductDisableConfigurationsActionGroup.xml @@ -12,8 +12,10 @@ <arguments> <argument name="productName" type="string" defaultValue="{{SimpleProduct.name}}"/> </arguments> + <scrollTo selector="{{AdminProductFormConfigurationsSection.currentVariations}}" stepKey="scrollToVariations" /> <click selector="{{AdminProductFormConfigurationsSection.actionsBtnByProductName(productName)}}" stepKey="clickToExpandActionsSelect"/> - <click selector="{{AdminProductFormConfigurationsSection.disableProductBtn}}" stepKey="clickDisableChildProduct"/> + <click selector="{{AdminProductFormConfigurationsSection.disableProductBtnActive}}" stepKey="clickDisableChildProduct"/> <see selector="{{AdminProductFormConfigurationsSection.confProductOptionStatusCell(productName)}}" userInput="Disabled" stepKey="seeConfigDisabled"/> + <scrollTo selector="{{AdminProductFormConfigurationsSection.sectionHeader}}" stepKey="scrollToSectionHeader" /> </actionGroup> </actionGroups> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductVerifyOptionPriceActionGroup.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductVerifyOptionPriceActionGroup.xml new file mode 100644 index 0000000000000..83db1ac3fa954 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductVerifyOptionPriceActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminConfigurableProductVerifyOptionPriceActionGroup"> + <arguments> + <argument name="optionLabel" type="string" defaultValue="{{colorConfigurableProductAttribute1.name}}"/> + <argument name="price" type="string" defaultValue="10"/> + </arguments> + <grabValueFrom selector="{{AdminProductFormConfigurationsSection.confProductPriceCell(optionLabel)}}" stepKey="getOptionPrice"/> + <assertEquals stepKey="assertEquals"> + <expectedResult type="string">{{price}}</expectedResult> + <actualResult type="variable">getOptionPrice</actualResult> + </assertEquals> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductVerifyOptionQtyActionGroup.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductVerifyOptionQtyActionGroup.xml new file mode 100644 index 0000000000000..78528cdaa0625 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductVerifyOptionQtyActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminConfigurableProductVerifyOptionQtyActionGroup"> + <arguments> + <argument name="optionLabel" type="string" defaultValue="{{colorConfigurableProductAttribute1.name}}"/> + <argument name="qty" type="string" defaultValue="1"/> + </arguments> + <grabValueFrom selector="{{AdminProductFormConfigurationsSection.confProductQuantityCell(optionLabel)}}" stepKey="getOptionQty"/> + <assertEquals stepKey="assertEquals"> + <expectedResult type="string">{{qty}}</expectedResult> + <actualResult type="variable">getOptionQty</actualResult> + </assertEquals> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductVerifyOptionStatusActionGroup.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductVerifyOptionStatusActionGroup.xml new file mode 100644 index 0000000000000..a54ea8044516a --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductVerifyOptionStatusActionGroup.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminConfigurableProductVerifyOptionStatusActionGroup"> + <arguments> + <argument name="optionLabel" type="string" defaultValue="{{colorConfigurableProductAttribute1.name}}"/> + <argument name="status" type="string" defaultValue="Enabled"/> + </arguments> + <see selector="{{AdminProductFormConfigurationsSection.confProductOptionStatusCell(optionLabel)}}" userInput="{{status}}" stepKey="verifyStatus"/> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductVerifyOptionWeightActionGroup.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductVerifyOptionWeightActionGroup.xml new file mode 100644 index 0000000000000..e9d528be18cf4 --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/AdminConfigurableProductVerifyOptionWeightActionGroup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="AdminConfigurableProductVerifyOptionWeightActionGroup"> + <arguments> + <argument name="optionLabel" type="string" defaultValue="{{colorConfigurableProductAttribute1.name}}"/> + <argument name="weight" type="string" defaultValue="1"/> + </arguments> + <grabValueFrom selector="{{AdminProductFormConfigurationsSection.confProductWeightCell(optionLabel)}}" stepKey="getOptionWeight"/> + <assertEquals stepKey="assertEquals"> + <expectedResult type="string">{{weight}}</expectedResult> + <actualResult type="variable">getOptionWeight</actualResult> + </assertEquals> + </actionGroup> +</actionGroups> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/GenerateConfigurationsByAttributeCodeActionGroup.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/GenerateConfigurationsByAttributeCodeActionGroup.xml index 80248cf5e00f8..b05099da8e85c 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/GenerateConfigurationsByAttributeCodeActionGroup.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/ActionGroup/GenerateConfigurationsByAttributeCodeActionGroup.xml @@ -20,11 +20,11 @@ <click selector="{{AdminCreateProductConfigurationsPanel.filters}}" stepKey="clickFilters"/> <fillField selector="{{AdminCreateProductConfigurationsPanel.attributeCode}}" userInput="{{attributeCode}}" stepKey="fillFilterAttributeCodeField"/> <click selector="{{AdminCreateProductConfigurationsPanel.applyFilters}}" stepKey="clickApplyFiltersButton"/> - <click selector="{{AdminCreateProductConfigurationsPanel.firstCheckbox}}" stepKey="clickOnFirstCheckbox"/> + <checkOption selector="{{AdminCreateProductConfigurationsPanel.firstCheckbox}}" stepKey="clickOnFirstCheckbox"/> <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickOnNextButton1"/> <click selector="{{AdminCreateProductConfigurationsPanel.selectAll}}" stepKey="clickOnSelectAll"/> <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickOnNextButton2"/> - <click selector="{{AdminCreateProductConfigurationsPanel.applySingleQuantityToEachSkus}}" stepKey="clickOnApplySingleQuantityToEachSku"/> + <checkOption selector="{{AdminCreateProductConfigurationsPanel.applySingleQuantityToEachSkus}}" stepKey="clickOnApplySingleQuantityToEachSku"/> <fillField selector="{{AdminCreateProductConfigurationsPanel.quantity}}" userInput="99" stepKey="enterAttributeQuantity"/> <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickOnNextButton3"/> <click selector="{{AdminCreateProductConfigurationsPanel.next}}" stepKey="clickOnNextButton4"/> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/AdminProductFormConfigurationsSection/AdminProductFormConfigurationsSection.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/AdminProductFormConfigurationsSection/AdminProductFormConfigurationsSection.xml index 264a3d4e75032..ddb62ea9601a1 100644 --- a/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/AdminProductFormConfigurationsSection/AdminProductFormConfigurationsSection.xml +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Section/AdminProductFormConfigurationsSection/AdminProductFormConfigurationsSection.xml @@ -19,6 +19,7 @@ <element name="currentVariationsAttributesCells" type="textarea" selector=".admin__control-fields[data-index='attributes']"/> <element name="currentVariationsCells" type="textarea" selector=".admin__control-fields[data-index='{{var}}']" parameterized="true"/> <element name="currentVariationsStatusCells" type="textarea" selector="._no-header[data-index='status']"/> + <element name="currentVariations" type="text" selector="[data-index=configurable-matrix]"/> <element name="currentVariationsAllRows" type="text" selector="[data-index=configurable-matrix] .data-row"/> <element name="currentVariationsProductImage" type="text" parameterized="true" selector="[data-index=configurable-matrix] .data-row:nth-of-type({{index}}) td[data-index=thumbnail_image_container] img"/> <element name="currentVariationsProductName" type="text" parameterized="true" selector="[data-index=configurable-matrix] .data-row:nth-of-type({{index}}) td[data-index=name_container]"/> @@ -34,6 +35,7 @@ <element name="addProduct" type="button" selector="//*[.='Attributes']/ancestor::tr/td[@data-index='attributes']//span[contains(text(), '{{var}}')]/ancestor::tr//a[text()='Choose a different Product']" parameterized="true"/> <element name="removeProductBtn" type="button" selector="//a[text()='Remove Product']"/> <element name="disableProductBtn" type="button" selector="//a[text()='Disable Product']"/> + <element name="disableProductBtnActive" type="button" selector="//*[@class='action-menu _active']//a[text()='Disable Product']"/> <element name="enableProductBtn" type="button" selector="//a[text()='Enable Product']"/> <element name="confProductSku" type="input" selector="//*[@name='configurable-matrix[{{arg}}][sku]']" parameterized="true"/> <element name="confProductNameCell" type="input" selector="//*[.='Attributes']/ancestor::tr//span[contains(text(), '{{var}}')]/ancestor::tr/td[@data-index='name_container']//input" parameterized="true"/> diff --git a/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminConfigurableProductAddNewOptionsTest.xml b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminConfigurableProductAddNewOptionsTest.xml new file mode 100644 index 0000000000000..02a3417a48ffc --- /dev/null +++ b/app/code/Magento/ConfigurableProduct/Test/Mftf/Test/AdminConfigurableProductAddNewOptionsTest.xml @@ -0,0 +1,203 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="AdminConfigurableProductAddNewOptionsTest"> + <annotations> + <stories value="Create configurable product"/> + <title value="Adding new options to configurable product should not affect existing options"/> + <description value="Adding new options to configurable product should not affect existing options"/> + <testCaseId value="AC-1714"/> + <useCaseId value="ACP2E-101"/> + <severity value="MAJOR"/> + <group value="ConfigurableProduct"/> + </annotations> + + <before> + <!-- Login as Admin --> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminPanel"/> + + <!-- Create Default Category --> + <createData entity="_defaultCategory" stepKey="createCategory"/> + + <!-- Create an attribute with three options --> + <createData entity="productAttributeWithTwoOptions" stepKey="createConfigProductAttribute"/> + <createData entity="productAttributeOption1" stepKey="createConfigProductAttributeOption1"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <createData entity="productAttributeOption2" stepKey="createConfigProductAttributeOption2"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <createData entity="productAttributeOption3" stepKey="createConfigProductAttributeOption3"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <createData entity="productAttributeOption4" stepKey="createConfigProductAttributeOption4"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <createData entity="productAttributeOption5" stepKey="createConfigProductAttributeOption5"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + + <!-- Add the attribute just created to default attribute set --> + <createData entity="AddToDefaultSet" stepKey="createConfigAddToAttributeSet"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + + <!-- Get the first option of the attribute created --> + <getData entity="ProductAttributeOptionGetter" index="1" stepKey="getConfigAttributeOption1"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </getData> + + <!-- Get the second option of the attribute created --> + <getData entity="ProductAttributeOptionGetter" index="2" stepKey="getConfigAttributeOption2"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </getData> + + <!-- Get the third option of the attribute created --> + <getData entity="ProductAttributeOptionGetter" index="3" stepKey="getConfigAttributeOption3"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </getData> + + <!-- Create Configurable product --> + <createData entity="BaseConfigurableProduct" stepKey="createConfigProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + + <!-- Create a simple product and give it the attribute with the first option --> + <createData entity="ApiSimpleOne" stepKey="createConfigChildProduct1"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getConfigAttributeOption1"/> + </createData> + + <!--Create a simple product and give it the attribute with the second option --> + <createData entity="ApiSimpleTwo" stepKey="createConfigChildProduct2"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getConfigAttributeOption2"/> + </createData> + + <!-- Create the configurable product --> + <createData entity="ConfigurableProductTwoOptions" stepKey="createConfigProductOption"> + <requiredEntity createDataKey="createConfigProduct"/> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getConfigAttributeOption1"/> + <requiredEntity createDataKey="getConfigAttributeOption2"/> + </createData> + + <!-- Add the first simple product to the configurable product --> + <createData entity="ConfigurableProductAddChild" stepKey="createConfigProductAddChild1"> + <requiredEntity createDataKey="createConfigProduct"/> + <requiredEntity createDataKey="createConfigChildProduct1"/> + </createData> + + <!-- Add the second simple product to the configurable product --> + <createData entity="ConfigurableProductAddChild" stepKey="createConfigProductAddChild2"> + <requiredEntity createDataKey="createConfigProduct"/> + <requiredEntity createDataKey="createConfigChildProduct2"/> + </createData> + </before> + <after> + <!-- Delete Created Data --> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createConfigProduct" stepKey="deleteConfigProduct"/> + <deleteData createDataKey="createConfigChildProduct1" stepKey="deleteConfigChildProduct1"/> + <deleteData createDataKey="createConfigChildProduct2" stepKey="deleteConfigChildProduct2"/> + <deleteData createDataKey="createConfigProductAttribute" stepKey="deleteAttribute"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + + <actionGroup ref="AdminProductPageOpenByIdActionGroup" stepKey="goToEditPage"> + <argument name="productId" value="$$createConfigProduct.id$$"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductDisableConfigurationsActionGroup" stepKey="disableOption1"> + <argument name="productName" value="$$getConfigAttributeOption1.label$$"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductCreateConfigurationsAndSkipBulkByAttributeCodeActionGroup" stepKey="generateConfigurations"> + <argument name="attributeCode" value="$$createConfigProductAttribute.attribute_code$$"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductVerifyOptionStatusActionGroup" stepKey="checkOption1Status"> + <argument name="optionLabel" value="$$getConfigAttributeOption1.label$$"/> + <argument name="status" value="Disabled"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductVerifyOptionStatusActionGroup" stepKey="checkOption3Status"> + <argument name="optionLabel" value="$$getConfigAttributeOption3.label$$"/> + <argument name="status" value="Enabled"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductDisableConfigurationsActionGroup" stepKey="disableOption3"> + <argument name="productName" value="$$getConfigAttributeOption3.label$$"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductChangeOptionQtyActionGroup" stepKey="changeOption3Qty"> + <argument name="optionLabel" value="$$getConfigAttributeOption3.label$$"/> + <argument name="qty" value="742"/> + </actionGroup> + + <actionGroup ref="ChangeConfigurableProductChildProductPriceActionGroup" stepKey="changeOption3Price"> + <argument name="productAttributes" value="$$getConfigAttributeOption3.label$$"/> + <argument name="productPrice" value="5"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductChangeOptionWeightActionGroup" stepKey="changeOption3Weight"> + <argument name="optionLabel" value="$$getConfigAttributeOption3.label$$"/> + <argument name="weight" value="3"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductCreateConfigurationsAndSkipBulkByAttributeCodeActionGroup" stepKey="generateConfigurations2"> + <argument name="attributeCode" value="$$createConfigProductAttribute.attribute_code$$"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductVerifyOptionStatusActionGroup" stepKey="checkOption1Status2"> + <argument name="optionLabel" value="$$getConfigAttributeOption1.label$$"/> + <argument name="status" value="Disabled"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductVerifyOptionStatusActionGroup" stepKey="checkOption3Status2"> + <argument name="optionLabel" value="$$getConfigAttributeOption3.label$$"/> + <argument name="status" value="Disabled"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductVerifyOptionQtyActionGroup" stepKey="checkOption3Qty"> + <argument name="optionLabel" value="$$getConfigAttributeOption3.label$$"/> + <argument name="qty" value="742"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductVerifyOptionPriceActionGroup" stepKey="checkOption3Price"> + <argument name="optionLabel" value="$$getConfigAttributeOption3.label$$"/> + <argument name="price" value="5"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductVerifyOptionWeightActionGroup" stepKey="checkOption3Weight"> + <argument name="optionLabel" value="$$getConfigAttributeOption3.label$$"/> + <argument name="weight" value="3"/> + </actionGroup> + + <actionGroup ref="GenerateConfigurationsByAttributeCodeActionGroup" stepKey="generateConfigurations3"> + <argument name="attributeCode" value="$$createConfigProductAttribute.attribute_code$$"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductVerifyOptionQtyActionGroup" stepKey="checkOption1Qty"> + <argument name="optionLabel" value="$$getConfigAttributeOption1.label$$"/> + <argument name="qty" value="99"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductVerifyOptionQtyActionGroup" stepKey="checkOption2Qty"> + <argument name="optionLabel" value="$$getConfigAttributeOption2.label$$"/> + <argument name="qty" value="99"/> + </actionGroup> + + <actionGroup ref="AdminConfigurableProductVerifyOptionQtyActionGroup" stepKey="checkOption3Qty3"> + <argument name="optionLabel" value="$$getConfigAttributeOption3.label$$"/> + <argument name="qty" value="99"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/ConfigurableProduct/etc/di.xml b/app/code/Magento/ConfigurableProduct/etc/di.xml index 01edbe6cd75ca..270e8ec746097 100644 --- a/app/code/Magento/ConfigurableProduct/etc/di.xml +++ b/app/code/Magento/ConfigurableProduct/etc/di.xml @@ -17,6 +17,8 @@ <preference for="Magento\ConfigurableProduct\Pricing\Price\LowestPriceOptionsProviderInterface" type="Magento\ConfigurableProduct\Pricing\Price\LowestPriceOptionsProvider" /> <preference for="Magento\ConfigurableProduct\Model\AttributeOptionProviderInterface" type="Magento\ConfigurableProduct\Model\AttributeOptionProvider" /> <preference for="Magento\ConfigurableProduct\Model\ResourceModel\Attribute\OptionSelectBuilderInterface" type="Magento\ConfigurableProduct\Model\ResourceModel\Attribute\OptionSelectBuilder" /> + <preference for="Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price\OptionsIndexerInterface" type="Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price\OptionsIndexer" /> + <preference for="Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price\OptionsSelectBuilderInterface" type="Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price\OptionsSelectBuilder" /> <type name="Magento\CatalogInventory\Model\Quote\Item\QuantityValidator\Initializer\Option"> <plugin name="configurable_product" type="Magento\ConfigurableProduct\Model\Quote\Item\QuantityValidator\Initializer\Option\Plugin\ConfigurableProduct" sortOrder="50" /> @@ -54,7 +56,7 @@ <type name="Magento\Sales\Model\ResourceModel\Report\Bestsellers"> <arguments> <argument name="ignoredProductTypes" xsi:type="array"> - <item name="configurable" xsi:type="const">\Magento\ConfigurableProduct\Model\Product\Type\Configurable::TYPE_CODE</item> + <item name="configurable" xsi:type="const">Magento\ConfigurableProduct\Model\Product\Type\Configurable::TYPE_CODE</item> </argument> </arguments> </type> @@ -208,6 +210,12 @@ <argument name="baseSelectProcessor" xsi:type="object">Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price\BaseStockStatusSelectProcessor</argument> </arguments> </type> + <type name="Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price\OptionsSelectBuilder"> + <arguments> + <argument name="selectProcessor" xsi:type="object">Magento\ConfigurableProduct\Model\ResourceModel\Product\Indexer\Price\BaseStockStatusSelectProcessor</argument> + <argument name="connectionName" xsi:type="string">indexer</argument> + </arguments> + </type> <type name="Magento\ConfigurableProduct\Plugin\Model\ResourceModel\Product"> <arguments> <argument name="productIndexer" xsi:type="object">Magento\Catalog\Model\Indexer\Product\Full</argument> diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/catalog/product/edit/attribute/steps/summary.phtml b/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/catalog/product/edit/attribute/steps/summary.phtml index 379e129b68c7e..439fd0c934403 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/catalog/product/edit/attribute/steps/summary.phtml +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/templates/catalog/product/edit/attribute/steps/summary.phtml @@ -5,6 +5,7 @@ */ /* @var $block \Magento\ConfigurableProduct\Block\Adminhtml\Product\Steps\Summary */ + ?> <div data-bind="scope: '<?= /* @noEscape */ $block->getComponentName() ?>'"> <h2 class="steps-wizard-title"><?= $block->escapeHtml( @@ -52,8 +53,12 @@ "<?= /* @noEscape */ $block->getComponentName() ?>": { "component": "Magento_ConfigurableProduct/js/variations/steps/summary", "appendTo": "<?= /* @noEscape */ $block->getParentComponentName() ?>", - "variationsComponent": "<?= /* @noEscape */ $block->getData('config/form') ?>.configurableVariations", - "modalComponent": "<?= /* @noEscape */ $block->getData('config/form') ?>.configurableModal" + "variationsComponent": "<?= /* @noEscape */ $block->getData('config/form') + ?>.configurableVariations", + "modalComponent": "<?= /* @noEscape */ $block->getData('config/form') + ?>.configurableModal", + "matrixGridComponent": "<?= /* @noEscape */ $block->getData('config/form') + ?>.configurable.configurable-matrix" } } } diff --git a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/steps/summary.js b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/steps/summary.js index ac952ca531a34..f5c9382af0bc3 100644 --- a/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/steps/summary.js +++ b/app/code/Magento/ConfigurableProduct/view/adminhtml/web/js/variations/steps/summary.js @@ -17,7 +17,8 @@ define([ defaults: { modules: { variationsComponent: '${ $.variationsComponent }', - modalComponent: '${ $.modalComponent }' + modalComponent: '${ $.modalComponent }', + matrixGridComponent: '${ $.matrixGridComponent }' }, notificationMessage: { text: null, @@ -96,11 +97,16 @@ define([ variationsKeys = [], gridExisting = [], gridNew = [], - gridDeleted = []; + gridDeleted = [], + matrixGridData = this.matrixGridComponent() ? + _.indexBy(this.matrixGridComponent().getUnionInsertData(), 'variationKey') : {}; this.variations = []; _.each(variations, function (options) { var product, images, sku, name, quantity, price, variation, + variationsKey = this.variationsComponent().getVariationKey(options), + productDataFromGrid = matrixGridData[variationsKey] || {}, + productDataFromWizard = {}, productId = this.variationsComponent().getProductIdByOptions(options); if (productId) { @@ -117,40 +123,61 @@ define([ }, ''); quantity = getSectionValue(this.quantityFieldName, options); - if (!quantity && productId) { - quantity = product[this.quantityFieldName]; + if (quantity) { + productDataFromWizard[this.quantityFieldName] = quantity; } price = getSectionValue('price', options); - if (!price) { - price = productId ? product.price : productPrice; + if (price) { + productDataFromWizard.price = price; } if (productId && !images.file) { images = product.images; } + productDataFromGrid = _.pick( + productDataFromGrid, + 'sku', + 'name', + 'weight', + 'status', + 'price', + 'qty' + ); + + if (productDataFromGrid.hasOwnProperty('qty')) { + productDataFromGrid[this.quantityFieldName] = productDataFromGrid.qty; + } + delete productDataFromGrid.qty; + product = _.pick( + product || {}, + 'sku', + 'name', + 'weight', + 'status', + 'price', + this.quantityFieldName + ); variation = { options: options, images: images, sku: sku, name: name, - price: price, + price: productPrice, productId: productId, weight: productWeight, editable: true }; variation[this.quantityFieldName] = quantity; + variation = _.extend(variation, product, productDataFromGrid, productDataFromWizard); if (productId) { - variation.sku = product.sku; - variation.weight = product.weight; - variation.name = product.name; gridExisting.push(this.prepareRowForGrid(variation)); } else { gridNew.push(this.prepareRowForGrid(variation)); } this.variations.push(variation); - variationsKeys.push(this.variationsComponent().getVariationKey(options)); + variationsKeys.push(variationsKey); }, this); _.each(_.omit(this.variationsComponent().productAttributesMap, variationsKeys), function (productId) { diff --git a/app/code/Magento/Cron/Model/Config/Backend/Sitemap.php b/app/code/Magento/Cron/Model/Config/Backend/Sitemap.php index d10ed4979fa37..49556f75b37f0 100644 --- a/app/code/Magento/Cron/Model/Config/Backend/Sitemap.php +++ b/app/code/Magento/Cron/Model/Config/Backend/Sitemap.php @@ -21,12 +21,12 @@ class Sitemap extends \Magento\Framework\App\Config\Value /** * Cron string path for product alerts */ - const CRON_STRING_PATH = 'crontab/default/jobs/sitemap_generate/schedule/cron_expr'; + public const CRON_STRING_PATH = 'crontab/default/jobs/sitemap_generate/schedule/cron_expr'; /** * Cron mode path */ - const CRON_MODEL_PATH = 'crontab/default/jobs/sitemap_generate/run/model'; + public const CRON_MODEL_PATH = 'crontab/default/jobs/sitemap_generate/run/model'; /** * @var \Magento\Framework\App\Config\ValueFactory @@ -73,8 +73,12 @@ public function __construct( */ public function afterSave() { - $time = $this->getData('groups/generate/fields/time/value'); - $frequency = $this->getData('groups/generate/fields/frequency/value'); + $time = $this->getData('groups/generate/fields/time/value') ?: + explode( + ',', + $this->_config->getValue('sitemap/generate/time', $this->getScope(), $this->getScopeId()) ?: '0,0,0' + ); + $frequency = $this->getValue(); $cronExprArray = [ (int)($time[1] ?? 0), //Minute diff --git a/app/code/Magento/Customer/Test/Mftf/Test/StorefrontAddProductToCartVerifyThatErrorMessageShouldNotDisappearTest.xml b/app/code/Magento/Customer/Test/Mftf/Test/StorefrontAddProductToCartVerifyThatErrorMessageShouldNotDisappearTest.xml new file mode 100644 index 0000000000000..ccca330f5ff1a --- /dev/null +++ b/app/code/Magento/Customer/Test/Mftf/Test/StorefrontAddProductToCartVerifyThatErrorMessageShouldNotDisappearTest.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontAddProductToCartVerifyThatErrorMessageShouldNotDisappearTest"> + <annotations> + <title value="Adding a product to cart from product detail page with higher quantity then available when synchronize widget products with backend storage enabled"/> + <description value="Adding a product to cart from product detail page with higher quantity then available when synchronize widget products with backend storage enabled"/> + <features value="Module/ Catalog"/> + <severity value="AVERAGE"/> + <testCaseId value="AC-1571"/> + <useCaseId value="ACP2E-23"/> + <stories value="[Magento Cloud] Error message in PDP disappearing quickly"/> + <group value="customer"/> + </annotations> + + <before> + <!-- Set in Stores > Configuration > Catalog > Catalog > Recently Viewed/Compared Products > Synchronize Widget Products With Backend Storage = "Yes" --> + <magentoCLI command="config:set {{EnableSynchronizeWidgetProductsWithBackendStorage.path}} {{EnableSynchronizeWidgetProductsWithBackendStorage.value}}" stepKey="setEnableSynchronizeWidgetProductsWithBackendStorage"/> + <createData entity="_defaultCategory" stepKey="createCategory"/> + <createData entity="_defaultProduct" stepKey="createSimpleProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <!--Reindex and flush cache--> + <magentoCLI command="cron:run --group=index" stepKey="runCronReindex"/> + </before> + <after> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <magentoCLI command="config:set {{DisableSynchronizeWidgetProductsWithBackendStorage.path}} {{DisableSynchronizeWidgetProductsWithBackendStorage.value}}" stepKey="setDisableSynchronizeWidgetProductsWithBackendStorage"/> + <!--Reindex and flush cache--> + <magentoCLI command="cron:run --group=index" stepKey="runCronReindex"/> + </after> + + <waitForPageLoad time="60" stepKey="waitForPageLoad"/> + <actionGroup ref="StorefrontOpenProductEntityPageActionGroup" stepKey="openProductPage"> + <argument name="product" value="$$createSimpleProduct$$"/> + </actionGroup> + + <fillField selector="{{StorefrontProductInfoMainSection.qty}}" userInput="1001" stepKey="fillQuantity"/> + + <actionGroup ref="StorefrontProductPageAddSimpleProductToCartActionGroup" stepKey="addProductToCart"/> + <!-- Check that error remains --> + <actionGroup ref="StorefrontAssertProductAddToCartErrorMessageActionGroup" stepKey="assertFailure"> + <argument name="message" value="The requested qty is not available"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Review/Test/Mftf/Section/StorefrontMyProductReviewsSection.xml b/app/code/Magento/Review/Test/Mftf/Section/StorefrontMyProductReviewsSection.xml index 64c0c42df20a5..0275aa02fdcb9 100644 --- a/app/code/Magento/Review/Test/Mftf/Section/StorefrontMyProductReviewsSection.xml +++ b/app/code/Magento/Review/Test/Mftf/Section/StorefrontMyProductReviewsSection.xml @@ -11,5 +11,6 @@ <section name="StorefrontMyProductReviewsSection"> <element name="reviewDescription" type="text" selector="//td[@data-th='Review']"/> <element name="reviewRating" type="text" selector="//tbody/tr[position()='{{reviewNumber}}']/td/div/div/span[contains(@style,'width: {{reviewValue}};')]" parameterized="true"/> + <element name="reviewSeeDetails" type="text" selector="#my-reviews-table > tbody > tr:nth-child({{row}}) > td.col.actions > a" parameterized="true" /> </section> </sections> diff --git a/app/code/Magento/Review/Test/Mftf/Test/StorefrontVerifyMultipleProductRatingsOnCategoryPageTest.xml b/app/code/Magento/Review/Test/Mftf/Test/StorefrontVerifyMultipleProductRatingsOnCategoryPageTest.xml new file mode 100644 index 0000000000000..7e5a3b2a44ed3 --- /dev/null +++ b/app/code/Magento/Review/Test/Mftf/Test/StorefrontVerifyMultipleProductRatingsOnCategoryPageTest.xml @@ -0,0 +1,162 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontVerifyMultipleProductRatingsOnCategoryPageTest"> + <annotations> + <features value="Review"/> + <stories value="Review By Customers"/> + <title value="StoreFront inconsistent products rating on category page"/> + <description value="Check if product rating is the same at list, table view on PLP and customers account."/> + <severity value="AVERAGE"/> + <useCaseId value="ACP2E-111"/> + <testCaseId value="AC-1187"/> + </annotations> + <before> + <!-- Enable singe store view to view ratings --> + <magentoCLI command="config:set general/single_store_mode/enabled 1" stepKey="enabledSingleStoreMode"/> + <!-- Login --> + <actionGroup ref="AdminLoginActionGroup" stepKey="LoginAsAdmin"/> + <!-- Create product and category --> + <createData entity="SimpleSubCategory" stepKey="category"/> + <createData entity="SimpleProduct" stepKey="createProduct1"> + <requiredEntity createDataKey="category"/> + </createData> + <createData entity="SimpleProduct" stepKey="createProduct2"> + <requiredEntity createDataKey="category"/> + </createData> + </before> + <after> + <!-- Delete reviews --> + <actionGroup ref="AdminOpenReviewsPageActionGroup" stepKey="openAllReviewsPage"/> + <actionGroup ref="AdminDeleteReviewsByUserNicknameActionGroup" stepKey="deleteCustomerReview"/> + <actionGroup ref="ClearFiltersAdminDataGridActionGroup" stepKey="clearNickNameReviewFilters"/> + <!-- Delete customer --> + <actionGroup ref="AdminDeleteCustomerActionGroup" stepKey="deleteCustomer"> + <argument name="customerEmail" value="CustomerEntityOne.email"/> + </actionGroup> + <!-- delete Category and Products --> + <deleteData createDataKey="createProduct1" stepKey="deleteProduct1"/> + <deleteData createDataKey="createProduct2" stepKey="deleteProduct2"/> + <deleteData createDataKey="category" stepKey="deleteCategory"/> + <!-- Logout --> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + <!-- Disable single store view back --> + <magentoCLI command="config:set general/single_store_mode/enabled 0" stepKey="enabledSingleStoreMode"/> + </after> + + <!-- Go to frontend and make a user account and login with it --> + <actionGroup ref="SignUpNewUserFromStorefrontActionGroup" stepKey="signUpNewUser"> + <argument name="Customer" value="CustomerEntityOne"/> + </actionGroup> + <!-- Go to the first product view page --> + <actionGroup ref="StorefrontOpenProductPageActionGroup" stepKey="openStorefrontProductPage1"> + <argument name="productUrl" value="$$createProduct1.custom_attributes[url_key]$$"/> + </actionGroup> + <!-- Click on reviews and add reviews with current user --> + <click selector="{{StorefrontProductReviewsSection.reviewsTab}}" stepKey="openReviewTab1"/> + <!-- Set product rating stars --> + <actionGroup ref="StorefrontSetProductRatingStarsActionGroup" stepKey="setQualityStars1"> + <argument name="ratingName" value="Quality"/> + <argument name="stars" value="3"/> + </actionGroup> + <actionGroup ref="StorefrontSetProductRatingStarsActionGroup" stepKey="setValueStars1"> + <argument name="ratingName" value="Value"/> + <argument name="stars" value="4"/> + </actionGroup> + <actionGroup ref="StorefrontSetProductRatingStarsActionGroup" stepKey="setPriceStars1"> + <argument name="ratingName" value="Price"/> + <argument name="stars" value="5"/> + </actionGroup> + <!-- Click on reviews and add reviews with current user --> + <actionGroup ref="StorefrontAddProductReviewActionGroup" stepKey="addReview1"/> + <!-- Go to second product view page --> + <actionGroup ref="StorefrontOpenProductPageActionGroup" stepKey="openStorefrontProductPage2"> + <argument name="productUrl" value="$$createProduct2.custom_attributes[url_key]$$"/> + </actionGroup> + <!-- Click on reviews and add reviews with current user --> + <click selector="{{StorefrontProductReviewsSection.reviewsTab}}" stepKey="openReviewTab2"/> + <!-- Set product rating stars --> + <actionGroup ref="StorefrontSetProductRatingStarsActionGroup" stepKey="setQualityStars2"> + <argument name="ratingName" value="Quality"/> + <argument name="stars" value="1"/> + </actionGroup> + <actionGroup ref="StorefrontSetProductRatingStarsActionGroup" stepKey="setValueStars2"> + <argument name="ratingName" value="Value"/> + <argument name="stars" value="1"/> + </actionGroup> + <actionGroup ref="StorefrontSetProductRatingStarsActionGroup" stepKey="setPriceStars2"> + <argument name="ratingName" value="Price"/> + <argument name="stars" value="1"/> + </actionGroup> + <!-- Add review --> + <actionGroup ref="StorefrontAddProductReviewActionGroup" stepKey="addReview2"/> + <!-- Approve all reviews --> + <actionGroup ref="AdminOpenPendingReviewsPageActionGroup" stepKey="openPendingReviewsPage"/> + <actionGroup ref="AdminApproveAllReviewsActionGroup" stepKey="approveAllCustomerReview"/> + <!--Start Checking reviews --> + <!-- Navigate to PLP and check product rating for list and table views --> + <actionGroup ref="StorefrontOpenHomePageActionGroup" stepKey="amOnStoreViewHomePage"/> + <!-- Open products in category section --> + <actionGroup ref="StorefrontGoToCategoryPageActionGroup" stepKey="goToStorefrontCategoryPage"> + <argument name="categoryName" value="$$category.name$$" /> + </actionGroup> + <!-- Grid mode is default --> + <!-- Check stars at grid view for first product --> + <grabAttributeFrom selector="#rating-result_$$createProduct1.id$$ span" userInput="style" stepKey="getFirstProductStarsAtGridView"/> + <assertEquals stepKey="checkFirstProductStarsAtGridView"> + <actualResult type="string">$getFirstProductStarsAtGridView</actualResult> + <expectedResult type="string">width: 80%;</expectedResult> + </assertEquals> + <!-- Check stars at grid view for second product --> + <grabAttributeFrom selector="#rating-result_$$createProduct2.id$$ span" userInput="style" stepKey="getSecondProductStarsAtGridView"/> + <assertEquals stepKey="checkSecondProductStarsAtGridView"> + <actualResult type="string">$getSecondProductStarsAtGridView</actualResult> + <expectedResult type="string">width: 20%;</expectedResult> + </assertEquals> + <!-- Switch category view to list mode --> + <actionGroup ref="StorefrontSwitchCategoryViewToListModeActionGroup" stepKey="switchCategoryViewToListMode"/> + <!-- Check stars at list view for first product --> + <grabAttributeFrom selector="#rating-result_$$createProduct1.id$$ span" userInput="style" stepKey="getFirstProductStarsAtListView"/> + <assertEquals stepKey="checkFirstProductStarsAtListView"> + <actualResult type="string">$getFirstProductStarsAtListView</actualResult> + <expectedResult type="string">width: 80%;</expectedResult> + </assertEquals> + <!-- Check stars at list view for second product --> + <grabAttributeFrom selector="#rating-result_$$createProduct2.id$$ span" userInput="style" stepKey="getSecondProductStarsAtListView"/> + <assertEquals stepKey="checkSecondProductStarsAtListView"> + <actualResult type="string">$getSecondProductStarsAtListView</actualResult> + <expectedResult type="string">width: 20%;</expectedResult> + </assertEquals> + <!-- Navigate to user account and check product ratings --> + <!-- Checking that all 3 reviews on the My Product Reviews page have one star ratings --> + <actionGroup ref="StorefrontNavigateToMyProductReviewsPageActionGroup" stepKey="navigateToProductReviewsPage"/> + <seeElement selector="{{StorefrontMyProductReviewsSection.reviewRating('2', '80%')}}" stepKey="seeFirstOneStarReviewOnMyReviews"/> + <seeElement selector="{{StorefrontMyProductReviewsSection.reviewRating('1', '20%')}}" stepKey="seeSecondOneStarReviewOnMyReviews"/> + <!-- Click on see details button of two reviews --> + <!-- Navigate to user account and check product ratings --> + <amOnPage url="review/customer/" stepKey="amOnCustomerReviewPage2"/> + <!-- Click on second product review --> + <click selector="{{StorefrontMyProductReviewsSection.reviewSeeDetails('1')}}" stepKey="clickFirstReviewRow"/> + <grabAttributeFrom selector="#rating-result_$$createProduct2.id$$ span" userInput="style" stepKey="getSecondProductResultStarsUnderProductName1"/> + <assertEquals stepKey="checkSecondProductResultStarsUnderProductName1"> + <actualResult type="string">$getSecondProductResultStarsUnderProductName1</actualResult> + <expectedResult type="string">width: 20%;</expectedResult> + </assertEquals> + <!-- Navigate to user account and check product ratings --> + <amOnPage url="review/customer/" stepKey="amOnCustomerReviewPage3"/> + <!-- Click on first product review --> + <click selector="{{StorefrontMyProductReviewsSection.reviewSeeDetails('2')}}" stepKey="clickSecondReviewRow"/> + <grabAttributeFrom selector="#rating-result_$$createProduct1.id$$ span" userInput="style" stepKey="getFirstProductResultStarsUnderProductName2"/> + <assertEquals stepKey="checkFirstProductResultStarsUnderProductName2"> + <actualResult type="string">$getFirstProductResultStarsUnderProductName2</actualResult> + <expectedResult type="string">width: 80%;</expectedResult> + </assertEquals> + </test> +</tests> diff --git a/app/code/Magento/Review/view/frontend/templates/customer/list.phtml b/app/code/Magento/Review/view/frontend/templates/customer/list.phtml index d44dc203dab85..f4a7e67c9f312 100644 --- a/app/code/Magento/Review/view/frontend/templates/customer/list.phtml +++ b/app/code/Magento/Review/view/frontend/templates/customer/list.phtml @@ -44,6 +44,7 @@ $reviewHelper = $block->getData('reviewHelper'); <div class="rating-summary"> <span class="label"><span><?= $escaper->escapeHtml(__('Rating')) ?>:</span></span> <div class="rating-result" + id="rating-result_<?= /* @noEscape */ $block->escapeHtml($review->getId()) ?>" title="<?= /* @noEscape */ ((int)$review->getSum() / (int)$review->getCount()) ?>%"> <span class="rating_<?= $escaper->escapeUrl($review->getReviewId())?>"> <span> @@ -52,9 +53,11 @@ $reviewHelper = $block->getData('reviewHelper'); </span> </div> </div> - <?= /* @noEscape */ $secureRenderer->renderStyleAsTag( + <?= /* @noEscape */ + $secureRenderer->renderStyleAsTag( "width:" . /* @noEscape */ ((int)$review->getSum() / (int)$review->getCount()) . "%;", - 'div.rating-summary div.rating-result>span.rating_' . $escaper->escapeUrl($review->getReviewId()) + 'div.rating-summary div.rating-result>span.rating_' . + $escaper->escapeUrl($review->getReviewId()) ) ?> <?php endif; ?> </td> diff --git a/app/code/Magento/Review/view/frontend/templates/customer/view.phtml b/app/code/Magento/Review/view/frontend/templates/customer/view.phtml index d1d9d3b7ccae7..7207c191202ce 100644 --- a/app/code/Magento/Review/view/frontend/templates/customer/view.phtml +++ b/app/code/Magento/Review/view/frontend/templates/customer/view.phtml @@ -43,14 +43,15 @@ $product = $block->getProductData(); <span class="rating-label"> <span><?= $block->escapeHtml($_rating->getRatingCode()) ?></span> </span> - <div class="rating-result" + <div class="rating-result <?= $block->escapeHtml($_rating->getRatingCode()) ?>" id="rating-div-<?= $block->escapeHtml($ratingId) ?>" title="<?= /* @noEscape */ $rating ?>%"> <span> <span><?= /* @noEscape */ $rating ?>%</span> </span> </div> - <?= /* @noEscape */ $secureRenderer->renderStyleAsTag( + <?= /* @noEscape */ + $secureRenderer->renderStyleAsTag( "width:" . /* @noEscape */ $rating . "%", 'div#rating-div-'.$_rating->getRatingId(). '>span:first-child' diff --git a/app/code/Magento/Review/view/frontend/templates/helper/summary.phtml b/app/code/Magento/Review/view/frontend/templates/helper/summary.phtml index 93afe4a815f61..c5ec6bc3f63a5 100644 --- a/app/code/Magento/Review/view/frontend/templates/helper/summary.phtml +++ b/app/code/Magento/Review/view/frontend/templates/helper/summary.phtml @@ -19,7 +19,10 @@ $urlForm = $block->getReviewsUrl() . '#review-form'; <?php if ($rating):?> <div class="rating-summary"> <span class="label"><span><?= $block->escapeHtml(__('Rating')) ?>:</span></span> - <div class="rating-result" title="<?= $block->escapeHtmlAttr($rating); ?>%"> + <div class="rating-result" + id="rating-result_<?= $block->escapeHtmlAttr($block->getProduct()->getId()) ?>" + title="<?= $block->escapeHtmlAttr($rating) ?>%" + > <span> <span> <span itemprop="ratingValue"><?= $block->escapeHtml($rating); ?> @@ -28,9 +31,10 @@ $urlForm = $block->getReviewsUrl() . '#review-form'; </span> </div> </div> - <?= /* @noEscape */ $secureRenderer->renderStyleAsTag( - "width:" . $block->escapeHtmlAttr($rating) . "%", - 'div.rating-summary div.rating-result>span:first-child' + <?= /* @noEscape */ + $secureRenderer->renderStyleAsTag( + 'width:' . $block->escapeHtmlAttr($rating) . '%', + '#rating-result_' . $block->getProduct()->getId() . ' span' ) ?> <?php endif;?> <div class="reviews-actions"> diff --git a/app/code/Magento/SalesGraphQl/etc/schema.graphqls b/app/code/Magento/SalesGraphQl/etc/schema.graphqls index 288f648b50ee9..e6eb8efa294ef 100644 --- a/app/code/Magento/SalesGraphQl/etc/schema.graphqls +++ b/app/code/Magento/SalesGraphQl/etc/schema.graphqls @@ -69,7 +69,7 @@ type OrderAddress @doc(description: "Contains detailed information about an orde country_code: CountryCodeEnum @doc(description: "The customer's country.") street: [String!]! @doc(description: "An array of strings that define the street number and name.") company: String @doc(description: "The customer's company.") - telephone: String! @doc(description: "The telephone number.") + telephone: String @doc(description: "The telephone number.") fax: String @doc(description: "The fax number.") postcode: String @doc(description: "The customer's ZIP or postal code.") city: String! @doc(description: "The city or town.") diff --git a/app/code/Magento/SalesRule/Helper/CartFixedDiscount.php b/app/code/Magento/SalesRule/Helper/CartFixedDiscount.php index 025f2c7215964..e3ad412a8e783 100644 --- a/app/code/Magento/SalesRule/Helper/CartFixedDiscount.php +++ b/app/code/Magento/SalesRule/Helper/CartFixedDiscount.php @@ -51,6 +51,8 @@ public function calculateShippingAmountWhenAppliedToShipping( ): float { $shippingAmount = (float) $address->getShippingAmount(); if ($shippingAmount == 0.0) { + $addressQty = $this->getAddressQty($address); + $address->setItemQty($addressQty); $address->setCollectShippingRates(true); $address->collectShippingRates(); $shippingRates = $address->getAllShippingRates(); @@ -82,7 +84,7 @@ public function getDiscountAmount( float $baseRuleTotals, string $discountType ): float { - $ratio = $baseItemPrice * $qty / $baseRuleTotals; + $ratio = $baseRuleTotals != 0 ? $baseItemPrice * $qty / $baseRuleTotals : 0; return $this->deltaPriceRound->round( $ruleDiscount * $ratio, $discountType @@ -109,7 +111,7 @@ public function getDiscountedAmountProportionally( string $discountType ): float { $baseItemPriceTotal = $baseItemPrice * $qty - $baseItemDiscountAmount; - $ratio = $baseItemPriceTotal / $baseRuleTotalsDiscount; + $ratio = $baseRuleTotalsDiscount != 0 ? $baseItemPriceTotal / $baseRuleTotalsDiscount : 0; $discountAmount = $this->deltaPriceRound->round($ruleDiscount * $ratio, $discountType); return $discountAmount; } @@ -127,7 +129,7 @@ public function getShippingDiscountAmount( float $shippingAmount, float $quoteBaseSubtotal ): float { - $ratio = $shippingAmount / $quoteBaseSubtotal; + $ratio = $quoteBaseSubtotal != 0 ? $shippingAmount / $quoteBaseSubtotal : 0; return $this->priceCurrency ->roundPrice( $rule->getDiscountAmount() * $ratio @@ -241,4 +243,35 @@ public function getAvailableDiscountAmount( } return $availableDiscountAmount; } + + /** + * Get address quantity. + * + * @param AddressInterface $address + * @return float + */ + private function getAddressQty(AddressInterface $address): float + { + $addressQty = 0; + $items = array_filter( + $address->getAllItems(), + function ($item) { + return !$item->getProduct()->isVirtual() && !$item->getParentItem(); + } + ); + foreach ($items as $item) { + if ($item->getHasChildren() && $item->isShipSeparately()) { + foreach ($item->getChildren() as $child) { + if ($child->getProduct()->isVirtual()) { + continue; + } + $addressQty += $child->getTotalQty(); + } + } else { + $addressQty += (float)$item->getQty(); + } + } + + return (float)$addressQty; + } } diff --git a/app/code/Magento/SalesRule/Model/ResourceModel/Report/Rule/Createdat.php b/app/code/Magento/SalesRule/Model/ResourceModel/Report/Rule/Createdat.php index 4ebf01d145fe9..342fa8363da09 100644 --- a/app/code/Magento/SalesRule/Model/ResourceModel/Report/Rule/Createdat.php +++ b/app/code/Magento/SalesRule/Model/ResourceModel/Report/Rule/Createdat.php @@ -89,10 +89,13 @@ protected function _aggregateByOrder($aggregationField, $from, $to) 0 ), 'total_amount' => $connection->getIfNullSql( - 'SUM((base_subtotal - ' . $connection->getIfNullSql( + 'SUM(((base_subtotal - ' . $connection->getIfNullSql( 'base_subtotal_canceled', 0 - ) . ' - ' . $connection->getIfNullSql( + ) . ' + ' . $connection->getIfNullSql( + 'base_shipping_amount - ' . $connection->getIfNullSql('base_shipping_canceled', 0), + 0 + ) . ') - ' . $connection->getIfNullSql( 'ABS(base_discount_amount) - ABS(' . $connection->getIfNullSql('base_discount_canceled', 0) . ')', 0 @@ -119,17 +122,20 @@ protected function _aggregateByOrder($aggregationField, $from, $to) 0 ), 'total_amount_actual' => $connection->getIfNullSql( - 'SUM((base_subtotal_invoiced - ' . $connection->getIfNullSql( + 'SUM(((base_subtotal_invoiced - ' . $connection->getIfNullSql( 'base_subtotal_refunded', 0 - ) . ' - ' . $connection->getIfNullSql( + ) . ' + ' . $connection->getIfNullSql( + 'base_shipping_invoiced - ' . $connection->getIfNullSql('base_shipping_refunded', 0), + 0 + ) . ') - ' . $connection->getIfNullSql( 'ABS(base_discount_invoiced) - ABS(' . $connection->getIfNullSql('base_discount_refunded', 0) . ')', 0 ) . ' + ' . $connection->getIfNullSql( 'base_tax_invoiced - ' . $connection->getIfNullSql('base_tax_refunded', 0), 0 - ) . ') * base_to_global_rate)', + ) . ') * base_to_global_rate)', 0 ), ]; diff --git a/app/code/Magento/SalesRule/Test/Mftf/Test/StoreFrontAddZeroPriceProductToCardWithFixedAmountPriceRule.xml b/app/code/Magento/SalesRule/Test/Mftf/Test/StoreFrontAddZeroPriceProductToCardWithFixedAmountPriceRule.xml new file mode 100644 index 0000000000000..9bcefdcfc3144 --- /dev/null +++ b/app/code/Magento/SalesRule/Test/Mftf/Test/StoreFrontAddZeroPriceProductToCardWithFixedAmountPriceRule.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + /** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StoreFrontAddZeroPriceProductToCardWithFixedAmountPriceRule"> + <annotations> + <features value="SalesRule"/> + <stories value="Zero price product added to cart while cart price rule is Fixed amount discount for whole cart"/> + <title value="Add zero price product to cart when fixed amount discount for the whole cart rule is active"/> + <description value="Customers should be able to add a zero price product from the storefront when the cart price rule of type Fixed amount discount for the whole cart is configured"/> + <severity value="MAJOR"/> + <testCaseId value="AC-1618"/> + <useCaseId value="ACP2E-285"/> + <group value="SalesRule"/> + </annotations> + + <before> + <!-- Create Simple Product and Category --> + <createData entity="_defaultCategory" stepKey="createPreReqCategory"/> + <createData entity="SimpleProduct_zero" stepKey="createSimpleProduct"> + <requiredEntity createDataKey="createPreReqCategory"/> + </createData> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + + <!-- Create cart price rule --> + <actionGroup ref="AdminCreateCartPriceRuleActionGroup" stepKey="createCartPriceRule"> + <argument name="ruleName" value="PriceRuleWithCondition"/> + </actionGroup> + </before> + + <after> + <!-- Delete the cart price rule we made during the test --> + <actionGroup ref="DeleteCartPriceRuleByName" stepKey="cleanUpRule"> + <argument name="ruleName" value="{{PriceRuleWithCondition.name}}"/> + </actionGroup> + <deleteData createDataKey="createSimpleProduct" stepKey="deleteProduct"/> + <deleteData createDataKey="createPreReqCategory" stepKey="deleteCategory"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="adminLogout"/> + </after> + + <!-- Go to the storefront and add the product to the cart --> + <actionGroup ref="AddSimpleProductToCartActionGroup" stepKey="gotoAndAddProductToCart"> + <argument name="product" value="$$createSimpleProduct$$"/> + </actionGroup> + + <!-- Go to the cart page and verify we see the product --> + <actionGroup ref="StorefrontCartPageOpenActionGroup" stepKey="gotoCart"/> + <actionGroup ref="AssertStorefrontCheckoutCartItemsActionGroup" stepKey="assertProductItemInCheckOutCart"> + <argument name="productName" value="$$createSimpleProduct.name$$"/> + <argument name="productPrice" value="$$createSimpleProduct.price$$"/> + <argument name="subtotal" value="$$createSimpleProduct.price$$" /> + <argument name="qty" value="1"/> + </actionGroup> + </test> +</tests> diff --git a/app/code/Magento/Theme/Controller/Result/MessagePlugin.php b/app/code/Magento/Theme/Controller/Result/MessagePlugin.php index 20d0165e84740..056f1b21a9be8 100644 --- a/app/code/Magento/Theme/Controller/Result/MessagePlugin.php +++ b/app/code/Magento/Theme/Controller/Result/MessagePlugin.php @@ -7,7 +7,7 @@ use Magento\Framework\Controller\Result\Json; use Magento\Framework\Controller\ResultInterface; -use Magento\Framework\Message\MessageInterface; +use Magento\Framework\Stdlib\Cookie\CookieSizeLimitReachedException; use Magento\Framework\Translate\Inline\ParserInterface; use Magento\Framework\Translate\InlineInterface; use Magento\Framework\Session\Config\ConfigInterface; @@ -22,7 +22,7 @@ class MessagePlugin /** * Cookies name for messages */ - const MESSAGES_COOKIES_NAME = 'mage-messages'; + public const MESSAGES_COOKIES_NAME = 'mage-messages'; /** * @var \Magento\Framework\Stdlib\CookieManagerInterface @@ -101,11 +101,44 @@ public function afterRenderResult( ResultInterface $result ) { if (!($subject instanceof Json)) { - $this->setCookie($this->getMessages()); + $newMessages = []; + foreach ($this->messageManager->getMessages(true)->getItems() as $message) { + $newMessages[] = [ + 'type' => $message->getType(), + 'text' => $this->interpretationStrategy->interpret($message), + ]; + } + if (!empty($newMessages)) { + $this->setMessages($this->getCookiesMessages(), $newMessages); + } } return $result; } + /** + * Add new messages to already existing ones. + * + * In case if there are too many messages clear old messages. + * + * @param array $oldMessages + * @param array $newMessages + * @throws CookieSizeLimitReachedException + */ + private function setMessages(array $oldMessages, array $newMessages): void + { + $messages = array_merge($oldMessages, $newMessages); + try { + $this->setCookie($messages); + } catch (CookieSizeLimitReachedException $e) { + if (empty($oldMessages)) { + throw $e; + } + + array_shift($oldMessages); + $this->setMessages($oldMessages, $newMessages); + } + } + /** * Set 'mage-messages' cookie with 'messages' array * @@ -166,24 +199,6 @@ private function convertMessageText(string $text): string return $text; } - /** - * Return messages array and clean message manager messages - * - * @return array - */ - protected function getMessages() - { - $messages = $this->getCookiesMessages(); - /** @var MessageInterface $message */ - foreach ($this->messageManager->getMessages(true)->getItems() as $message) { - $messages[] = [ - 'type' => $message->getType(), - 'text' => $this->interpretationStrategy->interpret($message), - ]; - } - return $messages; - } - /** * Return messages stored in cookies * diff --git a/app/code/Magento/Theme/CustomerData/Messages.php b/app/code/Magento/Theme/CustomerData/Messages.php index adb3f7df27395..15575b1c1c7e0 100644 --- a/app/code/Magento/Theme/CustomerData/Messages.php +++ b/app/code/Magento/Theme/CustomerData/Messages.php @@ -7,6 +7,7 @@ namespace Magento\Theme\CustomerData; use Magento\Customer\CustomerData\SectionSourceInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Message\ManagerInterface as MessageManager; use Magento\Framework\Message\MessageInterface; use Magento\Framework\View\Element\Message\InterpretationStrategyInterface; @@ -28,18 +29,27 @@ class Messages implements SectionSourceInterface */ private $interpretationStrategy; + /** + * @var MessagesProviderInterface + */ + private $messageProvider; + /** * Constructor * * @param MessageManager $messageManager * @param InterpretationStrategyInterface $interpretationStrategy + * @param MessagesProviderInterface|null $messageProvider */ public function __construct( MessageManager $messageManager, - InterpretationStrategyInterface $interpretationStrategy + InterpretationStrategyInterface $interpretationStrategy, + ?MessagesProviderInterface $messageProvider = null ) { $this->messageManager = $messageManager; $this->interpretationStrategy = $interpretationStrategy; + $this->messageProvider = $messageProvider + ?? ObjectManager::getInstance()->get(MessagesProviderInterface::class); } /** @@ -47,19 +57,20 @@ public function __construct( */ public function getSectionData() { - $messages = $this->messageManager->getMessages(true); + $messages = $this->messageProvider->getMessages(); + $messageResponse = array_reduce( + $messages->getItems(), + function (array $result, MessageInterface $message) { + $result[] = [ + 'type' => $message->getType(), + 'text' => $this->interpretationStrategy->interpret($message) + ]; + return $result; + }, + [] + ); return [ - 'messages' => array_reduce( - $messages->getItems(), - function (array $result, MessageInterface $message) { - $result[] = [ - 'type' => $message->getType(), - 'text' => $this->interpretationStrategy->interpret($message) - ]; - return $result; - }, - [] - ), + 'messages' => $messageResponse ]; } } diff --git a/app/code/Magento/Theme/CustomerData/MessagesProvider.php b/app/code/Magento/Theme/CustomerData/MessagesProvider.php new file mode 100644 index 0000000000000..360f348cb2a45 --- /dev/null +++ b/app/code/Magento/Theme/CustomerData/MessagesProvider.php @@ -0,0 +1,42 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Theme\CustomerData; + +use Magento\Framework\Message\Collection; +use Magento\Framework\Message\ManagerInterface as MessageManager; + +class MessagesProvider implements MessagesProviderInterface +{ + /** + * Manager messages + * + * @var MessageManager + */ + private $messageManager; + + /** + * Constructor + * + * @param MessageManager $messageManager + */ + public function __construct( + MessageManager $messageManager + ) { + $this->messageManager = $messageManager; + } + + /** + * Return collection object of messages from session + * + * @return Collection + */ + public function getMessages() : Collection + { + return $this->messageManager->getMessages(true); + } +} diff --git a/app/code/Magento/Theme/CustomerData/MessagesProviderInterface.php b/app/code/Magento/Theme/CustomerData/MessagesProviderInterface.php new file mode 100644 index 0000000000000..cfa281e8aab42 --- /dev/null +++ b/app/code/Magento/Theme/CustomerData/MessagesProviderInterface.php @@ -0,0 +1,20 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Theme\CustomerData; + +use Magento\Framework\Message\Collection; + +interface MessagesProviderInterface +{ + /** + * Get the messages stored in session before session clear + * + * @return Collection + */ + public function getMessages(): Collection; +} diff --git a/app/code/Magento/Theme/Test/Unit/Controller/Result/MessagePluginTest.php b/app/code/Magento/Theme/Test/Unit/Controller/Result/MessagePluginTest.php index 5f365e6a82eae..da679ba524867 100644 --- a/app/code/Magento/Theme/Test/Unit/Controller/Result/MessagePluginTest.php +++ b/app/code/Magento/Theme/Test/Unit/Controller/Result/MessagePluginTest.php @@ -12,6 +12,7 @@ use Magento\Framework\Message\Collection; use Magento\Framework\Message\ManagerInterface; use Magento\Framework\Message\MessageInterface; +use Magento\Framework\Serialize\Serializer\Json as JsonSerializer; use Magento\Framework\Stdlib\Cookie\CookieMetadataFactory; use Magento\Framework\Stdlib\Cookie\PublicCookieMetadata; use Magento\Framework\Stdlib\CookieManagerInterface; @@ -28,21 +29,21 @@ class MessagePluginTest extends TestCase { /** @var MessagePlugin */ - protected $model; + private $model; /** @var CookieManagerInterface|MockObject */ - protected $cookieManagerMock; + private $cookieManagerMock; /** @var CookieMetadataFactory|MockObject */ - protected $cookieMetadataFactoryMock; + private $cookieMetadataFactoryMock; /** @var ManagerInterface|MockObject */ - protected $managerMock; + private $managerMock; /** @var InterpretationStrategyInterface|MockObject */ - protected $interpretationStrategyMock; + private $interpretationStrategyMock; - /** @var \Magento\Framework\Serialize\Serializer\Json|MockObject */ + /** @var JsonSerializer|MockObject */ private $serializerMock; /** @var InlineInterface|MockObject */ @@ -51,25 +52,17 @@ class MessagePluginTest extends TestCase /** * @var ConfigInterface|MockObject */ - protected $sessionConfigMock; + private $sessionConfigMock; protected function setUp(): void { - $this->cookieManagerMock = $this->getMockBuilder(CookieManagerInterface::class) - ->getMockForAbstractClass(); - $this->cookieMetadataFactoryMock = $this->getMockBuilder(CookieMetadataFactory::class) - ->disableOriginalConstructor() - ->getMock(); - $this->managerMock = $this->getMockBuilder(ManagerInterface::class) - ->getMockForAbstractClass(); - $this->interpretationStrategyMock = $this->getMockBuilder(InterpretationStrategyInterface::class) - ->getMockForAbstractClass(); - $this->serializerMock = $this->getMockBuilder(\Magento\Framework\Serialize\Serializer\Json::class) - ->getMock(); - $this->inlineTranslateMock = $this->getMockBuilder(InlineInterface::class) - ->getMockForAbstractClass(); - $this->sessionConfigMock = $this->getMockBuilder(ConfigInterface::class) - ->getMockForAbstractClass(); + $this->cookieManagerMock = $this->createMock(CookieManagerInterface::class); + $this->cookieMetadataFactoryMock = $this->createMock(CookieMetadataFactory::class); + $this->managerMock = $this->createMock(ManagerInterface::class); + $this->interpretationStrategyMock = $this->createMock(InterpretationStrategyInterface::class); + $this->serializerMock = $this->createMock(JsonSerializer::class); + $this->inlineTranslateMock = $this->createMock(InlineInterface::class); + $this->sessionConfigMock = $this->createMock(ConfigInterface::class); $this->model = new MessagePlugin( $this->cookieManagerMock, @@ -85,9 +78,7 @@ protected function setUp(): void public function testAfterRenderResultJson() { /** @var Json|MockObject $resultMock */ - $resultMock = $this->getMockBuilder(Json::class) - ->disableOriginalConstructor() - ->getMock(); + $resultMock = $this->createMock(Json::class); $this->cookieManagerMock->expects($this->never()) ->method('setPublicCookie'); @@ -114,19 +105,12 @@ public function testAfterRenderResult() $messages = array_merge($existingMessages, $messages); /** @var Redirect|MockObject $resultMock */ - $resultMock = $this->getMockBuilder(Redirect::class) - ->disableOriginalConstructor() - ->getMock(); - + $resultMock = $this->createMock(Redirect::class); /** @var PublicCookieMetadata|MockObject $cookieMetadataMock */ - $cookieMetadataMock = $this->getMockBuilder(PublicCookieMetadata::class) - ->disableOriginalConstructor() - ->getMock(); - + $cookieMetadataMock = $this->createMock(PublicCookieMetadata::class); $this->cookieMetadataFactoryMock->expects($this->once()) ->method('createPublicCookieMetadata') ->willReturn($cookieMetadataMock); - $this->cookieManagerMock->expects($this->once()) ->method('setPublicCookie') ->with( @@ -157,25 +141,20 @@ function ($data) { ); /** @var MessageInterface|MockObject $messageMock */ - $messageMock = $this->getMockBuilder(MessageInterface::class) - ->getMock(); + $messageMock = $this->createMock(MessageInterface::class); $messageMock->expects($this->once()) ->method('getType') ->willReturn($messageType); - $this->interpretationStrategyMock->expects($this->once()) ->method('interpret') ->with($messageMock) ->willReturn($messageText); /** @var Collection|MockObject $collectionMock */ - $collectionMock = $this->getMockBuilder(Collection::class) - ->disableOriginalConstructor() - ->getMock(); + $collectionMock = $this->createMock(Collection::class); $collectionMock->expects($this->once()) ->method('getItems') ->willReturn([$messageMock]); - $this->managerMock->expects($this->once()) ->method('getMessages') ->with(true, null) @@ -187,43 +166,27 @@ function ($data) { public function testAfterRenderResultWithNoMessages() { /** @var Redirect|MockObject $resultMock */ - $resultMock = $this->getMockBuilder(Redirect::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->cookieManagerMock->expects($this->once()) - ->method('getCookie') - ->with( - MessagePlugin::MESSAGES_COOKIES_NAME - ) - ->willReturn(json_encode([])); + $resultMock = $this->createMock(Redirect::class); - $this->serializerMock->expects($this->once()) - ->method('unserialize') - ->willReturnCallback( - function ($data) { - return json_decode($data, true); - } - ); + $this->cookieManagerMock->expects($this->never()) + ->method('getCookie'); + $this->serializerMock->expects($this->never()) + ->method('unserialize'); $this->serializerMock->expects($this->never()) ->method('serialize'); /** @var Collection|MockObject $collectionMock */ - $collectionMock = $this->getMockBuilder(Collection::class) - ->disableOriginalConstructor() - ->getMock(); + $collectionMock = $this->createMock(Collection::class); $collectionMock->expects($this->once()) ->method('getItems') ->willReturn([]); - $this->managerMock->expects($this->once()) ->method('getMessages') ->with(true, null) ->willReturn($collectionMock); $this->cookieMetadataFactoryMock->expects($this->never()) - ->method('createPublicCookieMetadata') - ->willReturn(null); + ->method('createPublicCookieMetadata'); $this->assertEquals($resultMock, $this->model->afterRenderResult($resultMock, $resultMock)); } @@ -240,19 +203,12 @@ public function testAfterRenderResultWithoutExisting() ]; /** @var Redirect|MockObject $resultMock */ - $resultMock = $this->getMockBuilder(Redirect::class) - ->disableOriginalConstructor() - ->getMock(); - + $resultMock = $this->createMock(Redirect::class); /** @var PublicCookieMetadata|MockObject $cookieMetadataMock */ - $cookieMetadataMock = $this->getMockBuilder(PublicCookieMetadata::class) - ->disableOriginalConstructor() - ->getMock(); - + $cookieMetadataMock = $this->createMock(PublicCookieMetadata::class); $this->cookieMetadataFactoryMock->expects($this->once()) ->method('createPublicCookieMetadata') ->willReturn($cookieMetadataMock); - $this->cookieManagerMock->expects($this->once()) ->method('setPublicCookie') ->with( @@ -283,25 +239,20 @@ function ($data) { ); /** @var MessageInterface|MockObject $messageMock */ - $messageMock = $this->getMockBuilder(MessageInterface::class) - ->getMock(); + $messageMock = $this->createMock(MessageInterface::class); $messageMock->expects($this->once()) ->method('getType') ->willReturn($messageType); - $this->interpretationStrategyMock->expects($this->once()) ->method('interpret') ->with($messageMock) ->willReturn($messageText); /** @var Collection|MockObject $collectionMock */ - $collectionMock = $this->getMockBuilder(Collection::class) - ->disableOriginalConstructor() - ->getMock(); + $collectionMock = $this->createMock(Collection::class); $collectionMock->expects($this->once()) ->method('getItems') ->willReturn([$messageMock]); - $this->managerMock->expects($this->once()) ->method('getMessages') ->with(true, null) @@ -322,19 +273,12 @@ public function testAfterRenderResultWithWrongJson() ]; /** @var Redirect|MockObject $resultMock */ - $resultMock = $this->getMockBuilder(Redirect::class) - ->disableOriginalConstructor() - ->getMock(); - + $resultMock = $this->createMock(Redirect::class); /** @var PublicCookieMetadata|MockObject $cookieMetadataMock */ - $cookieMetadataMock = $this->getMockBuilder(PublicCookieMetadata::class) - ->disableOriginalConstructor() - ->getMock(); - + $cookieMetadataMock = $this->createMock(PublicCookieMetadata::class); $this->cookieMetadataFactoryMock->expects($this->once()) ->method('createPublicCookieMetadata') ->willReturn($cookieMetadataMock); - $this->cookieManagerMock->expects($this->once()) ->method('setPublicCookie') ->with( @@ -351,7 +295,6 @@ public function testAfterRenderResultWithWrongJson() $this->serializerMock->expects($this->never()) ->method('unserialize'); - $this->serializerMock->expects($this->once()) ->method('serialize') ->willReturnCallback( @@ -361,25 +304,20 @@ function ($data) { ); /** @var MessageInterface|MockObject $messageMock */ - $messageMock = $this->getMockBuilder(MessageInterface::class) - ->getMock(); + $messageMock = $this->createMock(MessageInterface::class); $messageMock->expects($this->once()) ->method('getType') ->willReturn($messageType); - $this->interpretationStrategyMock->expects($this->once()) ->method('interpret') ->with($messageMock) ->willReturn($messageText); /** @var Collection|MockObject $collectionMock */ - $collectionMock = $this->getMockBuilder(Collection::class) - ->disableOriginalConstructor() - ->getMock(); + $collectionMock = $this->createMock(Collection::class); $collectionMock->expects($this->once()) ->method('getItems') ->willReturn([$messageMock]); - $this->managerMock->expects($this->once()) ->method('getMessages') ->with(true, null) @@ -400,15 +338,9 @@ public function testAfterRenderResultWithWrongArray() ]; /** @var Redirect|MockObject $resultMock */ - $resultMock = $this->getMockBuilder(Redirect::class) - ->disableOriginalConstructor() - ->getMock(); - + $resultMock = $this->createMock(Redirect::class); /** @var PublicCookieMetadata|MockObject $cookieMetadataMock */ - $cookieMetadataMock = $this->getMockBuilder(PublicCookieMetadata::class) - ->disableOriginalConstructor() - ->getMock(); - + $cookieMetadataMock = $this->createMock(PublicCookieMetadata::class); $this->cookieMetadataFactoryMock->expects($this->once()) ->method('createPublicCookieMetadata') ->willReturn($cookieMetadataMock); @@ -443,25 +375,20 @@ function ($data) { ); /** @var MessageInterface|MockObject $messageMock */ - $messageMock = $this->getMockBuilder(MessageInterface::class) - ->getMock(); + $messageMock = $this->createMock(MessageInterface::class); $messageMock->expects($this->once()) ->method('getType') ->willReturn($messageType); - $this->interpretationStrategyMock->expects($this->once()) ->method('interpret') ->with($messageMock) ->willReturn($messageText); /** @var Collection|MockObject $collectionMock */ - $collectionMock = $this->getMockBuilder(Collection::class) - ->disableOriginalConstructor() - ->getMock(); + $collectionMock = $this->createMock(Collection::class); $collectionMock->expects($this->once()) ->method('getItems') ->willReturn([$messageMock]); - $this->managerMock->expects($this->once()) ->method('getMessages') ->with(true, null) @@ -485,19 +412,12 @@ public function testAfterRenderResultWithAllowedInlineTranslate(): void ]; /** @var Redirect|MockObject $resultMock */ - $resultMock = $this->getMockBuilder(Redirect::class) - ->disableOriginalConstructor() - ->getMock(); - + $resultMock = $this->createMock(Redirect::class); /** @var PublicCookieMetadata|MockObject $cookieMetadataMock */ - $cookieMetadataMock = $this->getMockBuilder(PublicCookieMetadata::class) - ->disableOriginalConstructor() - ->getMock(); - + $cookieMetadataMock = $this->createMock(PublicCookieMetadata::class); $this->cookieMetadataFactoryMock->expects($this->once()) ->method('createPublicCookieMetadata') ->willReturn($cookieMetadataMock); - $this->cookieManagerMock->expects($this->once()) ->method('setPublicCookie') ->with( @@ -528,12 +448,10 @@ function ($data) { ); /** @var MessageInterface|MockObject $messageMock */ - $messageMock = $this->getMockBuilder(MessageInterface::class) - ->getMock(); + $messageMock = $this->createMock(MessageInterface::class); $messageMock->expects($this->once()) ->method('getType') ->willReturn($messageType); - $this->interpretationStrategyMock->expects($this->once()) ->method('interpret') ->with($messageMock) @@ -544,13 +462,10 @@ function ($data) { ->willReturn(true); /** @var Collection|MockObject $collectionMock */ - $collectionMock = $this->getMockBuilder(Collection::class) - ->disableOriginalConstructor() - ->getMock(); + $collectionMock = $this->createMock(Collection::class); $collectionMock->expects($this->once()) ->method('getItems') ->willReturn([$messageMock]); - $this->managerMock->expects($this->once()) ->method('getMessages') ->with(true, null) @@ -562,27 +477,17 @@ function ($data) { public function testSetCookieWithCookiePath() { /** @var Redirect|MockObject $resultMock */ - $resultMock = $this->getMockBuilder(Redirect::class) - ->disableOriginalConstructor() - ->getMock(); - + $resultMock = $this->createMock(Redirect::class); /** @var PublicCookieMetadata|MockObject $cookieMetadataMock */ - $cookieMetadataMock = $this->getMockBuilder(PublicCookieMetadata::class) - ->disableOriginalConstructor() - ->getMock(); - + $cookieMetadataMock = $this->createMock(PublicCookieMetadata::class); $this->cookieMetadataFactoryMock->expects($this->once()) ->method('createPublicCookieMetadata') ->willReturn($cookieMetadataMock); /** @var MessageInterface|MockObject $messageMock */ - $messageMock = $this->getMockBuilder(MessageInterface::class) - ->getMock(); - + $messageMock = $this->createMock(MessageInterface::class); /** @var Collection|MockObject $collectionMock */ - $collectionMock = $this->getMockBuilder(Collection::class) - ->disableOriginalConstructor() - ->getMock(); + $collectionMock = $this->createMock(Collection::class); $collectionMock->expects($this->once()) ->method('getItems') ->willReturn([$messageMock]); diff --git a/app/code/Magento/Theme/Test/Unit/CustomerData/MessagesTest.php b/app/code/Magento/Theme/Test/Unit/CustomerData/MessagesTest.php index cb07730c9d8d1..44ccb2b8b3dfb 100644 --- a/app/code/Magento/Theme/Test/Unit/CustomerData/MessagesTest.php +++ b/app/code/Magento/Theme/Test/Unit/CustomerData/MessagesTest.php @@ -12,6 +12,7 @@ use Magento\Framework\Message\MessageInterface; use Magento\Framework\View\Element\Message\InterpretationStrategyInterface; use Magento\Theme\CustomerData\Messages; +use Magento\Theme\CustomerData\MessagesProviderInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -22,6 +23,11 @@ class MessagesTest extends TestCase */ protected $messageManager; + /** + * @var MessagesProviderInterface|MockObject + */ + private $messageProvider; + /** * @var InterpretationStrategyInterface|MockObject */ @@ -36,10 +42,16 @@ protected function setUp(): void { $this->messageManager = $this->getMockBuilder(ManagerInterface::class) ->getMock(); + $this->messageProvider = $this->getMockBuilder(MessagesProviderInterface::class) + ->getMock(); $this->messageInterpretationStrategy = $this->createMock( InterpretationStrategyInterface::class ); - $this->object = new Messages($this->messageManager, $this->messageInterpretationStrategy); + $this->object = new Messages( + $this->messageManager, + $this->messageInterpretationStrategy, + $this->messageProvider + ); } public function testGetSectionData() @@ -59,9 +71,8 @@ public function testGetSectionData() ->method('interpret') ->with($msg) ->willReturn($msgText); - $this->messageManager->expects($this->once()) + $this->messageProvider->expects($this->once()) ->method('getMessages') - ->with(true, null) ->willReturn($msgCollection); $msgCollection->expects($this->once()) ->method('getItems') diff --git a/app/code/Magento/Theme/etc/di.xml b/app/code/Magento/Theme/etc/di.xml index d6fe3f8fef355..6ea495e2702ae 100644 --- a/app/code/Magento/Theme/etc/di.xml +++ b/app/code/Magento/Theme/etc/di.xml @@ -19,6 +19,7 @@ <preference for="Magento\Framework\View\Model\PageLayout\Config\BuilderInterface" type="Magento\Theme\Model\PageLayout\Config\Builder"/> <preference for="Magento\Theme\Model\Design\Config\MetadataProviderInterface" type="Magento\Theme\Model\Design\Config\MetadataProvider"/> <preference for="Magento\Theme\Model\Theme\StoreThemesResolverInterface" type="Magento\Theme\Model\Theme\StoreThemesResolver"/> + <preference for="Magento\Theme\CustomerData\MessagesProviderInterface" type="Magento\Theme\CustomerData\MessagesProvider"/> <type name="Magento\Theme\Model\Config"> <arguments> <argument name="configCache" xsi:type="object">Magento\Framework\App\Cache\Type\Config</argument> diff --git a/app/code/Magento/Ui/view/base/web/js/lib/validation/rules.js b/app/code/Magento/Ui/view/base/web/js/lib/validation/rules.js index 5f7571aa7246d..b9a147e2609de 100644 --- a/app/code/Magento/Ui/view/base/web/js/lib/validation/rules.js +++ b/app/code/Magento/Ui/view/base/web/js/lib/validation/rules.js @@ -1101,6 +1101,22 @@ define([ return moment.utc(value, params.dateFormat).isSameOrBefore(moment.utc()); }, $.mage.__('The Date of Birth should not be greater than today.') + ], + 'validate-no-utf8mb4-characters': [ + function (value) { + var validator = this, + message = $.mage.__('Please remove invalid characters: {0}.'), + matches = value.match(/(?:[\uD800-\uDBFF][\uDC00-\uDFFF])/g), + result = matches === null; + + if (!result) { + validator.charErrorMessage = message.replace('{0}', matches.join()); + } + + return result; + }, function () { + return this.charErrorMessage; + } ] }, function (data) { return { diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductWithDisabledProductTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductWithDisabledProductTest.php new file mode 100644 index 0000000000000..da16dd9ba139a --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductWithDisabledProductTest.php @@ -0,0 +1,156 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Bundle; + +use Exception; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\Helper\CompareArraysRecursively; +use Magento\TestFramework\TestCase\GraphQlAbstract; + +/** + * class BundleProductWithDisabledProductTest + * + * Test Bundle product with disabled product and verify graphQl response + */ +class BundleProductWithDisabledProductTest extends GraphQlAbstract +{ + /** + * @var CompareArraysRecursively + */ + private $compareArraysRecursively; + + /** + * @inheritDoc + */ + protected function setUp(): void + { + $objectManager = Bootstrap::getObjectManager(); + $this->compareArraysRecursively = $objectManager->create(CompareArraysRecursively::class); + } + + /** + * Test Bundle product with disabled product test. + * + * @magentoApiDataFixture Magento/Bundle/_files/bundle_product_with_disabled_product_options.php + * + * @throws Exception + */ + public function testBundleProductWithMultipleOptionsWithDisabledProduct(): void + { + $categorySku = 'c1'; + $query + = <<<QUERY +{ + categoryList(filters: {url_path: {eq: "{$categorySku}"}}) { + children_count + id + url_path + url_key + id + products { + total_count + items { + ... on BundleProduct { + id + categories { + id + name + description + } + dynamic_price + price_range { + minimum_price { + regular_price { + value + currency + } + } + maximum_price { + regular_price { + value + currency + } + } + } + sku + name + short_description { + html + } + description { + html + } + stock_status + __typename + url_key + items { + position + uid + option_id + options { + uid + label + id + price + quantity + product { + ... on VirtualProduct { + sku + stock_status + name + } + price_range { + minimum_price { + regular_price { + value + currency + } + } + maximum_price { + regular_price { + value + currency + } + } + } + } + } + } + } + } + } + } +} +QUERY; + + $response = $this->graphQlQuery($query); + $this->assertBundleProduct($response); + } + + /** + * Assert bundle product response. + * + * @param array $response + */ + private function assertBundleProduct(array $response): void + { + $this->assertNotEmpty( + $response['categoryList'][0]['products']['items'], + 'Precondition failed: "items" must not be empty' + ); + $productItems = end($response['categoryList'][0]['products']['items'])['items']; + $this->assertEquals(3, count($productItems[0]['options'])); + $this->assertEquals('virtual1', $productItems[0]['options'][0]['product']['sku']); + $this->assertEquals('virtual2', $productItems[0]['options'][1]['product']['sku']); + $this->assertEquals('virtual3', $productItems[0]['options'][2]['product']['sku']); + $this->assertEquals(3, count($productItems[1]['options'])); + $this->assertEquals('virtual1', $productItems[1]['options'][0]['product']['sku']); + $this->assertEquals('virtual2', $productItems[1]['options'][1]['product']['sku']); + $this->assertEquals('virtual3', $productItems[1]['options'][2]['product']['sku']); + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/Fixtures/CustomerPlaceOrder.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/Fixtures/CustomerPlaceOrder.php index 0386d414b8682..28021304ce029 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/Fixtures/CustomerPlaceOrder.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/Fixtures/CustomerPlaceOrder.php @@ -64,15 +64,19 @@ public function __construct( * * @param array $customerLogin * @param array $productData + * @param array|null $addressData * @return array */ - public function placeOrderWithBundleProduct(array $customerLogin, array $productData): array - { + public function placeOrderWithBundleProduct( + array $customerLogin, + array $productData, + ?array $addressData = null + ): array { $this->customerLogin = $customerLogin; $this->createCustomerCart(); $this->addBundleProduct($productData); - $this->setBillingAddress(); - $shippingMethod = $this->setShippingAddress(); + $this->setBillingAddress($addressData); + $shippingMethod = $this->setShippingAddress($addressData); $paymentMethod = $this->setShippingMethod($shippingMethod); $this->setPaymentMethod($paymentMethod); return $this->doPlaceOrder(); @@ -198,11 +202,13 @@ private function addBundleProduct(array $productData) /** * Set the billing address on the cart * + * @param array $addressData * @return array */ - private function setBillingAddress(): array + private function setBillingAddress(?array $addressData = null): array { - $setBillingAddress = <<<QUERY + $telephone = $addressData['telephone'] ?? '5123456677'; + $setBillingAddress = <<<QUERY mutation { setBillingAddressOnCart( input: { @@ -215,7 +221,7 @@ private function setBillingAddress(): array street: ["test street 1", "test street 2"] city: "Texas City" postcode: "78717" - telephone: "5123456677" + telephone: "{$telephone}" region: "TX" country_code: "US" } @@ -236,10 +242,12 @@ private function setBillingAddress(): array /** * Set the shipping address on the cart and return an available shipping method * + * @param array|null $addressData * @return array */ - private function setShippingAddress(): array + private function setShippingAddress(?array $addressData): array { + $telephone = $addressData['telephone'] ?? '5123456677'; $setShippingAddress = <<<QUERY mutation { setShippingAddressesOnCart( @@ -256,7 +264,7 @@ private function setShippingAddress(): array region: "AL" postcode: "36013" country_code: "US" - telephone: "3347665522" + telephone: "{$telephone}" } } ] @@ -283,10 +291,10 @@ private function setShippingAddress(): array /** * Set the shipping method on the cart and return an available payment method * - * @param array $shippingMethod + * @param array|null $shippingMethod * @return array */ - private function setShippingMethod(array $shippingMethod): array + private function setShippingMethod(?array $shippingMethod): array { $setShippingMethod = <<<QUERY mutation { diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersWithBundleProductByOrderNumberTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersWithBundleProductByOrderNumberTest.php index b4c9bd4962cc2..3d523fd7a8f6d 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersWithBundleProductByOrderNumberTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersWithBundleProductByOrderNumberTest.php @@ -117,6 +117,35 @@ public function testGetCustomerOrderBundleProduct() $this->assertEquals($expectedBundleOptions, $bundleOptionsFromResponse); } + /** + * Test customer order with bundle product and no telephone in address + * + * @magentoApiDataFixture Magento/Customer/_files/attribute_telephone_not_required_address.php + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * @magentoApiDataFixture Magento/Bundle/_files/bundle_product_two_dropdown_options.php + */ + public function testOrderBundleProductWithNoTelephoneInAddress() + { + //Place order with bundled product + $qty = 1; + $bundleSku = 'bundle-product-two-dropdown-options'; + /** @var CustomerPlaceOrder $bundleProductOrderFixture */ + $bundleProductOrderFixture = Bootstrap::getObjectManager()->create(CustomerPlaceOrder::class); + $orderResponse = $bundleProductOrderFixture->placeOrderWithBundleProduct( + ['email' => 'customer@example.com', 'password' => 'password'], + ['sku' => $bundleSku, 'quantity' => $qty], + ['telephone' => ''] + ); + $orderNumber = $orderResponse['placeOrder']['order']['order_number']; + $customerOrderResponse = $this->getCustomerOrderQueryBundleProduct($orderNumber); + $customerOrderItems = $customerOrderResponse[0]; + $this->assertEquals("Pending", $customerOrderItems['status']); + $billingAddress = $customerOrderItems['billing_address']; + $shippingAddress = $customerOrderItems['shipping_address']; + $this->assertNull($billingAddress['telephone']); + $this->assertNull($shippingAddress['telephone']); + } + /** * Test customer order details with bundle products * @magentoApiDataFixture Magento/Customer/_files/customer.php @@ -220,59 +249,81 @@ private function getCustomerOrderQueryBundleProduct($orderNumber) $query = <<<QUERY { - customer { - orders(filter:{number:{eq:"{$orderNumber}"}}) { - total_count - items { - id - number - order_date - status - items{ - __typename - product_sku - product_name - product_url_key - product_sale_price{value} - quantity_ordered - discounts{amount{value} label} - ... on BundleOrderItem{ - bundle_options{ - __typename - label - values { - product_sku - product_name - quantity - price { - value - currency - } + customer { + orders(filter:{number:{eq:"{$orderNumber}"}}) { + total_count + items { + id + number + order_date + status + items{ + __typename + product_sku + product_name + product_url_key + product_sale_price{value} + quantity_ordered + discounts{amount{value} label} + ... on BundleOrderItem{ + bundle_options{ + __typename + label + values { + product_sku + product_name + quantity + price { + value + currency + } + } + } + } } - } - } - } - total { - base_grand_total{value currency} - grand_total{value currency} - subtotal {value currency } - total_tax{value currency} - taxes {amount{value currency} title rate} - total_shipping{value currency} - shipping_handling - { - amount_including_tax{value} - amount_excluding_tax{value} - total_amount{value} - discounts{amount{value}} - taxes {amount{value} title rate} - } - discounts {amount{value currency} label} - } - } - } - } - } + total { + base_grand_total{value currency} + grand_total{value currency} + subtotal {value currency } + total_tax{value currency} + taxes {amount{value currency} title rate} + total_shipping{value currency} + shipping_handling + { + amount_including_tax{value} + amount_excluding_tax{value} + total_amount{value} + discounts{amount{value}} + taxes {amount{value} title rate} + } + discounts {amount{value currency} label} + } + billing_address { + firstname + lastname + street + city + region + region_id + postcode + telephone + country_code + } + shipping_address { + firstname + lastname + street + city + region + region_id + postcode + telephone + country_code + } + } + } + } +} QUERY; $currentEmail = 'customer@example.com'; $currentPassword = 'password'; diff --git a/dev/tests/integration/testsuite/Magento/Bundle/Model/ProductTest.php b/dev/tests/integration/testsuite/Magento/Bundle/Model/ProductTest.php index a981a7b957ec7..df8d79c5fff6d 100644 --- a/dev/tests/integration/testsuite/Magento/Bundle/Model/ProductTest.php +++ b/dev/tests/integration/testsuite/Magento/Bundle/Model/ProductTest.php @@ -12,12 +12,18 @@ use Magento\Bundle\Model\Product\Price; use Magento\Bundle\Model\Product\Type as BundleType; +use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Attribute\Source\Status; use Magento\Catalog\Model\Product\Type; use Magento\Catalog\Model\Product\Visibility; +use Magento\CatalogInventory\Api\Data\StockItemInterface; +use Magento\CatalogInventory\Api\StockConfigurationInterface; +use Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory; +use Magento\CatalogInventory\Api\StockItemRepositoryInterface; use Magento\CatalogInventory\Model\Stock; +use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\ObjectManagerInterface; use Magento\Store\Api\StoreRepositoryInterface; use Magento\Store\Model\StoreManagerInterface; @@ -250,6 +256,134 @@ public function stockConfigDataProvider(): array return $variations; } + /** + * @magentoAppIsolation enabled + * @magentoDataFixture Magento/Bundle/_files/bundle_product_with_dynamic_price.php + * @dataProvider shouldUpdateBundleStockStatusIfChildProductsStockStatusChangedDataProvider + * @param bool $isOption1Required + * @param bool $isOption2Required + * @param array $outOfStockConfig + * @param array $inStockConfig + * @throws NoSuchEntityException + * @throws \Magento\Framework\Exception\CouldNotSaveException + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\StateException + */ + public function testShouldUpdateBundleStockStatusIfChildProductsStockStatusChanged( + bool $isOption1Required, + bool $isOption2Required, + array $outOfStockConfig, + array $inStockConfig + ): void { + $sku = 'bundle_product_with_dynamic_price'; + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + /** @var ProductInterface $product */ + $product = $productRepository->get($sku, true, null, true); + $extension = $product->getExtensionAttributes(); + $options = $extension->getBundleProductOptions(); + $options[0]->setRequired($isOption1Required); + $options[1]->setRequired($isOption2Required); + $extension->setBundleProductOptions($options); + $stockItem = $extension->getStockItem(); + $stockItem->setUseConfigManageStock(1); + $product->setExtensionAttributes($extension); + $productRepository->save($product); + + $stockItem = $this->getStockItem((int) $product->getId()); + $this->assertNotNull($stockItem); + $this->assertTrue($stockItem->getIsInStock()); + foreach ($outOfStockConfig as $childSku => $stockData) { + $this->updateStockItem($childSku, $stockData); + } + + $stockItem = $this->getStockItem((int) $product->getId()); + $this->assertNotNull($stockItem); + $this->assertFalse($stockItem->getIsInStock()); + foreach ($inStockConfig as $childSku => $stockData) { + $this->updateStockItem($childSku, $stockData); + } + + $stockItem = $this->getStockItem((int) $product->getId()); + $this->assertNotNull($stockItem); + $this->assertTrue($stockItem->getIsInStock()); + } + + /** + * @return array + */ + public function shouldUpdateBundleStockStatusIfChildProductsStockStatusChangedDataProvider(): array + { + return [ + 'all options are required' => [ + true, + true, + 'out-of-stock' => [ + 'simple1' => [ + 'is_in_stock' => false + ], + ], + 'in-stock' => [ + 'simple1' => [ + 'is_in_stock' => true + ] + ] + ], + 'all options are optional' => [ + false, + false, + 'out-of-stock' => [ + 'simple1' => [ + 'is_in_stock' => false + ], + 'simple2' => [ + 'is_in_stock' => false + ], + ], + 'in-stock' => [ + 'simple1' => [ + 'is_in_stock' => true + ] + ] + ] + ]; + } + + /** + * @param string $sku + * @param array $data + * @throws NoSuchEntityException + */ + private function updateStockItem(string $sku, array $data): void + { + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $product = $productRepository->get($sku, true, null, true); + $extendedAttributes = $product->getExtensionAttributes(); + $stockItem = $extendedAttributes->getStockItem(); + $stockItem->setIsInStock($data['is_in_stock']); + $extendedAttributes->setStockItem($stockItem); + $product->setExtensionAttributes($extendedAttributes); + $productRepository->save($product); + } + + /** + * @param int $productId + * @return StockItemInterface|null + */ + private function getStockItem(int $productId): ?StockItemInterface + { + $criteriaFactory = $this->objectManager->create(StockItemCriteriaInterfaceFactory::class); + $stockItemRepository = $this->objectManager->create(StockItemRepositoryInterface::class); + $stockConfiguration = $this->objectManager->create(StockConfigurationInterface::class); + $criteria = $criteriaFactory->create(); + $criteria->setScopeFilter($stockConfiguration->getDefaultScopeId()); + $criteria->setProductsFilter($productId); + $stockItemCollection = $stockItemRepository->getList($criteria); + $stockItems = $stockItemCollection->getItems(); + return reset($stockItems); + } + /** * @param float $selectionQty * @param float $qty diff --git a/dev/tests/integration/testsuite/Magento/Bundle/_files/bundle_product_with_disabled_product_options.php b/dev/tests/integration/testsuite/Magento/Bundle/_files/bundle_product_with_disabled_product_options.php new file mode 100644 index 0000000000000..5661c508efbae --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Bundle/_files/bundle_product_with_disabled_product_options.php @@ -0,0 +1,174 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Bundle\Api\Data\LinkInterfaceFactory; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Visibility; +use Magento\CatalogInventory\Model\Stock\Item; +use Magento\Catalog\Model\Product\Type; +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; +use Magento\Bundle\Api\Data\OptionInterfaceFactory; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\TestFramework\Helper\Bootstrap; + +Resolver::getInstance()->requireDataFixture( + 'Magento/Catalog/_files/multiple_products_with_disabled_virtual_product.php' +); + +$objectManager = Bootstrap::getObjectManager(); + +$productIds = range(101, 104, 1); + +foreach ($productIds as $productId) { + /** @var Item $stockItem */ + $stockItem = $objectManager->create(Item::class); + $stockItem->load($productId, 'product_id'); + + if (!$stockItem->getProductId()) { + $stockItem->setProductId($productId); + } + $stockItem->setUseConfigManageStock(1); + $stockItem->setIsQtyDecimal(0); + if ($productId == 104) { + $stockItem->setQty(0); + $stockItem->setIsInStock(0); + } else { + $stockItem->setQty(1000); + $stockItem->setIsInStock(1); + } + $stockItem->save(); +} + +/** @var $product Product */ +$product = $objectManager->create(Product::class); +$product->setTypeId(Type::TYPE_BUNDLE) + ->setId(3) + ->setCategoryIds([565]) + ->setAttributeSetId(4) + ->setWebsiteIds([1]) + ->setName('Bundle Product') + ->setSku('bundle-product') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]) + ->setPriceView(1) + ->setPriceType(1) + ->setPrice(10.0) + ->setShipmentType(0) + ->setBundleOptionsData( + [ + // Required "Drop-down" option 1 + [ + 'title' => 'Option 1', + 'default_title' => 'Option 1', + 'type' => 'select', + 'required' => 1, + 'position' => 1, + 'delete' => '', + ], + // Required "Drop-down" option 2 + [ + 'title' => 'Option 2', + 'default_title' => 'Option 2', + 'type' => 'select', + 'required' => 1, + 'position' => 2, + 'delete' => '', + ] + ] + )->setBundleSelectionsData( + [ + [ + [ + 'product_id' => 101, + 'selection_qty' => 1, + 'selection_can_change_qty' => 1, + 'delete' => '', + 'option_id' => 1 + ], + [ + 'product_id' => 102, + 'selection_qty' => 1, + 'selection_can_change_qty' => 1, + 'delete' => '', + 'option_id' => 1 + ], + [ + 'product_id' => 103, + 'selection_qty' => 1, + 'selection_can_change_qty' => 1, + 'delete' => '', + 'option_id' => 1 + ], + [ + 'product_id' => 104, + 'selection_qty' => 1, + 'selection_can_change_qty' => 1, + 'delete' => '', + 'option_id' => 1 + ] + ], + [ + [ + 'product_id' => 101, + 'selection_qty' => 1, + 'selection_can_change_qty' => 1, + 'delete' => '', + 'option_id' => 2 + ], + [ + 'product_id' => 102, + 'selection_qty' => 1, + 'selection_can_change_qty' => 1, + 'delete' => '', + 'option_id' => 2 + ], + [ + 'product_id' => 103, + 'selection_qty' => 1, + 'selection_can_change_qty' => 1, + 'delete' => '', + 'option_id' => 2 + ] + ] + ] + ); +$productRepository = $objectManager->create(ProductRepositoryInterface::class); + +if ($product->getBundleOptionsData()) { + $options = []; + foreach ($product->getBundleOptionsData() as $key => $optionData) { + if (!(bool)$optionData['delete']) { + $option = $objectManager->create(OptionInterfaceFactory::class) + ->create(['data' => $optionData]); + $option->setSku($product->getSku()); + $option->setOptionId(null); + + $links = []; + $bundleLinks = $product->getBundleSelectionsData(); + if (!empty($bundleLinks[$key])) { + foreach ($bundleLinks[$key] as $linkData) { + if (!(bool)$linkData['delete']) { + $link = $objectManager->create(LinkInterfaceFactory::class) + ->create(['data' => $linkData]); + $linkProduct = $productRepository->getById($linkData['product_id']); + $link->setSku($linkProduct->getSku()); + $link->setQty($linkData['selection_qty']); + $links[] = $link; + } + } + $option->setProductLinks($links); + $options[] = $option; + } + } + } + $extension = $product->getExtensionAttributes(); + $extension->setBundleProductOptions($options); + $product->setExtensionAttributes($extension); +} +$product->save(); diff --git a/dev/tests/integration/testsuite/Magento/Bundle/_files/bundle_product_with_disabled_product_options_rollback.php b/dev/tests/integration/testsuite/Magento/Bundle/_files/bundle_product_with_disabled_product_options_rollback.php new file mode 100644 index 0000000000000..3d542ebbb0df4 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Bundle/_files/bundle_product_with_disabled_product_options_rollback.php @@ -0,0 +1,36 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Registry; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; + +Resolver::getInstance()->requireDataFixture( + 'Magento/Catalog/_files/multiple_products_with_disabled_virtual_product_rollback.php' +); + +$objectManager = Bootstrap::getObjectManager(); +/** @var Registry $registry */ +$registry = Bootstrap::getObjectManager()->get(Registry::class); +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->create(ProductRepositoryInterface::class); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +//delete bundle product +try { + $product = $productRepository->get('bundle-product'); + $productRepository->delete($product); +} catch (NoSuchEntityException $exception) { + //Product already removed +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options.php b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options.php index 46a88b1ec8b13..d1d4ccacace7f 100644 --- a/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options.php +++ b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options.php @@ -9,9 +9,16 @@ Resolver::getInstance()->requireDataFixture('Magento/Catalog/_files/multiple_products.php'); $objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); +$productRepository = $objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); $productIds = range(10, 12, 1); foreach ($productIds as $productId) { + $product = $productRepository->getById($productId, true, null, true); + if ((int) $product->getStatus() === \Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_DISABLED) { + $product->unlockAttribute('status') + ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED); + $product->save(); + } /** @var \Magento\CatalogInventory\Model\Stock\Item $stockItem */ $stockItem = $objectManager->create(\Magento\CatalogInventory\Model\Stock\Item::class); $stockItem->load($productId, 'product_id'); @@ -167,7 +174,6 @@ ] ] ); -$productRepository = $objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); if ($product->getBundleOptionsData()) { $options = []; diff --git a/dev/tests/integration/testsuite/Magento/BundleImportExport/Model/Import/Product/Type/BundleTest.php b/dev/tests/integration/testsuite/Magento/BundleImportExport/Model/Import/Product/Type/BundleTest.php index 7228e21f0a026..883b41387a2b0 100644 --- a/dev/tests/integration/testsuite/Magento/BundleImportExport/Model/Import/Product/Type/BundleTest.php +++ b/dev/tests/integration/testsuite/Magento/BundleImportExport/Model/Import/Product/Type/BundleTest.php @@ -5,18 +5,26 @@ */ namespace Magento\BundleImportExport\Model\Import\Product\Type; +use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Api\ProductRepositoryInterface; use Magento\Catalog\Model\Product; use Magento\Catalog\Model\ResourceModel\Product as ProductResource; +use Magento\CatalogInventory\Api\Data\StockItemInterface; +use Magento\CatalogInventory\Api\StockConfigurationInterface; +use Magento\CatalogInventory\Api\StockItemCriteriaInterfaceFactory; +use Magento\CatalogInventory\Api\StockItemRepositoryInterface; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Filesystem; use Magento\ImportExport\Model\Import; +use Magento\ImportExport\Model\Import\Adapter as ImportAdapter; +use Magento\ImportExport\Model\Import\ErrorProcessing\ProcessingErrorAggregatorInterface; use Magento\ImportExport\Model\Import\Source\Csv; use Magento\TestFramework\Helper\Bootstrap; use Magento\Framework\App\Filesystem\DirectoryList; /** * @magentoAppArea adminhtml + * @magentoAppIsolation enabled * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class BundleTest extends \Magento\TestFramework\Indexer\TestCase @@ -24,12 +32,12 @@ class BundleTest extends \Magento\TestFramework\Indexer\TestCase /** * Bundle product test Name */ - const TEST_PRODUCT_NAME = 'Bundle 1'; + private const TEST_PRODUCT_NAME = 'Bundle 1'; /** * Bundle product test Type */ - const TEST_PRODUCT_TYPE = 'bundle'; + private const TEST_PRODUCT_TYPE = 'bundle'; /** * @var \Magento\CatalogImportExport\Model\Import\Product @@ -81,29 +89,8 @@ public function testBundleImport() { // import data from CSV file $pathToFile = __DIR__ . '/../../_files/import_bundle.csv'; - $filesystem = $this->objectManager->create( - Filesystem::class - ); - - $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); - $source = $this->objectManager->create( - Csv::class, - [ - 'file' => $pathToFile, - 'directory' => $directory - ] - ); - $errors = $this->model->setSource( - $source - )->setParameters( - [ - 'behavior' => Import::BEHAVIOR_APPEND, - 'entity' => 'catalog_product' - ] - )->validateData(); - - $this->assertTrue($errors->getErrorsCount() == 0); - $this->model->importData(); + $errors = $this->doImport($pathToFile, Import::BEHAVIOR_APPEND); + $this->assertEquals(0, $errors->getErrorsCount()); $resource = $this->objectManager->get(ProductResource::class); $productId = $resource->getIdBySku(self::TEST_PRODUCT_NAME); @@ -161,50 +148,13 @@ public function testBundleImportUpdateValues(array $expectedValues): void { // import data from CSV file $pathToFile = __DIR__ . '/../../_files/import_bundle.csv'; - $filesystem = $this->objectManager->create( - Filesystem::class - ); - - $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); - $source = $this->objectManager->create( - Csv::class, - [ - 'file' => $pathToFile, - 'directory' => $directory - ] - ); - $errors = $this->model->setSource( - $source - )->setParameters( - [ - 'behavior' => Import::BEHAVIOR_APPEND, - 'entity' => 'catalog_product' - ] - )->validateData(); - - $this->assertTrue($errors->getErrorsCount() == 0); - $this->model->importData(); + $errors = $this->doImport($pathToFile, Import::BEHAVIOR_APPEND); + $this->assertEquals(0, $errors->getErrorsCount()); // import data from CSV file to update values $pathToFile2 = __DIR__ . '/../../_files/import_bundle_update_values.csv'; - $source2 = $this->objectManager->create( - Csv::class, - [ - 'file' => $pathToFile2, - 'directory' => $directory - ] - ); - $errors2 = $this->model->setSource( - $source2 - )->setParameters( - [ - 'behavior' => Import::BEHAVIOR_APPEND, - 'entity' => 'catalog_product' - ] - )->validateData(); - - $this->assertTrue($errors2->getErrorsCount() == 0); - $this->model->importData(); + $errors = $this->doImport($pathToFile2, Import::BEHAVIOR_APPEND); + $this->assertEquals(0, $errors->getErrorsCount()); $resource = $this->objectManager->get(ProductResource::class); $productId = $resource->getIdBySku(self::TEST_PRODUCT_NAME); @@ -244,24 +194,8 @@ public function testBundleImportWithMultipleStoreViews(): void { // import data from CSV file $pathToFile = __DIR__ . '/../../_files/import_bundle_multiple_store_views.csv'; - $filesystem = $this->objectManager->create(Filesystem::class); - $directory = $filesystem->getDirectoryWrite(DirectoryList::ROOT); - $source = $this->objectManager->create( - Csv::class, - [ - 'file' => $pathToFile, - 'directory' => $directory, - ] - ); - $errors = $this->model->setSource($source) - ->setParameters( - [ - 'behavior' => Import::BEHAVIOR_APPEND, - 'entity' => 'catalog_product', - ] - )->validateData(); - $this->assertTrue($errors->getErrorsCount() == 0); - $this->model->importData(); + $errors = $this->doImport($pathToFile, Import::BEHAVIOR_APPEND); + $this->assertEquals(0, $errors->getErrorsCount()); $resource = $this->objectManager->get(ProductResource::class); $productId = $resource->getIdBySku(self::TEST_PRODUCT_NAME); $this->assertIsNumeric($productId); @@ -323,6 +257,121 @@ public function valuesDataProvider(): array ]; } + /** + * @magentoDbIsolation enabled + * @dataProvider shouldUpdateBundleStockStatusIfChildProductsStockStatusChangedDataProvider + * @param bool $isOption1Required + * @param bool $isOption2Required + * @param string $outOfStockImportFile + * @param string $inStockImportFile + * @throws NoSuchEntityException + */ + public function testShouldUpdateBundleStockStatusIfChildProductsStockStatusChanged( + bool $isOption1Required, + bool $isOption2Required, + string $outOfStockImportFile, + string $inStockImportFile + ): void { + // import data from CSV file + $pathToFile = __DIR__ . '/../../_files/import_bundle.csv'; + $errors = $this->doImport($pathToFile, Import::BEHAVIOR_APPEND); + $this->assertEquals(0, $errors->getErrorsCount()); + $this->importedProductSkus = ['Simple 1', 'Simple 2', 'Simple 3', 'Bundle 1']; + $sku = 'Bundle 1'; + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + /** @var ProductInterface $product */ + $product = $productRepository->get($sku, true, null, true); + $options = $product->getExtensionAttributes()->getBundleProductOptions(); + $options[0]->setRequired($isOption1Required); + $options[1]->setRequired($isOption2Required); + $extension = $product->getExtensionAttributes(); + $extension->setBundleProductOptions($options); + $product->setExtensionAttributes($extension); + $productRepository->save($product); + + $stockItem = $this->getStockItem((int) $product->getId()); + $this->assertNotNull($stockItem); + $this->assertTrue($stockItem->getIsInStock()); + + $errors = $this->doImport(__DIR__ . '/../../_files/' . $outOfStockImportFile); + $this->assertEquals(0, $errors->getErrorsCount()); + + $stockItem = $this->getStockItem((int) $product->getId()); + $this->assertNotNull($stockItem); + $this->assertFalse($stockItem->getIsInStock()); + + $errors = $this->doImport(__DIR__ . '/../../_files/' . $inStockImportFile); + $this->assertEquals(0, $errors->getErrorsCount()); + + $stockItem = $this->getStockItem((int) $product->getId()); + $this->assertNotNull($stockItem); + $this->assertTrue($stockItem->getIsInStock()); + } + + /** + * @return array + */ + public function shouldUpdateBundleStockStatusIfChildProductsStockStatusChangedDataProvider(): array + { + return [ + 'all options are required' => [ + true, + true, + 'out-of-stock' => 'import_bundle_set_option1_products_out_of_stock.csv', + 'in-stock' => 'import_bundle_set_option1_products_in_stock.csv' + ], + 'all options are optional' => [ + false, + false, + 'out-of-stock' => 'import_bundle_set_all_products_out_of_stock.csv', + 'in-stock' => 'import_bundle_set_option1_products_in_stock.csv' + ] + ]; + } + + /** + * @param int $productId + * @return StockItemInterface|null + */ + private function getStockItem(int $productId): ?StockItemInterface + { + $criteriaFactory = $this->objectManager->create(StockItemCriteriaInterfaceFactory::class); + $stockItemRepository = $this->objectManager->create(StockItemRepositoryInterface::class); + $stockConfiguration = $this->objectManager->create(StockConfigurationInterface::class); + $criteria = $criteriaFactory->create(); + $criteria->setScopeFilter($stockConfiguration->getDefaultScopeId()); + $criteria->setProductsFilter($productId); + $stockItemCollection = $stockItemRepository->getList($criteria); + $stockItems = $stockItemCollection->getItems(); + return reset($stockItems); + } + + /** + * @param string $file + * @param string $behavior + * @param bool $validateOnly + * @return ProcessingErrorAggregatorInterface + */ + private function doImport( + string $file, + string $behavior = Import::BEHAVIOR_ADD_UPDATE, + bool $validateOnly = false + ): ProcessingErrorAggregatorInterface { + /** @var Filesystem $filesystem */ + $filesystem =$this->objectManager->create(Filesystem::class); + $directoryWrite = $filesystem->getDirectoryWrite(DirectoryList::ROOT); + $source = ImportAdapter::findAdapterFor($file, $directoryWrite); + $errors = $this->model + ->setParameters(['behavior' => $behavior, 'entity' => 'catalog_product']) + ->setSource($source) + ->validateData(); + if (!$validateOnly && !$errors->getAllErrors()) { + $this->model->importData(); + } + return $errors; + } + /** * teardown */ diff --git a/dev/tests/integration/testsuite/Magento/BundleImportExport/Model/Import/_files/import_bundle_set_all_products_out_of_stock.csv b/dev/tests/integration/testsuite/Magento/BundleImportExport/Model/Import/_files/import_bundle_set_all_products_out_of_stock.csv new file mode 100644 index 0000000000000..36e8e87196ac1 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/BundleImportExport/Model/Import/_files/import_bundle_set_all_products_out_of_stock.csv @@ -0,0 +1,4 @@ +"sku","qty","is_in_stock" +"Simple 1","0","0" +"Simple 2","0","0" +"Simple 3","0","0" diff --git a/dev/tests/integration/testsuite/Magento/BundleImportExport/Model/Import/_files/import_bundle_set_option1_products_in_stock.csv b/dev/tests/integration/testsuite/Magento/BundleImportExport/Model/Import/_files/import_bundle_set_option1_products_in_stock.csv new file mode 100644 index 0000000000000..ac888ecbc7079 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/BundleImportExport/Model/Import/_files/import_bundle_set_option1_products_in_stock.csv @@ -0,0 +1,2 @@ +"sku","qty","is_in_stock" +"Simple 1","100","1" diff --git a/dev/tests/integration/testsuite/Magento/BundleImportExport/Model/Import/_files/import_bundle_set_option1_products_out_of_stock.csv b/dev/tests/integration/testsuite/Magento/BundleImportExport/Model/Import/_files/import_bundle_set_option1_products_out_of_stock.csv new file mode 100644 index 0000000000000..64830cdf3619c --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/BundleImportExport/Model/Import/_files/import_bundle_set_option1_products_out_of_stock.csv @@ -0,0 +1,2 @@ +"sku","qty","is_in_stock" +"Simple 1","0","0" diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/multiple_products_with_disabled_virtual_product.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/multiple_products_with_disabled_virtual_product.php new file mode 100644 index 0000000000000..5cb63985311df --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/multiple_products_with_disabled_virtual_product.php @@ -0,0 +1,114 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Catalog\Model\Category; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Visibility; +use Magento\TestFramework\Helper\Bootstrap; + +//test category +$category = Bootstrap::getObjectManager()->create(Category::class); +$category->isObjectNew(true); +$category->setId('565') + ->setName('c1') + ->setAttributeSetId('3') + ->setParentId(2) + ->setPath('1/2/565') + ->setLevel('2') + ->setDefaultSortBy('name') + ->setIsActive(true) + ->save(); + +//virtual product 1 +/** @var $product Product */ +$product = Bootstrap::getObjectManager()->create(Product::class); +$product->isObjectNew(true); +$product->setTypeId(Product\Type::TYPE_VIRTUAL) + ->setId(101) + ->setAttributeSetId(4) + ->setName('Virtual Product1') + ->setSku('virtual1') + ->setTaxClassId('none') + ->setDescription('description unique word') + ->setShortDescription('short description') + ->setOptionsContainer('container1') + ->setPrice(10) + ->setWeight(1) + ->setMetaTitle('meta title') + ->setMetaKeyword('meta keyword') + ->setMetaDescription('meta description') + ->setVisibility(Visibility::VISIBILITY_BOTH) + ->setStatus(Status::STATUS_ENABLED) + ->setWebsiteIds([1]) + ->setCategoryIds([565]) + ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]) + ->save(); + +//virtual product 2 +$product = Bootstrap::getObjectManager()->create(Product::class); +$product->isObjectNew(true); +$product->setTypeId(Product\Type::TYPE_VIRTUAL) + ->setId(102) + ->setAttributeSetId(4) + ->setName('Virtual Product2') + ->setSku('virtual2') + ->setTaxClassId('none') + ->setDescription('description') + ->setShortDescription('short description') + ->setOptionsContainer('container1') + ->setPrice(20) + ->setWeight(1) + ->setMetaTitle('meta title') + ->setMetaKeyword('meta keyword') + ->setMetaDescription('meta description') + ->setVisibility(Visibility::VISIBILITY_IN_CATALOG) + ->setStatus(Status::STATUS_ENABLED) + ->setWebsiteIds([1]) + ->setCategoryIds([565]) + ->setStockData(['use_config_manage_stock' => 1, 'qty' => 50, 'is_qty_decimal' => 0, 'is_in_stock' => 1]) + ->save(); + +//virtual product 3 +$product = Bootstrap::getObjectManager()->create(Product::class); +$product->isObjectNew(true); +$product->setTypeId(Product\Type::TYPE_VIRTUAL) + ->setId(103) + ->setAttributeSetId(4) + ->setName('Virtual Product3') + ->setSku('virtual3') + ->setTaxClassId('none') + ->setDescription('description') + ->setShortDescription('short description') + ->setPrice(30) + ->setWeight(1) + ->setVisibility(Visibility::VISIBILITY_IN_CATALOG) + ->setStatus(Status::STATUS_ENABLED) + ->setWebsiteIds([1]) + ->setCategoryIds([565]) + ->setStockData(['use_config_manage_stock' => 1, 'qty' => 140, 'is_qty_decimal' => 0, 'is_in_stock' => 1]) + ->save(); + +//virtual product 4 +$product = Bootstrap::getObjectManager()->create(Product::class); +$product->isObjectNew(true); +$product->setTypeId(Product\Type::TYPE_VIRTUAL) + ->setId(104) + ->setAttributeSetId(4) + ->setName('Virtual Product4') + ->setSku('virtual4') + ->setTaxClassId('none') + ->setDescription('description') + ->setShortDescription('short description') + ->setPrice(40) + ->setWeight(1) + ->setVisibility(Visibility::VISIBILITY_IN_CATALOG) + ->setStatus(Status::STATUS_DISABLED) + ->setWebsiteIds([1]) + ->setCategoryIds([565]) + ->setStockData(['use_config_manage_stock' => 1, 'qty' => 0, 'is_qty_decimal' => 0, 'is_in_stock' => 0]) + ->save(); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/multiple_products_with_disabled_virtual_product_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/multiple_products_with_disabled_virtual_product_rollback.php new file mode 100644 index 0000000000000..0a7ee384e5428 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/multiple_products_with_disabled_virtual_product_rollback.php @@ -0,0 +1,44 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Registry; +use Magento\Catalog\Model\ResourceModel\Category\Collection as CategoryCollection; +use Magento\TestFramework\Helper\Bootstrap; + +$objectManager = Bootstrap::getObjectManager(); + +/** @var Registry $registry */ +$registry = Bootstrap::getObjectManager()->get(Registry::class); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +//delete category +/** @var CategoryCollection $collection */ +$categoryCollection = $objectManager->create(CategoryCollection::class); +$categoryCollection + ->addAttributeToFilter('level', 2) + ->load() + ->delete(); + +//delete product +/** @var ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->create(ProductRepositoryInterface::class); + +foreach (['virtual1', 'virtual2', 'virtual3', 'virtual4'] as $sku) { + try { + $product = $productRepository->get($sku, false, null, true); + $productRepository->delete($product); + } catch (NoSuchEntityException $exception) { + //Product already removed + } +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); diff --git a/dev/tests/integration/testsuite/Magento/CatalogRule/Model/ResourceModel/Product/ConditionsToCollectionApplierTest.php b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/ResourceModel/Product/ConditionsToCollectionApplierTest.php index 0bef038b5f7e5..b06de8109ecbd 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogRule/Model/ResourceModel/Product/ConditionsToCollectionApplierTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/ResourceModel/Product/ConditionsToCollectionApplierTest.php @@ -247,7 +247,8 @@ private function conditionProvider() 'simple-product-9', 'simple-product-10', 'simple-product-11', - 'simple-product-12' + 'simple-product-12', + 'simple-product-13', ] ], @@ -272,7 +273,8 @@ private function conditionProvider() 'simple-product-9', 'simple-product-10', 'simple-product-11', - 'simple-product-12' + 'simple-product-12', + 'simple-product-13', ] ], @@ -386,7 +388,8 @@ private function conditionProvider() 'simple-product-9', 'simple-product-10', 'simple-product-11', - 'simple-product-12' + 'simple-product-12', + 'simple-product-13', ] ], @@ -416,7 +419,8 @@ private function conditionProvider() 'simple-product-9', 'simple-product-10', 'simple-product-11', - 'simple-product-12' + 'simple-product-12', + 'simple-product-13', ] ], @@ -424,12 +428,9 @@ private function conditionProvider() 'variation 22' => [ 'condition' => $this->getConditionsForVariation22(), 'expected-sku' => [ - 'simple-product-1', - 'simple-product-2', - 'simple-product-3', - 'simple-product-4', 'simple-product-7', - 'simple-product-8' + 'simple-product-8', + 'simple-product-13', ] ], ]; diff --git a/dev/tests/integration/testsuite/Magento/CatalogRule/_files/conditions_to_collection/products.php b/dev/tests/integration/testsuite/Magento/CatalogRule/_files/conditions_to_collection/products.php index db44a3d10ac9c..71c3d2369506e 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogRule/_files/conditions_to_collection/products.php +++ b/dev/tests/integration/testsuite/Magento/CatalogRule/_files/conditions_to_collection/products.php @@ -173,6 +173,19 @@ 'qty' => 42, 'categories' => ['Category 1.1.1'], ], + [ + 'type-id' => 'simple', + 'attribute-set-id' => $attributeSetGuardians->getId(), + 'website-ids' => [1], + 'name' => 'Simple Product 13', + 'sku' => 'simple-product-13', + 'price' => 10, + 'visibility' => \Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH, + 'status' => \Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED, + 'stock-data' => ['use_config_manage_stock' => 1, 'qty' => 22, 'is_in_stock' => 1], + 'qty' => 42, + 'categories' => [], + ], ]; foreach ($productsData as $productData) { diff --git a/dev/tests/integration/testsuite/Magento/CatalogRuleConfigurable/Model/Indexer/Product/ProductRuleIndexerTest.php b/dev/tests/integration/testsuite/Magento/CatalogRuleConfigurable/Model/Indexer/Product/ProductRuleIndexerTest.php index 7463b0967833f..d040cad330aff 100644 --- a/dev/tests/integration/testsuite/Magento/CatalogRuleConfigurable/Model/Indexer/Product/ProductRuleIndexerTest.php +++ b/dev/tests/integration/testsuite/Magento/CatalogRuleConfigurable/Model/Indexer/Product/ProductRuleIndexerTest.php @@ -49,51 +49,109 @@ protected function setUp(): void } /** + * @dataProvider productsDataProvider + * @param string $reindexSku + * @param array $expectedPrices * @return void + * @throws \Magento\Framework\Exception\NoSuchEntityException */ - public function testExecute(): void + public function testExecute(string $reindexSku, array $expectedPrices): void { - $product = $this->productRepository->get('configurable'); + $product = $this->productRepository->get($reindexSku); $this->productRuleIndexer->execute([$product->getId()]); - $product = $this->productRepository->get('simple_10'); - $price = $this->getCatalogRulePrice($product); - $this->assertEquals(5, $price); + $this->assertEquals($expectedPrices, $this->getCatalogRulePrices(array_keys($expectedPrices))); } /** + * @dataProvider productsDataProvider + * @param string $reindexSku + * @param array $expectedPrices * @return void + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Magento\Framework\Exception\NoSuchEntityException */ - public function testExecuteRow(): void + public function testExecuteRow(string $reindexSku, array $expectedPrices): void { - $product = $this->productRepository->get('configurable'); + $product = $this->productRepository->get($reindexSku); $this->productRuleIndexer->executeRow($product->getId()); - $product = $this->productRepository->get('simple_10'); - $price = $this->getCatalogRulePrice($product); - $this->assertEquals(5, $price); + $this->assertEquals($expectedPrices, $this->getCatalogRulePrices(array_keys($expectedPrices))); } /** + * @dataProvider productsDataProvider + * @param string $reindexSku + * @param array $expectedPrices * @return void + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Magento\Framework\Exception\NoSuchEntityException */ - public function testExecuteList(): void + public function testExecuteList(string $reindexSku, array $expectedPrices): void { - $product = $this->productRepository->get('configurable'); + $product = $this->productRepository->get($reindexSku); $this->productRuleIndexer->executeList([$product->getId()]); - $product = $this->productRepository->get('simple_10'); - $price = $this->getCatalogRulePrice($product); - $this->assertEquals(5, $price); + $this->assertEquals($expectedPrices, $this->getCatalogRulePrices(array_keys($expectedPrices))); } + /** + * @return void + */ public function testExecuteFull(): void { $this->productRuleIndexer->executeFull(); - $product = $this->productRepository->get('simple_10'); - $price = $this->getCatalogRulePrice($product); - $this->assertEquals(5, $price); + $expectedPrices = [ + 'simple_10' => 5, + 'simple_20' => 10, + ]; + $this->assertEquals($expectedPrices, $this->getCatalogRulePrices(array_keys($expectedPrices))); + } + + /** + * @return array + */ + public function productsDataProvider(): array + { + return [ + [ + 'configurable', + [ + 'simple_10' => 5, + 'simple_20' => 10, + ] + ], + [ + 'simple_10', + [ + 'simple_10' => 5, + 'simple_20' => 10, + ] + ], + [ + 'simple_20', + [ + 'simple_10' => 5, + 'simple_20' => 10, + ] + ], + ]; + } + + /** + * @param array $skus + * @return array + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + private function getCatalogRulePrices(array $skus): array + { + $actualPrices = []; + foreach ($skus as $sku) { + $product = $this->productRepository->get($sku); + $actualPrices[$sku] = $this->getCatalogRulePrice($product); + } + return $actualPrices; } /** diff --git a/dev/tests/integration/testsuite/Magento/Cron/Model/Config/Backend/SitemapTest.php b/dev/tests/integration/testsuite/Magento/Cron/Model/Config/Backend/SitemapTest.php new file mode 100644 index 0000000000000..6e40366eee04d --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Cron/Model/Config/Backend/SitemapTest.php @@ -0,0 +1,98 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Cron\Model\Config\Backend; + +use Magento\Config\Model\Config as ConfigModel; +use Magento\Config\Model\PreparedValueFactory; +use Magento\Cron\Model\Config\Source\Frequency; +use Magento\Framework\App\Config\ValueFactory; +use Magento\Framework\ObjectManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +/** + * @magentoAppArea adminhtml + */ +class SitemapTest extends TestCase +{ + /** + * @var ObjectManagerInterface + */ + private $objectManager; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $this->objectManager = Bootstrap::getObjectManager(); + } + + /** + * @dataProvider frequencyDataProvider + * @param string $frequency + * @param string $expectedCronExpr + */ + public function testDirectSave(string $frequency, string $expectedCronExpr): void + { + $preparedValueFactory = $this->objectManager->get(PreparedValueFactory::class); + /** @var Sitemap $sitemapValue */ + $sitemapValue = $preparedValueFactory->create('sitemap/generate/frequency', $frequency, 'default', 0); + $sitemapValue->save(); + + self::assertEquals($expectedCronExpr, $this->getCronExpression()); + } + + /** + * @dataProvider frequencyDataProvider + * @param string $frequency + * @param string $expectedCronExpr + */ + public function testSaveFromAdmin(string $frequency, string $expectedCronExpr): void + { + $config = $this->objectManager->create(ConfigModel::class); + $config->setSection('sitemap'); + $config->setGroups( + [ + 'generate' => [ + 'fields' => [ + 'time' => ['value' => ['00', '00', '00']], + 'frequency' => ['value' => $frequency], + ], + ], + ] + ); + $config->save(); + + self::assertEquals($expectedCronExpr, $this->getCronExpression()); + } + + /** + * @return array + */ + public function frequencyDataProvider(): array + { + return [ + 'daily' => [Frequency::CRON_DAILY, '0 0 * * *'], + 'weekly' => [Frequency::CRON_WEEKLY, '0 0 * * 1'], + 'monthly' => [Frequency::CRON_MONTHLY, '0 0 1 * *'], + ]; + } + + /** + * @return string + */ + private function getCronExpression(): string + { + $valueFactory = $this->objectManager->get(ValueFactory::class); + $cronExprValue = $valueFactory->create() + ->load(Sitemap::CRON_STRING_PATH, 'path'); + + return $cronExprValue->getValue(); + } +} diff --git a/dev/tests/integration/testsuite/Magento/SalesRule/Model/ResourceModel/Report/Rule/CreatedatTest.php b/dev/tests/integration/testsuite/Magento/SalesRule/Model/ResourceModel/Report/Rule/CreatedatTest.php index 393f9f1cc925b..6f50ce57309e5 100644 --- a/dev/tests/integration/testsuite/Magento/SalesRule/Model/ResourceModel/Report/Rule/CreatedatTest.php +++ b/dev/tests/integration/testsuite/Magento/SalesRule/Model/ResourceModel/Report/Rule/CreatedatTest.php @@ -58,7 +58,8 @@ public function testTotals($orderParams) private function getTotalAmount(\Magento\Sales\Model\Order $order) { return ( - $order->getBaseSubtotal() - $order->getBaseSubtotalCanceled() + ($order->getBaseSubtotal() - $order->getBaseSubtotalCanceled() + + ($order->getBaseShippingAmount() - $order->getBaseShippingCanceled())) - (abs((float) $order->getBaseDiscountAmount()) - abs((float) $order->getBaseDiscountCanceled())) + ($order->getBaseTaxAmount() - $order->getBaseTaxCanceled()) ) * $order->getBaseToGlobalRate(); @@ -73,7 +74,8 @@ private function getTotalAmount(\Magento\Sales\Model\Order $order) private function getTotalAmountActual(\Magento\Sales\Model\Order $order) { return ( - $order->getBaseSubtotalInvoiced() - $order->getSubtotalRefunded() + ($order->getBaseSubtotalInvoiced() - $order->getSubtotalRefunded() + + ($order->getBaseShippingInvoiced() - $order->getBaseShippingRefunded())) - abs((float) $order->getBaseDiscountInvoiced()) - abs((float) $order->getBaseDiscountRefunded()) + $order->getBaseTaxInvoiced() - $order->getBaseTaxRefunded() ) * $order->getBaseToGlobalRate(); diff --git a/lib/web/mage/adminhtml/wysiwyg/tiny_mce/plugins/magentowidget/editor_plugin.js b/lib/web/mage/adminhtml/wysiwyg/tiny_mce/plugins/magentowidget/editor_plugin.js index 4a9d0c65e19ea..0b0687e7d329c 100644 --- a/lib/web/mage/adminhtml/wysiwyg/tiny_mce/plugins/magentowidget/editor_plugin.js +++ b/lib/web/mage/adminhtml/wysiwyg/tiny_mce/plugins/magentowidget/editor_plugin.js @@ -142,7 +142,7 @@ define([ * @return {*} */ encodeWidgets: function (content) { - return content.gsub(/\{\{widget(.*?)\}\}/i, function (match) { + return content.gsub(/\{\{widget([\S\s]*?)\}\}/i, function (match) { var attributes = wysiwyg.parseAttributesString(match[1]), imageSrc, imageHtml = '';