diff --git a/BundleProductDataExporter/Model/Provider/Product/BundleProductOptions.php b/BundleProductDataExporter/Model/Provider/Product/BundleProductOptions.php index 10cc40fa8..b1fda4846 100644 --- a/BundleProductDataExporter/Model/Provider/Product/BundleProductOptions.php +++ b/BundleProductDataExporter/Model/Provider/Product/BundleProductOptions.php @@ -162,6 +162,8 @@ private function formatBundleValuesRow(array $row) : array 'sku' => $row['sku'], 'label' => $row['label'], 'qty' => $row['qty'], + 'priceType' => $row['price_type'] ? 'percent' : 'fixed', + 'price' => $row['price_value'], 'sortOrder' => $row['sort_order'], 'isDefault' => $row['default'], 'qtyMutability' => (bool)$row['qty_mutability'], diff --git a/BundleProductDataExporter/Model/Query/BundleProductOptionValuesQuery.php b/BundleProductDataExporter/Model/Query/BundleProductOptionValuesQuery.php index 13affafa9..ebf159c1f 100644 --- a/BundleProductDataExporter/Model/Query/BundleProductOptionValuesQuery.php +++ b/BundleProductDataExporter/Model/Query/BundleProductOptionValuesQuery.php @@ -114,6 +114,8 @@ public function getQuery(array $productIds, string $storeViewCode) : Select 'id' => 'main_table.selection_id', 'sort_order' => 'main_table.position', 'default' => 'main_table.is_default', + 'price_type' => 'main_table.selection_price_type', + 'price_value' => 'main_table.selection_price_value', 'attribute_id' => 'name_default.attribute_id', 'qty' => 'main_table.selection_qty', 'qty_mutability' => 'main_table.selection_can_change_qty', diff --git a/BundleProductDataExporter/Test/Integration/BundleProductTest.php b/BundleProductDataExporter/Test/Integration/BundleProductTest.php index 5b365b9af..8bb3ab922 100644 --- a/BundleProductDataExporter/Test/Integration/BundleProductTest.php +++ b/BundleProductDataExporter/Test/Integration/BundleProductTest.php @@ -38,14 +38,14 @@ protected function setUp() : void * @param array $bundleProductOptionsDataProvider * * @magentoDataFixture Magento/Bundle/_files/product_1.php - * @dataProvider getBundleProductOptionsDataProvider + * @dataProvider getBundleFixedProductOptionsDataProvider * * @magentoDbIsolation disabled * @magentoAppIsolation enabled * * @return void */ - public function testBundleProductOptions(array $bundleProductOptionsDataProvider) : void + public function testBundleFixedProductOptions(array $bundleProductOptionsDataProvider) : void { $extractedProduct = $this->getExtractedProduct('bundle-product', 'default'); $this->assertNotEmpty($extractedProduct, 'Feed data must not be empty'); @@ -56,12 +56,37 @@ public function testBundleProductOptions(array $bundleProductOptionsDataProvider } } + /** + * Validate bundle product options data + * + * @param array $bundleProductOptionsDataProvider + * + * @return void + * @throws \Zend_Db_Statement_Exception + * @magentoDataFixture Magento/Bundle/_files/dynamic_bundle_product_with_special_price.php + * @dataProvider getBundleDynamicProductOptionsDataProvider + * + * @magentoDbIsolation disabled + * @magentoAppIsolation enabled + * + */ + public function testBundleDynamicProductOptions(array $bundleProductOptionsDataProvider) : void + { + $extractedProduct = $this->getExtractedProduct('dynamic_bundle_product_with_special_price', 'default'); + $this->assertNotEmpty($extractedProduct, 'Feed data must not be empty'); + + foreach ($bundleProductOptionsDataProvider as $key => $expectedData) { + $diff = $this->arrayUtils->recursiveDiff($expectedData, $extractedProduct[$key]); + self::assertEquals([], $diff, 'Actual feed data doesn\'t equal to expected data'); + } + } + /** * Get bundle product options data provider * * @return array */ - public function getBundleProductOptionsDataProvider() : array + public function getBundleFixedProductOptionsDataProvider() : array { return [ 'bundleProduct' => [ @@ -86,6 +111,60 @@ public function getBundleProductOptionsDataProvider() : array 'isDefault' => false, 'qtyMutability' => true, 'sku' => 'simple', + 'price' => 2.75, + 'priceType' => 'fixed' + ], + ], + ], + ], + ], + ], + ], + ]; + } + + /** + * Get bundle product options data provider + * + * @return array + */ + public function getBundleDynamicProductOptionsDataProvider() : array + { + return [ + 'bundleProduct' => [ + 'item' => [ + 'feedData' => [ + 'sku' => 'dynamic_bundle_product_with_special_price', + 'storeViewCode' => 'default', + 'name' => 'Bundle Product', + 'type' => 'bundle', + 'optionsV2' => [ + [ + 'type' => 'bundle', + 'renderType' => 'select', + 'required' => true, + 'label' => 'Option 1', + 'sortOrder' => 0, + 'values' => [ + [ + 'sortOrder' => 0, + 'label' => 'Simple Product With Price 10', + 'qty' => 1, + 'isDefault' => false, + 'qtyMutability' => false, + 'sku' => 'simple1000', + 'price' => 0, + 'priceType' => 'fixed' + ], + [ + 'sortOrder' => 0, + 'label' => 'Simple Product With Price 20', + 'qty' => 1, + 'isDefault' => false, + 'qtyMutability' => false, + 'sku' => 'simple1001', + 'price' => 0, + 'priceType' => 'fixed' ], ], ], diff --git a/BundleProductDataExporter/etc/et_schema.xml b/BundleProductDataExporter/etc/et_schema.xml new file mode 100644 index 000000000..a36e6640e --- /dev/null +++ b/BundleProductDataExporter/etc/et_schema.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/CatalogDataExporter/Model/CreatePriceReadTable.php b/CatalogDataExporter/Model/CreatePriceReadTable.php deleted file mode 100644 index 26d9a8971..000000000 --- a/CatalogDataExporter/Model/CreatePriceReadTable.php +++ /dev/null @@ -1,64 +0,0 @@ -resourceConnection = $resourceConnection; - $this->dimensionCollectionFactory = $dimensionCollectionFactory; - $this->tableResolver = $tableResolver; - } - - /** - * @param string $mode - * @return void - */ - public function createView(string $mode): void - { - $connection = $this->resourceConnection->getConnection(); - $priceName = $this->resourceConnection->getTableName('catalog_product_index_price'); - $viewName = $priceName . '_read'; - - $sql = "CREATE OR REPLACE ALGORITHM = MERGE VIEW $viewName AS SELECT * FROM $priceName"; - foreach ($this->dimensionCollectionFactory->create($mode) as $dimensions) { - $dimensionTableName = $this->tableResolver->resolve('catalog_product_index_price', $dimensions); - $sql .= " UNION SELECT * FROM $dimensionTableName"; - } - - $connection->query($sql); - } -} \ No newline at end of file diff --git a/CatalogDataExporter/Model/Indexer/MarkRemovedEntities.php b/CatalogDataExporter/Model/Indexer/MarkRemovedEntities.php deleted file mode 100644 index ba80e9b12..000000000 --- a/CatalogDataExporter/Model/Indexer/MarkRemovedEntities.php +++ /dev/null @@ -1,61 +0,0 @@ -resourceConnection = $resourceConnection; - $this->markRemovedEntitiesQuery = $markRemovedEntitiesQuery; - } - - /** - * Mark specific store catalog items as deleted - * - * @param array $ids - * @param string $storeCode - * @param FeedIndexMetadata $feedIndexMetadata - * @throws \InvalidArgumentException - */ - public function execute(array $ids, string $storeCode, FeedIndexMetadata $feedIndexMetadata): void - { - $select = $this->markRemovedEntitiesQuery->getQuery($ids, $storeCode, $feedIndexMetadata); - $connection = $this->resourceConnection->getConnection(); - - $update = $connection->updateFromSelect( - $select, - ['f' => $this->resourceConnection->getTableName($feedIndexMetadata->getFeedTableName())] - ); - - $connection->query($update); - } -} diff --git a/CatalogDataExporter/Model/Provider/Product/CustomOptions.php b/CatalogDataExporter/Model/Provider/Product/CustomOptions.php index bcdf5f044..bc7c5987b 100644 --- a/CatalogDataExporter/Model/Provider/Product/CustomOptions.php +++ b/CatalogDataExporter/Model/Provider/Product/CustomOptions.php @@ -9,7 +9,6 @@ use Magento\CatalogDataExporter\Model\Query\CustomOptions as CustomOptionsQuery; use Magento\CatalogDataExporter\Model\Query\CustomOptionValues; -use Magento\Customer\Model\ResourceModel\Group\CollectionFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Exception\NoSuchEntityException; @@ -33,32 +32,19 @@ class CustomOptions */ private $customOptionValues; - /** - * @var CollectionFactory - */ - private $customerGroups; - - /** - * @var string[] - */ - private $customerGroupsArray = []; - /** * @param CustomOptionsQuery $customOptions * @param CustomOptionValues $customOptionValues * @param ResourceConnection $resourceConnection - * @param CollectionFactory $customerGroups */ public function __construct( CustomOptionsQuery $customOptions, CustomOptionValues $customOptionValues, ResourceConnection $resourceConnection, - CollectionFactory $customerGroups ) { $this->customOptions = $customOptions; $this->resourceConnection = $resourceConnection; $this->customOptionValues = $customOptionValues; - $this->customerGroups = $customerGroups; } /** @@ -80,12 +66,9 @@ public function get(array $productIds, array $optionTypes, string $storeViewCode ]); $productOptions = $connection->fetchAssoc($productOptionsSelect); $productOptions = $this->addValues($productOptions, $storeViewCode); - $productOptionsPercentPrices = $this->getPercentFinalPrice($productIds, $storeViewCode); - // $this->customerGroupsArray = $this->customerGroups->create()->toOptionArray(); foreach ($productOptions as $option) { if (in_array($option['type'], $optionTypes)) { - $option = $this->setPricingData($option, $productOptionsPercentPrices, $storeViewCode); $filteredProductOptions[$option['entity_id']][] = $option; } } @@ -125,58 +108,4 @@ private function addValues(array $productOptions, string $storeViewCode): array return $productOptions; } - - /** - * Get the final product Price - * - * @param array $productIds - * @param string $storeViewCode - * @return array - * @throws NoSuchEntityException - */ - public function getPercentFinalPrice(array $productIds, string $storeViewCode): array - { - $formattedPrices = []; - $priceQuery = $this->customOptionValues->percentPriceQuery($productIds, $storeViewCode); - $prices = $this->resourceConnection->getConnection()->fetchAll($priceQuery); - foreach ($prices as $price) { - $calculatedPrice = $price['price'] / 100 * $price['final_price']; - $key = $price['entity_id'] . $storeViewCode . $price['option_id']; - $formattedPrices[$key]['price'] = $calculatedPrice; - } - return $formattedPrices; - } - - /** - * Fill out the price by type - * - * @param array $option - * @param array $productOptionsPercentPrices - * @param string $storeViewCode - * @return array - */ - private function setPricingData(array $option, array $productOptionsPercentPrices, string $storeViewCode): array - { - if ($option['price_type'] === 'percent') { - $key = $option['entity_id'] . $storeViewCode . $option['option_id']; - if (isset($productOptionsPercentPrices[$key])) { - $option['price'] = $productOptionsPercentPrices[$key]['price']; - } - } elseif ($option['price_type'] === 'fixed') { - // TODO: should be handled by ProductOverride feed - // $prices = []; - // if (isset($option['price'])) { - // foreach ($this->customerGroupsArray as $customerGroup) { - // $prices[] = [ - // 'regularPrice' => $option['price'], - // 'finalPrice' => $option['price'], - // 'scope' => $customerGroup['label'], - // ]; - // } - // $option['price'] = $prices; - //} - } - - return $option; - } } diff --git a/CatalogDataExporter/Model/Provider/Product/Displayable.php b/CatalogDataExporter/Model/Provider/Product/Displayable.php index e67e920a8..6377f3865 100644 --- a/CatalogDataExporter/Model/Provider/Product/Displayable.php +++ b/CatalogDataExporter/Model/Provider/Product/Displayable.php @@ -7,66 +7,21 @@ namespace Magento\CatalogDataExporter\Model\Provider\Product; -use Magento\CatalogDataExporter\Model\Query\UnavailableProductQuery; -use Magento\Framework\App\ResourceConnection; -use Magento\Framework\Exception\NoSuchEntityException; - /** * Is product displayable data provider */ class Displayable { - /** - * @var ResourceConnection - */ - private $resourceConnection; - - /** - * @var UnavailableProductQuery - */ - private $unavailableProductQuery; - - /** - * @param ResourceConnection $resourceConnection - * @param UnavailableProductQuery $unavailableProductQuery - */ - public function __construct( - ResourceConnection $resourceConnection, - UnavailableProductQuery $unavailableProductQuery - ) { - $this->resourceConnection = $resourceConnection; - $this->unavailableProductQuery = $unavailableProductQuery; - } - /** * Get provider data * * @param array $values * @return array - * @throws NoSuchEntityException - * @throws \Zend_Db_Statement_Exception */ public function get(array $values) : array { - $connection = $this->resourceConnection->getConnection(); - $queryArguments = []; - - foreach ($values as $value) { - $queryArguments['productId'][$value['productId']] = $value['productId']; - $queryArguments['storeViewCode'][$value['storeViewCode']] = $value['storeViewCode']; - } - $unavailable = []; - foreach ($queryArguments['storeViewCode'] as $storeViewCode) { - $arguments = [ - 'productId' => $queryArguments['productId'], - 'storeViewCode' => $storeViewCode - ]; - $cursor = $connection->query($this->unavailableProductQuery->getQuery($arguments)); - while ($row = $cursor->fetch()) { - $unavailable[$storeViewCode][$row['productId']] = true; - } - } $output = []; + foreach ($values as $value) { $output[] = [ 'productId' => $value['productId'], @@ -74,10 +29,10 @@ public function get(array $values) : array 'displayable' => ( $value['status'] === 'Enabled' && in_array($value['visibility'], ['Catalog', 'Search', 'Catalog, Search']) - && !isset($unavailable[$value['storeViewCode']][$value['productId']]) ) ]; } + return $output; } } diff --git a/CatalogDataExporter/Model/Provider/Product/Prices.php b/CatalogDataExporter/Model/Provider/Product/Prices.php deleted file mode 100644 index 96a283066..000000000 --- a/CatalogDataExporter/Model/Provider/Product/Prices.php +++ /dev/null @@ -1,109 +0,0 @@ -resourceConnection = $resourceConnection; - $this->productPriceQuery = $productPriceQuery; - $this->logger = $logger; - } - - /** - * Format provider data - * - * @param array $row - * @return array - */ - private function format(array $row) : array - { - $output = [ - 'productId' => $row['productId'], - 'storeViewCode' => $row['storeViewCode'], - 'prices' => [ - 'minimumPrice' => [ - 'regularPrice' => (float)$row['price'] === 0.0 && $row['min_price'] !== 0.0 - ? $row['min_price'] - : $row['price'], - 'finalPrice' => (float)$row['final_price'] === 0.0 && $row['min_price'] !== 0.0 - ? $row['min_price'] - : $row['final_price'] - ], - 'maximumPrice' => [ - 'regularPrice' => $row['max_price'], - 'finalPrice' => $row['max_price'] - ] - ] - ]; - return $output; - } - - /** - * Get provider data - * - * @param array $values - * @return array - * @throws UnableRetrieveData - */ - public function get(array $values) : array - { - $connection = $this->resourceConnection->getConnection(); - $queryArguments = []; - try { - $output = []; - foreach ($values as $value) { - $queryArguments['productId'][$value['productId']] = $value['productId']; - $queryArguments['storeViewCode'][$value['storeViewCode']] = $value['storeViewCode']; - } - $select = $this->productPriceQuery->getQuery($queryArguments); - $cursor = $connection->query($select); - while ($row = $cursor->fetch()) { - $output[] = $this->format($row); - } - } catch (\Exception $exception) { - $this->logger->error($exception->getMessage(), ['exception' => $exception]); - throw new UnableRetrieveData('Unable to retrieve price data'); - } - return $output; - } -} diff --git a/CatalogDataExporter/Model/Provider/Product/ProductOptions/SelectableOptions.php b/CatalogDataExporter/Model/Provider/Product/ProductOptions/SelectableOptions.php index 8628d9bec..1e9eeb39b 100644 --- a/CatalogDataExporter/Model/Provider/Product/ProductOptions/SelectableOptions.php +++ b/CatalogDataExporter/Model/Provider/Product/ProductOptions/SelectableOptions.php @@ -121,8 +121,8 @@ private function processOptionValues( 'qty' => null, 'infoUrl' => null, 'sku' => $value['sku'], - //TODO: calculate price depend on price type: fixed/percent - 'price' => $value['price'] + 'price' => $value['price'], + 'priceType' => $value['price_type'], ]; } return $resultValues; diff --git a/CatalogDataExporter/Model/Query/CustomOptionValues.php b/CatalogDataExporter/Model/Query/CustomOptionValues.php index e3067271b..dd75eccfe 100644 --- a/CatalogDataExporter/Model/Query/CustomOptionValues.php +++ b/CatalogDataExporter/Model/Query/CustomOptionValues.php @@ -7,10 +7,8 @@ namespace Magento\CatalogDataExporter\Model\Query; -use Magento\CatalogDataExporter\Model\Resolver\PriceTableResolver; use Magento\Framework\App\ResourceConnection; use Magento\Framework\DB\Select; -use Magento\Framework\EntityManager\MetadataPool; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Store\Model\StoreRepository; @@ -31,32 +29,16 @@ class CustomOptionValues */ private $storeRepository; - /** - * @var MetadataPool - */ - private $metadataPool; - - /** - * @var PriceTableResolver - */ - private $priceTableResolver; - /** * @param ResourceConnection $resourceConnection * @param StoreRepository $storeRepository - * @param MetadataPool $metadataPool - * @param PriceTableResolver $priceTableResolver */ public function __construct( ResourceConnection $resourceConnection, - StoreRepository $storeRepository, - MetadataPool $metadataPool, - PriceTableResolver $priceTableResolver + StoreRepository $storeRepository ) { $this->resourceConnection = $resourceConnection; $this->storeRepository = $storeRepository; - $this->metadataPool = $metadataPool; - $this->priceTableResolver = $priceTableResolver; } /** @@ -80,44 +62,6 @@ public function query(array $arguments): Select return $select; } - /** - * Retrieve product price select from the index table - * - * @param array $productIds - * @param string $storeViewCode - * @return Select - * @throws NoSuchEntityException - */ - public function percentPriceQuery(array $productIds, string $storeViewCode): Select - { - $mainTable = $this->priceTableResolver->getTableName('catalog_product_index_price'); - $connection = $this->resourceConnection->getConnection(); - $websiteId = (int)$this->storeRepository->get($storeViewCode)->getWebsiteId(); - $select = $connection->select()->from( - ['main_table' => $mainTable], - ['final_price', 'customer_group_id', 'entity_id'] - ); - $select->joinCross( - ['catalog_product_option' => $this->resourceConnection->getTableName('catalog_product_option')], - ['option_id'] - ); - // should be handled by customer ProductOverride feed - // $select->joinLeft( - // ['customer_groups' => $this->resourceConnection->getTableName('customer_group')], - // 'customer_groups.customer_group_id = main_table.customer_group_id', - // ['customer_group_code'] - // ); - $select->joinLeft( - ['catalog_product_option_price' => $this->resourceConnection->getTableName('catalog_product_option_price')], - 'catalog_product_option_price.option_id = catalog_product_option.option_id', - ['price', 'price_type'] - ); - $select->where('main_table.entity_id IN (?)', $productIds); - $select->where('main_table.website_id = ?', $websiteId); - $select->where('catalog_product_option_price.price_type = ?', 'percent'); - return $select; - } - /** * Add prices to custom options * diff --git a/CatalogDataExporter/Model/Query/Eav/ProductAttributeQueryBuilder.php b/CatalogDataExporter/Model/Query/Eav/ProductAttributeQueryBuilder.php index 45155c9bf..42a317f33 100644 --- a/CatalogDataExporter/Model/Query/Eav/ProductAttributeQueryBuilder.php +++ b/CatalogDataExporter/Model/Query/Eav/ProductAttributeQueryBuilder.php @@ -25,8 +25,6 @@ class ProductAttributeQueryBuilder implements EavAttributeQueryBuilderInterface 'small_image' => ['small_image_label', 'name'], 'image' => ['image_label', 'name'], 'thumbnail' => ['thumbnail_label', 'name'], - 'price' => null, - 'tier_price' => null, ]; /** diff --git a/CatalogDataExporter/Model/Query/MarkRemovedEntitiesQuery.php b/CatalogDataExporter/Model/Query/MarkRemovedEntitiesQuery.php index 154ddd590..7096aa20a 100644 --- a/CatalogDataExporter/Model/Query/MarkRemovedEntitiesQuery.php +++ b/CatalogDataExporter/Model/Query/MarkRemovedEntitiesQuery.php @@ -12,14 +12,13 @@ use Magento\Framework\DB\Select; /** - * Mark removed entities select query provider + * Mark product feed item as removed when: + * - product deleted + * - product unassigned from website */ -class MarkRemovedEntitiesQuery +class MarkRemovedEntitiesQuery extends \Magento\DataExporter\Model\Query\MarkRemovedEntitiesQuery { - /** - * @var ResourceConnection - */ - private $resourceConnection; + private ResourceConnection $resourceConnection; /** * @param ResourceConnection $resourceConnection @@ -27,38 +26,35 @@ class MarkRemovedEntitiesQuery public function __construct(ResourceConnection $resourceConnection) { $this->resourceConnection = $resourceConnection; + parent::__construct($resourceConnection); } /** - * Get select query for marking removed entities + * @inheirtDoc * * @param array $ids - * @param string $storeCode - * @param FeedIndexMetadata $feedIndexMetadata - * @throws \InvalidArgumentException - * + * @param FeedIndexMetadata $metadata * @return Select */ - public function getQuery(array $ids, string $storeCode, FeedIndexMetadata $feedIndexMetadata): Select + public function getQuery(array $ids, FeedIndexMetadata $metadata): Select { - $connection = $this->resourceConnection->getConnection(); - $feedTableField = $feedIndexMetadata->getFeedTableField(); - if (empty($ids)) { - throw new \InvalidArgumentException( - 'Ids list can not be empty' - ); - } - return $connection->select() - ->joinInner( - ['feed' => $this->resourceConnection->getTableName($feedIndexMetadata->getFeedTableName())], - \sprintf( - 'feed.%s = f.%s', - $feedTableField, - $feedTableField - ), - ['is_deleted' => new \Zend_Db_Expr('1')] - ) - ->where(\sprintf('f.%s IN (?)', $feedTableField), $ids) - ->where(\sprintf('f.%s = ?', 'store_view_code'), $storeCode); + $select = parent::getQuery($ids, $metadata); + $select->reset(\Magento\Framework\DB\Select::WHERE); + $select->joinLeft( + ['store' => $this->resourceConnection->getTableName('store')], + 'f.store_view_code = store.code', + [] + )->joinLeft( + ['w' => $this->resourceConnection->getTableName('store_website')], + 'store.website_id = w.website_id', + [] + )->joinLeft( + ['pw' => $this->resourceConnection->getTableName('catalog_product_website')], + 'f.id = pw.product_id AND pw.website_id = w.website_id', + [] + )->where(\sprintf('f.%s IN (?)', $metadata->getFeedTableField()), $ids) + ->where(\sprintf('s.%s IS NULL OR pw.website_id is NULL', $metadata->getSourceTableField())); + + return $select; } } diff --git a/CatalogDataExporter/Model/Query/ProductPriceQuery.php b/CatalogDataExporter/Model/Query/ProductPriceQuery.php deleted file mode 100644 index 16fdc168c..000000000 --- a/CatalogDataExporter/Model/Query/ProductPriceQuery.php +++ /dev/null @@ -1,83 +0,0 @@ -resourceConnection = $resourceConnection; - $this->priceTableResolver = $priceTableResolver; - } - - /** - * Get resource table - * - * @param string $tableName - * @return string - */ - private function getTable(string $tableName) : string - { - return $this->priceTableResolver->getTableName($tableName); - } - - /** - * Get query for provider - * - * @param array $arguments - * @return Select - */ - public function getQuery(array $arguments) : Select - { - $productIds = isset($arguments['productId']) ? $arguments['productId'] : []; - $storeViewCodes = isset($arguments['storeViewCode']) ? $arguments['storeViewCode'] : []; - $connection = $this->resourceConnection->getConnection(); - $select = $connection->select() - ->from(['cpp' => $this->getTable('catalog_product_index_price')]) - ->join( - ['s' => $this->getTable('store')], - 's.website_id = cpp.website_id AND cpp.customer_group_id = 0', - ['storeViewCode' => 's.code'] - ) - ->columns( - [ - 'productId' => 'cpp.entity_id', - 'storeViewCode' => 's.code' - ] - ) - ->where('s.code IN (?)', $storeViewCodes) - ->where('cpp.entity_id IN (?)', $productIds); - return $select; - } -} diff --git a/CatalogDataExporter/Model/Query/UnavailableProductQuery.php b/CatalogDataExporter/Model/Query/UnavailableProductQuery.php deleted file mode 100644 index ab6411c72..000000000 --- a/CatalogDataExporter/Model/Query/UnavailableProductQuery.php +++ /dev/null @@ -1,92 +0,0 @@ -resourceConnection = $resourceConnection; - $this->tableMaintainer = $tableMaintainer; - $this->storeManager = $storeManager; - } - - /** - * Get resource table - * - * @param string $tableName - * @return string - */ - private function getTable(string $tableName): string - { - return $this->resourceConnection->getTableName($tableName); - } - - /** - * Get query for provider - * - * @param array $arguments - * @return Select - * @throws NoSuchEntityException - */ - public function getQuery(array $arguments) : Select - { - $productIds = isset($arguments['productId']) ? $arguments['productId'] : []; - $storeViewCode = isset($arguments['storeViewCode']) ? $arguments['storeViewCode'] : null; - $store = $this->storeManager->getStore($storeViewCode); - $connection = $this->resourceConnection->getConnection(); - return $connection->select() - ->from( - ['cpe' => $this->getTable('catalog_product_entity')], - [ - 'productId' => 'cpe.entity_id' - ] - ) - ->joinLeft( - ['ccp' => $this->tableMaintainer->getMainTable((int)$store->getId())], - 'cpe.entity_id = ccp.product_id', - [] - ) - ->where('ccp.product_id IS NULL') - ->where('cpe.entity_id IN (?)', $productIds); - } -} diff --git a/CatalogDataExporter/Model/Resolver/PriceTableResolver.php b/CatalogDataExporter/Model/Resolver/PriceTableResolver.php deleted file mode 100644 index 126667faf..000000000 --- a/CatalogDataExporter/Model/Resolver/PriceTableResolver.php +++ /dev/null @@ -1,59 +0,0 @@ -resourceConnection = $resourceConnection; - $this->dimensionModeConfiguration = $dimensionModeConfiguration; - } - - /** - * Resolve price table name for dimension - * @param $tableName - * @return string - */ - public function getTableName($tableName): string - { - $realTableName = $this->resourceConnection->getTableName($tableName); - if ($tableName === 'catalog_product_index_price' - && $this->dimensionModeConfiguration->getDimensionConfiguration() - ) { - return $realTableName . '_read'; - } - - return $realTableName; - - } -} diff --git a/CatalogDataExporter/Plugin/Index/CreateViewAfterSwitchDimensionMode.php b/CatalogDataExporter/Plugin/Index/CreateViewAfterSwitchDimensionMode.php deleted file mode 100644 index 213ef22cd..000000000 --- a/CatalogDataExporter/Plugin/Index/CreateViewAfterSwitchDimensionMode.php +++ /dev/null @@ -1,70 +0,0 @@ -createDbView = $createDbView; - $this->resourceConnection = $resourceConnection; - $this->logger = $logger; - } - - /** - * Recreate price view - * @param ModeSwitcher $subject - * @param null $result - * @param string $currentMode - * @return void - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ - public function afterSwitchMode( - ModeSwitcher $subject, - $result, - string $currentMode - ) { - try { - $connection = $this->resourceConnection->getConnection(); - $viewName = $this->resourceConnection->getTableName('catalog_product_index_price') . '_read'; - if ($currentMode === DimensionModeConfiguration::DIMENSION_NONE) { - $connection->query("DROP VIEW IF EXISTS $viewName"); - return; - } - $this->createDbView->createView($currentMode); - - return $result; - } catch (\Throwable $e) { - $this->logger->error( - 'Data Exporter exception has occurred: ' . $e->getMessage(), - ['exception' => $e] - ); - } - } -} diff --git a/CatalogDataExporter/Plugin/Index/CreateViewAfterTableMaintenance.php b/CatalogDataExporter/Plugin/Index/CreateViewAfterTableMaintenance.php deleted file mode 100644 index a4450a906..000000000 --- a/CatalogDataExporter/Plugin/Index/CreateViewAfterTableMaintenance.php +++ /dev/null @@ -1,92 +0,0 @@ -scopeConfig = $scopeConfig; - $this->createDbView = $createDbView; - $this->logger = $logger; - } - - /** - * Recreate price view - * @param TableMaintainer $subject - * @param null $result - * @param array $dimensions - * @return void - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ - public function afterCreateTablesForDimensions( - TableMaintainer $subject, - $result, - array $dimensions - ): void { - try { - $mode = $this->scopeConfig->getValue(ModeSwitcherConfiguration::XML_PATH_PRICE_DIMENSIONS_MODE); - if ($mode !== DimensionModeConfiguration::DIMENSION_NONE) { - $this->createDbView->createView($mode); - } - } catch (\Throwable $e) { - $this->logger->error( - 'Data Exporter exception has occurred: ' . $e->getMessage(), - ['exception' => $e] - ); - } - } - - /** - * Recreate price view - * @param TableMaintainer $subject - * @param null $result - * @param array $dimensions - * @return void - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ - public function afterDropTablesForDimensions( - TableMaintainer $subject, - $result, - array $dimensions - ): void { - try { - $mode = $this->scopeConfig->getValue(ModeSwitcherConfiguration::XML_PATH_PRICE_DIMENSIONS_MODE); - if ($mode !== DimensionModeConfiguration::DIMENSION_NONE) { - $this->createDbView->createView($mode); - } - } catch (\Throwable $e) { - $this->logger->error( - 'Data Exporter exception has occurred: ' . $e->getMessage(), - ['exception' => $e] - ); - } - } -} diff --git a/CatalogDataExporter/Plugin/Index/SaveNewPriceIndexerMode.php b/CatalogDataExporter/Plugin/Index/SaveNewPriceIndexerMode.php deleted file mode 100644 index 59dffabe2..000000000 --- a/CatalogDataExporter/Plugin/Index/SaveNewPriceIndexerMode.php +++ /dev/null @@ -1,61 +0,0 @@ -scopeConfig = $scopeConfig; - $this->logger = $logger; - } - - /** - * Save new price mode - * - * @param ModeSwitcherConfiguration $subject - * @param $result - * @param string $mode - * @return void - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ - public function afterSaveMode( - ModeSwitcherConfiguration $subject, - $result, - string $mode - ) { - try { - $this->scopeConfig->setValue( - ModeSwitcherConfiguration::XML_PATH_PRICE_DIMENSIONS_MODE, - $mode - ); - } catch (\Throwable $e) { - $this->logger->error( - 'Data Exporter exception has occurred: ' . $e->getMessage(), - ['exception' => $e] - ); - } - return $result; - } -} diff --git a/CatalogDataExporter/Plugin/Product/BulkWebsiteUnassign.php b/CatalogDataExporter/Plugin/Product/BulkWebsiteUnassign.php deleted file mode 100644 index c5e2d6dfd..000000000 --- a/CatalogDataExporter/Plugin/Product/BulkWebsiteUnassign.php +++ /dev/null @@ -1,78 +0,0 @@ -markRemovedEntities = $markRemovedEntities; - $this->feedIndexMetadata = $feedIndexMetadata; - $this->productExporterFeedQuery = $productExporterFeedQuery; - $this->logger = $logger; - } - - /** - * Set is_deleted value to 1 for product export entities when websites were unassigned via bulk operation - * - * @param ProductAction $subject - * @param $result - * @param array $productIds - * @param array $websiteIds - * @param string $type - * @return void - * @throws \InvalidArgumentException - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ - public function afterUpdateWebsites(ProductAction $subject, $result, $productIds, $websiteIds, $type) - { - try { - if ($type === self::REMOVE_ACTION && !empty($websiteIds)) { - $deletedFeedItems = []; - $feedItems = $this->productExporterFeedQuery->getFeedItems($productIds, $websiteIds); - foreach ($feedItems as $itemToDelete) { - $deletedFeedItems[$itemToDelete['store_view_code']][] = $itemToDelete['id']; - } - foreach ($deletedFeedItems as $storeCode => $ids) { - $this->markRemovedEntities->execute($ids, $storeCode, $this->feedIndexMetadata); - } - } - } catch (\Throwable $e) { - $this->logger->error( - 'Data Exporter exception has occurred: ' . $e->getMessage(), - ['exception' => $e] - ); - } - return $result; - } -} diff --git a/CatalogDataExporter/Plugin/Product/WebsiteUnassign.php b/CatalogDataExporter/Plugin/Product/WebsiteUnassign.php deleted file mode 100644 index f9d59babf..000000000 --- a/CatalogDataExporter/Plugin/Product/WebsiteUnassign.php +++ /dev/null @@ -1,79 +0,0 @@ -markRemovedEntities = $markRemovedEntities; - $this->feedIndexMetadata = $feedIndexMetadata; - $this->productExporterFeedQuery = $productExporterFeedQuery; - $this->logger = $logger; - } - - /** - * Set is_deleted value to 1 for product export entity when website unassigned - * - * @param Link $subject - * @param int $productId - * @param string[] $websiteIds - * @return null - * @throws \InvalidArgumentException - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ - public function beforeUpdateProductWebsite( - Link $subject, - int $productId, - array $websiteIds - ) { - try { - $originWebsites = $subject->getWebsiteIdsByProductId($productId); - $deleteInWebsites = array_diff($originWebsites, $websiteIds); - if (!empty($deleteInWebsites)) { - $deletedFeedItems = []; - $feedItems = $this->productExporterFeedQuery->getFeedItems([$productId], $deleteInWebsites); - foreach ($feedItems as $itemToDelete) { - $deletedFeedItems[$itemToDelete['store_view_code']][] = $itemToDelete['id']; - } - foreach ($deletedFeedItems as $storeCode => $ids) { - $this->markRemovedEntities->execute($ids, $storeCode, $this->feedIndexMetadata); - } - } - } catch (\Throwable $e) { - $this->logger->error( - 'Data Exporter exception has occurred: ' . $e->getMessage(), - ['exception' => $e] - ); - } - return null; - } -} diff --git a/CatalogDataExporter/Setup/Patch/Schema/MakeReadTableForDimension.php b/CatalogDataExporter/Setup/Patch/Schema/MakeReadTableForDimension.php deleted file mode 100644 index 4ebb23b7e..000000000 --- a/CatalogDataExporter/Setup/Patch/Schema/MakeReadTableForDimension.php +++ /dev/null @@ -1,71 +0,0 @@ -scopeConfig = $scopeConfig; - $this->createDbView = $createDbView; - } - - - /** - * @inheritDoc - */ - public function apply(): PatchInterface - { - $mode = $this->scopeConfig->getValue(ModeSwitcherConfiguration::XML_PATH_PRICE_DIMENSIONS_MODE); - if ($mode !== DimensionModeConfiguration::DIMENSION_NONE) { - $this->createDbView->createView($mode); - } - return $this; - } - - /** - * @inheritDoc - */ - public static function getDependencies(): array - { - return []; - } - - /** - * @inheritDoc - */ - public function getAliases(): array - { - return []; - } -} diff --git a/CatalogDataExporter/Test/Integration/AbstractProductTestHelper.php b/CatalogDataExporter/Test/Integration/AbstractProductTestHelper.php index 63eaed928..e8a0442b5 100644 --- a/CatalogDataExporter/Test/Integration/AbstractProductTestHelper.php +++ b/CatalogDataExporter/Test/Integration/AbstractProductTestHelper.php @@ -26,6 +26,7 @@ use Magento\Store\Api\GroupRepositoryInterface; use Magento\Tax\Model\TaxClass\Source\Product as TaxClassSource; use Magento\TestFramework\Helper\Bootstrap; +use function PHPUnit\Framework\assertEmpty; /** * Abstract Class AbstractProductTestHelper @@ -165,57 +166,20 @@ protected function getExtractedProduct(string $sku, string $storeViewCode) : arr return $data[$sku]; } - /** - * Get the pricing data for product and website - * - * @param ProductInterface $product - * @return array - * @throws LocalizedException - * @throws \Zend_Db_Statement_Exception - */ - protected function getPricingData(ProductInterface $product) : array - { - $query = $this->connection->select() - ->from(['p' => 'catalog_product_index_price']) - ->where('p.entity_id = ?', $product->getId()) - ->where('p.customer_group_id = 0') - ->where('p.website_id = ?', $this->storeManager->getWebsite()->getId()); - $cursor = $this->connection->query($query); - $data = []; - while ($row = $cursor->fetch()) { - $data['price'] = $row['price']; - $data['final_price'] = $row['final_price']; - $data['min_price'] = $row['min_price']; - $data['max_price'] = $row['max_price']; - } - return $data; - } - /** * Validate pricing data in extracted product data * - * @param ProductInterface $product * @param array $extractedProduct * @return void * @throws NoSuchEntityException * @throws LocalizedException * @throws \Zend_Db_Statement_Exception */ - protected function validatePricingData(ProductInterface $product, array $extractedProduct) : void + protected function validatePricingData(array $extractedProduct) : void { - $pricingData = $this->getPricingData($product); $currencyCode = $this->storeManager->getStore()->getCurrentCurrency()->getCode(); $this->assertEquals($currencyCode, $extractedProduct['feedData']['currency']); - if ($product->getStatus() == 1) { - $extractedPricingData = $extractedProduct['feedData']['prices']; - $this->assertEquals($pricingData['price'], $extractedPricingData['minimumPrice']['regularPrice']); - $this->assertEquals($pricingData['final_price'], $extractedPricingData['minimumPrice']['finalPrice']); - $this->assertEquals($pricingData['max_price'], $extractedPricingData['maximumPrice']['regularPrice']); - $this->assertEquals($pricingData['max_price'], $extractedPricingData['maximumPrice']['finalPrice']); - } else { - $this->assertEquals(null, $extractedProduct['feedData']['prices']); - } } /** diff --git a/CatalogDataExporter/Test/Integration/DownloadableProductsTest.php b/CatalogDataExporter/Test/Integration/DownloadableProductsTest.php index 54c537d72..f62445667 100644 --- a/CatalogDataExporter/Test/Integration/DownloadableProductsTest.php +++ b/CatalogDataExporter/Test/Integration/DownloadableProductsTest.php @@ -44,7 +44,7 @@ public function testDownloadableProducts() : void $extractedProduct = $this->getExtractedProduct($sku, $storeViewCode); $this->validateBaseProductData($product, $extractedProduct, $storeViewCode); - $this->validatePricingData($product, $extractedProduct); + $this->validatePricingData($extractedProduct); $this->validateImageUrls($product, $extractedProduct); $this->validateAttributeData($product, $extractedProduct); $this->validateMediaGallery($product, $extractedProduct); diff --git a/CatalogDataExporter/Test/Integration/ProductDataSerializerTest.php b/CatalogDataExporter/Test/Integration/ProductDataSerializerTest.php new file mode 100644 index 000000000..3bffe3b9e --- /dev/null +++ b/CatalogDataExporter/Test/Integration/ProductDataSerializerTest.php @@ -0,0 +1,205 @@ +testUnit = Bootstrap::getObjectManager()->create( + ProductDataSerializer::class // @phpstan-ignore-line + ); + $this->productFeed = Bootstrap::getObjectManager()->get(FeedPool::class)->getFeed('products'); + $this->feedExportStatusBuilder = Bootstrap::getObjectManager()->get(FeedExportStatusBuilder::class); + } + + /** + * @return void + */ + public function testSecondFeedItemHasErrorInExportStatus(): void + { + $feedItems = [ + [ + 'feed' => [ + 'sku' => 'valid-sku1', + 'storeViewCode' => 'default', + 'storeCode' => 'main_website_store', + 'websiteCode' => 'base', + 'productId' => 1, + 'deleted' => false, + 'modifiedAt' => "2023-01-06 23:36:51" + ], + 'hash' => 'hash', + ], + [ + 'feed' => [ + 'sku' => 'wrong-data-in-feed', + 'storeViewCode' => 'default', + 'storeCode' => 'main_website_store', + 'websiteCode' => 'base', + 'productId' => 2, + 'deleted' => false, + 'modifiedAt' => "2023-01-06 23:36:51" + ], + 'hash' => 'hash', + ], + [ + 'feed' => [ + 'sku' => 'valid-sku2', + 'storeViewCode' => 'default', + 'storeCode' => 'main_website_store', + 'websiteCode' => 'base', + 'productId' => 3, + 'deleted' => false, + 'modifiedAt' => "2023-01-06 23:36:51" + ], + 'hash' => 'hash', + ], + ]; + + $exportStatus = $this->feedExportStatusBuilder->build( + 200, + 'Failed to save feed', + [ + // only 2nd item failed + 1 => [ + 'message' => 'SKU "wrong-data-in-feed" not processed', + 'field' => "sku" + ], + ] + ); + + $this->assertEquals( + $this->prepareExpectedData($feedItems, $exportStatus, 1), + $this->testUnit->serialize($feedItems, $exportStatus, $this->productFeed->getFeedMetadata()) + ); + } + + /** + * @return void + */ + public function testAllItemsHaveErrorsInExportStatus(): void + { + $feedItems = [ + [ + 'feed' => [ + 'sku' => 'sku1', + 'storeViewCode' => 'default', + 'storeCode' => 'main_website_store', + 'websiteCode' => 'base', + 'productId' => 1, + 'deleted' => false, + 'modifiedAt' => "2023-01-06 23:36:51" + ], + 'hash' => 'hash', + ], + [ + 'feed' => [ + 'sku' => 'sku2', + 'storeViewCode' => 'default', + 'storeCode' => 'main_website_store', + 'websiteCode' => 'base', + 'productId' => 2, + 'deleted' => false, + 'modifiedAt' => "2023-01-06 23:36:51" + ], + 'hash' => 'hash', + ], + [ + 'feed' => [ + 'sku' => 'sku3', + 'storeViewCode' => 'default', + 'storeCode' => 'main_website_store', + 'websiteCode' => 'base', + 'productId' => 3, + 'deleted' => false, + 'modifiedAt' => "2023-01-06 23:36:51" + ], + 'hash' => 'hash', + ], + ]; + + $exportStatus = $this->feedExportStatusBuilder->build( + 501, + 'Failed to save feed' + ); + + $this->assertEquals( + $this->prepareExpectedData($feedItems, $exportStatus), + $this->testUnit->serialize($feedItems, $exportStatus, $this->productFeed->getFeedMetadata()) + ); + } + + /** + * @param array $feedItems + * @param FeedExportStatus $exportStatus + * @param $failedSkuPosition + * @return array + */ + private function prepareExpectedData( + array $feedItems, + FeedExportStatus $exportStatus, + $failedSkuPosition = null + ): array { + $expected = []; + $failedStatus = $failedSkuPosition ? $exportStatus->getFailedItems()[$failedSkuPosition] : null; + $status = $exportStatus->getStatus()->getValue(); + foreach ($feedItems as $position => $item) { + $feed = $item['feed']; + $expected[] = [ + 'sku' => $feed['sku'], + 'id' => $feed['productId'], + 'store_view_code' => $feed['storeViewCode'], + 'is_deleted' => $feed['deleted'], + 'status' => $failedSkuPosition + ? ($failedSkuPosition === $position ? ExportStatusCodeProvider::FAILED_ITEM_ERROR : $status) + : $status, + 'modified_at' => $feed['modifiedAt'], + 'errors' => $failedSkuPosition + ? ($failedSkuPosition === $position ? $failedStatus['message'] : '') + : $exportStatus->getReasonPhrase(), + 'feed_data' => $this->jsonSerializer->serialize($feed), + 'feed_hash' => $item['hash'] + ]; + } + return $expected; + } +} diff --git a/CatalogDataExporter/Test/Integration/ResubmitFailedFeedTest.php b/CatalogDataExporter/Test/Integration/ResubmitFailedFeedTest.php new file mode 100644 index 000000000..bd950de20 --- /dev/null +++ b/CatalogDataExporter/Test/Integration/ResubmitFailedFeedTest.php @@ -0,0 +1,186 @@ +configure([ + 'preferences' => [ + ExportFeedInterface::class => + ExportFeedStub::class, + ] + ]); + $connection = Bootstrap::getObjectManager()->create(ResourceConnection::class)->getConnection(); + $feedTable = $connection->getTableName( + Bootstrap::getObjectManager()->get(FeedPool::class) + ->getFeed('products') + ->getFeedMetadata() + ->getFeedTableName() + ); + $connection->truncateTable($feedTable); + } + + /** + * Integration test setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->productFeed = Bootstrap::getObjectManager()->get(FeedPool::class)->getFeed('products'); + $this->submitFeed = Bootstrap::getObjectManager()->get(ProductSubmitFeed::class); // @phpstan-ignore-line + $this->productRepository = Bootstrap::getObjectManager()->create(ProductRepositoryInterface::class); + $this->resourceConnection = Bootstrap::getObjectManager()->create(ResourceConnection::class); + } + + /** + * @magentoDbIsolation disabled + * @magentoAppIsolation enabled + * @magentoConfigFixture services_connector/services_connector_integration/production_api_key test_key + * @magentoDataFixture Magento_CatalogDataExporter::Test/_files/setup_simple_products.php + * @dataProvider productsWithStatusesDataProvider + * + * @param array $expectedProducts + * @return void + * @throws NoSuchEntityException + * @throws \Zend_Db_Statement_Exception + */ + public function testResubmitFailedFeed(array $expectedProducts) : void + { + $this->updateFeeds($expectedProducts); + $this->submitFeed->execute(); + + $feeds = $this->productFeed->getFeedSince('1'); + foreach ($expectedProducts as $expectedProduct) { + $this->checkProductInFeed($expectedProduct, $feeds['feed']); + } + } + + /** + * @param array $expectedProducts + * @return void + * @throws NoSuchEntityException + */ + private function updateFeeds(array $expectedProducts): void + { + $queryData = []; + foreach ($expectedProducts as $productData) { + $queryData[] = [ + 'id' => $this->productRepository->get($productData['sku'])->getId(), + 'sku' => $productData['sku'], + 'store_view_code' => $productData['store_view_code'], + 'status' => $productData['status'] + ]; + } + $connection = $this->resourceConnection->getConnection(); + $connection->insertOnDuplicate( + $connection->getTableName($this->productFeed->getFeedMetadata()->getFeedTableName()), + $queryData + ); + } + + /** + * @param array $expectedProduct + * @param array $actualFeed + * @return void + */ + private function checkProductInFeed(array $expectedProduct, array $actualFeed): void + { + $productStatusCorrect = $expectedProduct['expected_status'] !== self::EXPORT_SUCCESS_STATUS; + foreach ($actualFeed as $actualProductData) { + if (!$productStatusCorrect + && $expectedProduct['sku'] === $actualProductData['sku'] + && $expectedProduct['store_view_code'] === $actualProductData['storeViewCode']) { + $productStatusCorrect = true; + break; + } + } + + self::assertTrue($productStatusCorrect, 'Product ' . $expectedProduct['sku'] + . 'has wrong status or absent in the feed'); + } + + /** + * Get product with statuses + * + * @return array[] + */ + public function productsWithStatusesDataProvider(): array + { + return [ + [ + [ + [ + 'sku' => 'simple1', + 'store_view_code' => 'default', + 'status' => self::EXPORT_SUCCESS_STATUS, + 'expected_status' => self::EXPORT_SUCCESS_STATUS + ], + [ + 'sku' => 'simple1', + 'store_view_code' => 'fixture_second_store', + 'status' => ExportStatusCodeProvider::APPLICATION_ERROR, + 'expected_status' => self::EXPORT_SUCCESS_STATUS + ], + [ + 'sku' => 'simple2', + 'store_view_code' => 'default', + 'status' => self::EXPORT_SUCCESS_STATUS, + 'expected_status' => self::EXPORT_SUCCESS_STATUS + ], + [ + 'sku' => 'simple2', + 'store_view_code' => 'fixture_second_store', + 'status' => 400, + 'expected_status' => 400 + ], + [ + 'sku' => 'simple3', + 'store_view_code' => 'default', + 'status' => ExportStatusCodeProvider::APPLICATION_ERROR, + 'expected_status' => self::EXPORT_SUCCESS_STATUS + ], + [ + 'sku' => 'simple3', + 'store_view_code' => 'fixture_second_store', + 'status' => 500, + 'expected_status' => self::EXPORT_SUCCESS_STATUS + ] + ] + ] + ]; + } +} diff --git a/CatalogDataExporter/Test/Integration/SimpleProductsTest.php b/CatalogDataExporter/Test/Integration/SimpleProductsTest.php index 37135c28e..d75024ebf 100644 --- a/CatalogDataExporter/Test/Integration/SimpleProductsTest.php +++ b/CatalogDataExporter/Test/Integration/SimpleProductsTest.php @@ -45,7 +45,7 @@ public function testSimpleProducts() : void $this->validateBaseProductData($product, $extractedProduct, $storeViewCode); $this->validateRealProductData($product, $extractedProduct); $this->validateCategoryData($product, $extractedProduct, $storeViewCode); - $this->validatePricingData($product, $extractedProduct); + $this->validatePricingData($extractedProduct); $this->validateImageUrls($product, $extractedProduct); $this->validateAttributeData($product, $extractedProduct); $this->validateMediaGallery($product, $extractedProduct); @@ -63,8 +63,6 @@ public function testSimpleProducts() : void * @magentoDataFixture Magento_CatalogDataExporter::Test/_files/setup_simple_products_without_date.php * * @return void - * @throws NoSuchEntityException - * @throws LocalizedException * @throws \Zend_Db_Statement_Exception * @throws \Throwable */ @@ -72,10 +70,6 @@ public function testSimpleProductsWithoutCreatedAtAndUpdatedAt() : void { $sku = 'simple1'; $storeViewCode = 'default'; - $store = $this->storeManager->getStore($storeViewCode); - - $product = $this->productRepository->get($sku, false, $store->getId()); - //$product->setTypeInstance(Bootstrap::getObjectManager()->create(Simple::class)); $extractedProduct = $this->getExtractedProduct($sku, $storeViewCode); diff --git a/CatalogDataExporter/composer.json b/CatalogDataExporter/composer.json index ec0e075e9..a46e186aa 100644 --- a/CatalogDataExporter/composer.json +++ b/CatalogDataExporter/composer.json @@ -21,12 +21,14 @@ "magento/module-eav": ">=102.1.4", "magento/module-store": ">=101.1.4", "magento/module-directory": ">=100.4.4", - "magento/module-customer": ">=103.0.4", "magento/module-downloadable": ">=100.4.4", "magento/module-indexer": ">=100.4.4", "magento/module-grouped-product": ">=100.4.2", "magento/module-data-exporter": "self.version" }, + "require-dev": { + "magento/module-customer": ">=103.0.4" + }, "suggest": { "magento/module-config": ">=101.2.4", "magento/module-catalog": ">=104.0.4" diff --git a/CatalogDataExporter/etc/db_schema.xml b/CatalogDataExporter/etc/db_schema.xml index f127827db..e95f32a5c 100644 --- a/CatalogDataExporter/etc/db_schema.xml +++ b/CatalogDataExporter/etc/db_schema.xml @@ -49,6 +49,26 @@ default="0" comment="Product Deleted" /> + + + diff --git a/CatalogDataExporter/etc/di.xml b/CatalogDataExporter/etc/di.xml index 4cf56b411..dccb97a8c 100644 --- a/CatalogDataExporter/etc/di.xml +++ b/CatalogDataExporter/etc/di.xml @@ -227,6 +227,22 @@ is_deleted sku + true + Magento\DataExporter\Model\ExportFeedInterface::PERSIST_EXPORTED_FEED + + + productId + sku + storeViewCode + storeCode + websiteCode + + + + productId + storeViewCode + + @@ -239,10 +255,21 @@ + + + Magento\CatalogDataExporter\Model\Query\MarkRemovedEntitiesQuery + + + + + Magento\CatalogDataExporter\Model\Indexer\ProductFeedMarkRemovedEntities + + Magento\CatalogDataExporter\Model\Indexer\ProductFeedIndexMetadata Magento\CatalogDataExporter\Model\Indexer\ProductDataSerializer + Magento\CatalogDataExporter\Model\Indexer\ProductFeedIndexProcessorCreateUpdateDelete @@ -462,22 +489,6 @@ - - - - - - - - - Magento\CatalogDataExporter\Model\Indexer\ProductFeedIndexMetadata - - - - - Magento\CatalogDataExporter\Model\Indexer\ProductFeedIndexMetadata - - Magento\CatalogDataExporter\Model\Indexer\ProductFeedIndexMetadata @@ -511,17 +522,18 @@ - - - - - - - - - - + + + + + Magento\CatalogDataExporter\Model\Indexer\ProductFeedIndexMetadata + Magento\CatalogDataExporter\Model\Indexer\ProductAttributeFeedIndexMetadata + Magento\CatalogDataExporter\Model\Indexer\CategoryFeedIndexMetadata + + + + diff --git a/CatalogDataExporter/etc/et_schema.xml b/CatalogDataExporter/etc/et_schema.xml index 2df83ec3d..1a38c24e4 100644 --- a/CatalogDataExporter/etc/et_schema.xml +++ b/CatalogDataExporter/etc/et_schema.xml @@ -70,7 +70,6 @@ - - - - - - - - - - - - - @@ -181,15 +168,6 @@ - - - - - - - - - diff --git a/CatalogDataExporter/etc/module.xml b/CatalogDataExporter/etc/module.xml index 76bc6aa26..4d08d9ef8 100644 --- a/CatalogDataExporter/etc/module.xml +++ b/CatalogDataExporter/etc/module.xml @@ -12,7 +12,6 @@ - diff --git a/CatalogDataExporter/etc/mview.xml b/CatalogDataExporter/etc/mview.xml index ece5f3632..ee9bc36fb 100644 --- a/CatalogDataExporter/etc/mview.xml +++ b/CatalogDataExporter/etc/mview.xml @@ -18,8 +18,6 @@
-
-
diff --git a/ConfigurableProductDataExporter/Model/Provider/Product/Variants.php b/ConfigurableProductDataExporter/Model/Provider/Product/Variants.php index 2150c4c32..8baaf12a3 100644 --- a/ConfigurableProductDataExporter/Model/Provider/Product/Variants.php +++ b/ConfigurableProductDataExporter/Model/Provider/Product/Variants.php @@ -65,7 +65,7 @@ public function get(array $values) : array $output = []; foreach ($values as $value) { if (!isset($value['productId'], $value['type'], $value['storeViewCode']) - || $value['type'] !== Configurable::TYPE_CODE ) { + || $value['type'] !== Configurable::TYPE_CODE) { continue; } $queryArguments['productId'][$value['productId']] = $value['productId']; @@ -82,10 +82,6 @@ public function get(array $values) : array $output[$key]['variants']['sku'] = $row['sku']; $output[$key]['productId'] = $row['productId']; $output[$key]['storeViewCode'] = $row['storeViewCode']; - $output[$key]['variants']['minimumPrice']['regularPrice'] = $row['price']; - $output[$key]['variants']['minimumPrice']['finalPrice'] = $row['finalPrice']; - // Product.Variants are deprecated. Variant.Selections not used anymore - $output[$key]['variants']['selections'] = []; } } catch (\Exception $exception) { $this->logger->error($exception->getMessage(), ['exception' => $exception]); diff --git a/ConfigurableProductDataExporter/Model/Query/VariantsQuery.php b/ConfigurableProductDataExporter/Model/Query/VariantsQuery.php index 257debe3b..a6cea1aa2 100644 --- a/ConfigurableProductDataExporter/Model/Query/VariantsQuery.php +++ b/ConfigurableProductDataExporter/Model/Query/VariantsQuery.php @@ -7,7 +7,6 @@ namespace Magento\ConfigurableProductDataExporter\Model\Query; -use Magento\CatalogDataExporter\Model\Resolver\PriceTableResolver; use Magento\Framework\App\ResourceConnection; use Magento\Framework\DB\Select; @@ -22,32 +21,12 @@ class VariantsQuery private $resourceConnection; /** - * @var PriceTableResolver - */ - private $priceTableResolver; - - /** - * VariantsQuery constructor. * @param ResourceConnection $resourceConnection - * @param PriceTableResolver $priceTableResolver */ public function __construct( - ResourceConnection $resourceConnection, - PriceTableResolver $priceTableResolver + ResourceConnection $resourceConnection ) { $this->resourceConnection = $resourceConnection; - $this->priceTableResolver = $priceTableResolver; - } - - /** - * Get resource table - * - * @param string $tableName - * @return string - */ - private function getTable(string $tableName) : string - { - return $this->priceTableResolver->getTableName($tableName); } /** @@ -58,47 +37,44 @@ private function getTable(string $tableName) : string */ public function getQuery(array $arguments) : Select { - $productIds = isset($arguments['productId']) ? $arguments['productId'] : []; + $productIds = $arguments['productId'] ?? []; $connection = $this->resourceConnection->getConnection(); - $joinField = $connection->getAutoIncrementField($this->getTable('catalog_product_entity')); + $joinField = $connection->getAutoIncrementField( + $this->resourceConnection->getTableName('catalog_product_entity') + ); $select = $connection->select() - ->from(['cpsl' => $this->getTable('catalog_product_super_link')], []) + ->from(['cpsl' => $this->resourceConnection->getTableName('catalog_product_super_link')], []) ->joinInner( - ['cpsa' => $this->getTable('catalog_product_super_attribute')], + ['cpsa' => $this->resourceConnection->getTableName('catalog_product_super_attribute')], 'cpsa.product_id = cpsl.parent_id', [] ) ->joinInner( - ['cpe' => $this->getTable('catalog_product_entity')], + ['cpe' => $this->resourceConnection->getTableName('catalog_product_entity')], 'cpe.entity_id = cpsl.product_id', [] ) ->joinInner( - ['cpeParent' => $this->getTable('catalog_product_entity')], + ['cpeParent' => $this->resourceConnection->getTableName('catalog_product_entity')], sprintf('cpeParent.%1$s = cpsl.parent_id', $joinField), [] - ) - ->joinInner( - ['cpip' => $this->getTable('catalog_product_index_price')], - 'cpip.entity_id = cpe.entity_id', + )->join( + ['product_website' => $this->resourceConnection->getTableName('catalog_product_website')], + 'product_website.product_id = cpe.entity_id', [] - ) - ->joinInner( - ['s' => $this->getTable('store')], - 's.website_id = cpip.website_id' + )->joinInner( + ['s' => $this->resourceConnection->getTableName('store')], + 's.website_id = product_website.website_id' ) ->columns( [ 'storeViewCode' => 's.code', 'productId' => 'cpeParent.entity_id', - 'sku' => 'cpe.sku', - 'price' => 'cpip.price', - 'finalPrice' => 'cpip.final_price', + 'sku' => 'cpe.sku' ] ) ->where('cpeParent.entity_id IN (?)', $productIds) - ->where('cpip.customer_group_id = 0') ->distinct(); return $select; diff --git a/ConfigurableProductDataExporter/Test/Integration/ConfigurableProductsTest.php b/ConfigurableProductDataExporter/Test/Integration/ConfigurableProductsTest.php index c6b28d027..581533024 100755 --- a/ConfigurableProductDataExporter/Test/Integration/ConfigurableProductsTest.php +++ b/ConfigurableProductDataExporter/Test/Integration/ConfigurableProductsTest.php @@ -69,7 +69,7 @@ public function testConfigurableProducts() : void $this->validateBaseProductData($product, $extractedProduct, $storeViewCode); $this->validateRealProductData($product, $extractedProduct); $this->validateCategoryData($product, $extractedProduct, $storeViewCode); - $this->validatePricingData($product, $extractedProduct); + $this->validatePricingData($extractedProduct); $this->validateImageUrls($product, $extractedProduct); $this->validateAttributeData($product, $extractedProduct); $this->validateOptionsData($product, $extractedProduct); @@ -124,7 +124,6 @@ public function testParentProductsOnDifferentWebsites() : void { $this->runIndexer([40, 50, 60, 70, 55, 59, 65]); - $skus = [ 'simple_option_50' => [ 'custom_store_view_one' => true, @@ -249,14 +248,8 @@ private function validateVariantsData(ProductInterface $product, array $extract) $variants = []; foreach ($childIds as $childId) { $childProduct = $this->productRepository->getById($childId); - $childProductPricing = $this->getPricingData($childProduct); $variants[] = [ 'sku' => $childProduct->getSku(), - 'minimumPrice' => [ - 'regularPrice' => $childProductPricing['price'], - 'finalPrice' => $childProductPricing['final_price'] - ], - 'selections' => null, ]; } $actualVariants = $extract['feedData']['variants']; diff --git a/ConfigurableProductDataExporter/etc/et_schema.xml b/ConfigurableProductDataExporter/etc/et_schema.xml index fe8cb5950..9d0eeb4e8 100644 --- a/ConfigurableProductDataExporter/etc/et_schema.xml +++ b/ConfigurableProductDataExporter/etc/et_schema.xml @@ -17,11 +17,5 @@ - - - - - - diff --git a/DataExporter/Export/Transformer.php b/DataExporter/Export/Transformer.php index caee766be..155bba44d 100644 --- a/DataExporter/Export/Transformer.php +++ b/DataExporter/Export/Transformer.php @@ -137,7 +137,7 @@ private function castToFieldType(array $rootField, $value) if (isset($value[$i][$field['name']])) { $result[$i][$field['name']] = $this->castToFieldType($field, $value[$i][$field['name']]); - } else { + } elseif (!$type['skipNull']) { $result[$i][$field['name']] = null; } } diff --git a/DataExporter/Model/ExportFeedDummy.php b/DataExporter/Model/ExportFeedDummy.php new file mode 100644 index 000000000..5ed90c70e --- /dev/null +++ b/DataExporter/Model/ExportFeedDummy.php @@ -0,0 +1,29 @@ + 1" to app/etc/env.php + * + * Payload will be stored in to the corresponding feed table + */ + public const PERSIST_EXPORTED_FEED = 'PERSIST_EXPORTED_FEED'; + + /** + * Export data + * + * @param array $data + * @param FeedIndexMetadata $metadata + * @return FeedExportStatus + */ + public function export(array $data, FeedIndexMetadata $metadata): FeedExportStatus; +} diff --git a/DataExporter/Model/Feed.php b/DataExporter/Model/Feed.php index e8c323dcc..7c06ea0b9 100644 --- a/DataExporter/Model/Feed.php +++ b/DataExporter/Model/Feed.php @@ -53,7 +53,8 @@ class Feed implements FeedInterface * @param SerializerInterface $serializer * @param FeedIndexMetadata $feedIndexMetadata * @param FeedQuery $feedQuery - * @param null $dateTimeFormat + * @param CommerceDataExportLoggerInterface $logger + * @param ?string $dateTimeFormat */ public function __construct( ResourceConnection $resourceConnection, @@ -61,7 +62,7 @@ public function __construct( FeedIndexMetadata $feedIndexMetadata, FeedQuery $feedQuery, CommerceDataExportLoggerInterface $logger, - $dateTimeFormat = null + ?string $dateTimeFormat = null ) { $this->resourceConnection = $resourceConnection; $this->serializer = $serializer; @@ -74,21 +75,23 @@ public function __construct( /** * @inheritDoc */ - public function getFeedSince(string $timestamp): array + public function getFeedSince(string $timestamp, array $ignoredExportStatus = null): array { $connection = $this->resourceConnection->getConnection(); $limit = $connection->fetchOne( $this->feedQuery->getLimitSelect( $this->feedIndexMetadata, $timestamp, - $this->feedIndexMetadata->getFeedOffsetLimit() + $this->feedIndexMetadata->getFeedOffsetLimit(), + $ignoredExportStatus ) ); return $this->fetchData( $this->feedQuery->getDataSelect( $this->feedIndexMetadata, $timestamp, - !$limit ? null : $limit + !$limit ? null : $limit, + $ignoredExportStatus ) ); } @@ -137,6 +140,8 @@ private function fetchData($select): array } /** + * @inheirtDoc + * * @return FeedIndexMetadata */ public function getFeedMetadata(): FeedIndexMetadata diff --git a/DataExporter/Model/FeedExportStatus.php b/DataExporter/Model/FeedExportStatus.php new file mode 100644 index 000000000..c0df7d636 --- /dev/null +++ b/DataExporter/Model/FeedExportStatus.php @@ -0,0 +1,65 @@ +status = $status; + $this->reasonPhrase = $reasonPhrase; + $this->failedItems = $failedItems; + } + + /** + * Get reason phrase + * + * @return string + */ + public function getReasonPhrase(): string + { + return $this->reasonPhrase; + } + + /** + * Get failed items + * + * @return array + */ + public function getFailedItems(): array + { + return $this->failedItems; + } + + /** + * Get status + * + * @return ExportStatusCode + */ + public function getStatus(): ExportStatusCode + { + return $this->status; + } +} diff --git a/DataExporter/Model/FeedExportStatusBuilder.php b/DataExporter/Model/FeedExportStatusBuilder.php new file mode 100644 index 000000000..a4773e948 --- /dev/null +++ b/DataExporter/Model/FeedExportStatusBuilder.php @@ -0,0 +1,96 @@ +feedExportStatusFactory = $feedExportStatusFactory; + $this->exportStatusCodeFactory = $exportStatusCodeFactory; + $this->logger = $logger; + } + + /** + * Build data + * + * @param int $status + * @param string $reasonPhrase + * @param array $failedItems + * @return FeedExportStatus + */ + public function build( + int $status, + string $reasonPhrase = '', + array $failedItems = [] + ) : FeedExportStatus { + try { + return $this->feedExportStatusFactory->create( + [ + 'status' => $this->buildStatusCode($status), + 'reasonPhrase' => $reasonPhrase, + 'failedItems' => $failedItems + ] + ); + + } catch (\Throwable $e) { + $this->logger->error( + 'Data Exporter exception has occurred: ' . $e->getMessage(), + ['exception' => $e] + ); + throw new \RuntimeException('Unable to instantiate Feed Export Status'); + } + } + + /** + * Build status code + * + * @param int $statusCode + * @return ExportStatusCode + */ + private function buildStatusCode(int $statusCode) : ExportStatusCode + { + try { + return $this->exportStatusCodeFactory->create(['statusCode' => $statusCode]); + + } catch (\Throwable $e) { + $this->logger->error( + 'Data Exporter exception has occurred: ' . $e->getMessage(), + ['exception' => $e] + ); + throw new \RuntimeException('Unable to instantiate Export Status Code'); + } + } +} diff --git a/DataExporter/Model/FeedHashBuilder.php b/DataExporter/Model/FeedHashBuilder.php new file mode 100644 index 000000000..92ff2e858 --- /dev/null +++ b/DataExporter/Model/FeedHashBuilder.php @@ -0,0 +1,117 @@ +serializer = $serializer; + $this->resourceConnection = $resourceConnection; + } + + /** + * Hash row data + * + * @param array $row + * @param FeedIndexMetadata $metadata + * @return string + */ + public function buildHash(array $row, FeedIndexMetadata $metadata) : string + { + return sha1($this->serializer->serialize($this->sanitizeRow($row, $metadata))); + } + + /** + * Sanitize row + * + * @param array $row + * @param FeedIndexMetadata $metadata + * @return array + */ + private function sanitizeRow(array $row, FeedIndexMetadata $metadata): array + { + foreach (array_keys($row) as $key) { + if (\in_array($key, $metadata->getExcludeFromHashFields(), true)) { + unset($row[$key]); + } + } + return $row; + } + + /** + * Build identifier from feed item + * + * @param array $feedItem + * @param FeedIndexMetadata $metadata + * @return string + */ + public function buildIdentifierFromFeedItem(array $feedItem, FeedIndexMetadata $metadata): string + { + $identifier = []; + foreach ($metadata->getFeedIdentifierMappingFields() as $field) { + $this->addValue($identifier, $feedItem[$field] ? (string)$feedItem[$field] : ''); + } + return $this->convertToString($identifier); + } + + /** + * Build identifier from feed table row + * + * @param array $row + * @param FeedIndexMetadata $metadata + * @return string + */ + public function buildIdentifierFromFeedTableRow(array $row, FeedIndexMetadata $metadata): string + { + $identifier = []; + foreach (array_keys($metadata->getFeedIdentifierMappingFields()) as $columnName) { + $this->addValue($identifier, $row[$columnName] ? (string)$row[$columnName] : ''); + } + return $this->convertToString($identifier); + } + + /** + * Add value + * + * @param array $identifier + * @param string $value + * @return void + */ + private function addValue(array &$identifier, string $value): void + { + $identifier[] = $this->resourceConnection->getConnection()->quote($value); + } + + /** + * Convert to string + * + * @param array $identifier + * @return string + */ + private function convertToString(array $identifier): string + { + return implode(',', $identifier); + } +} diff --git a/DataExporter/Model/FeedInterface.php b/DataExporter/Model/FeedInterface.php index c93fd74a8..4afbfaed0 100644 --- a/DataExporter/Model/FeedInterface.php +++ b/DataExporter/Model/FeedInterface.php @@ -15,16 +15,21 @@ interface FeedInterface { /** - * Get feed from given timestamp + * Get feed from given timestamp. * - * @param string $timestamp + * If {$ignoredExportStatus} provided returns feed without specified export status * + * @param string $timestamp + * @param array|null $ignoredExportStatus * @return array * @throws \Zend_Db_Statement_Exception + * @see \Magento\DataExporter\Status\ExportStatusCode */ - public function getFeedSince(string $timestamp): array; + public function getFeedSince(string $timestamp, array $ignoredExportStatus = null): array; /** + * Get feed metadata + * * @return FeedIndexMetadata */ public function getFeedMetadata(): FeedIndexMetadata; diff --git a/DataExporter/Model/FeedMetadataPool.php b/DataExporter/Model/FeedMetadataPool.php new file mode 100644 index 000000000..ed013bc76 --- /dev/null +++ b/DataExporter/Model/FeedMetadataPool.php @@ -0,0 +1,47 @@ +classMap = $classMap; + } + + /** + * Returns feed object + * + * @param string $feedName + * @return FeedIndexMetadata + * @throws \InvalidArgumentException + */ + public function getMetadata(string $feedName) : FeedIndexMetadata + { + if (!isset($this->classMap[$feedName])) { + throw new \InvalidArgumentException( + \sprintf('Not registered FeedIndexMetadata for feed "%s"', $feedName) + ); + } + return $this->classMap[$feedName]; + } +} diff --git a/DataExporter/Model/Indexer/DataSerializer.php b/DataExporter/Model/Indexer/DataSerializer.php index 9a17848b9..1f294fd1a 100644 --- a/DataExporter/Model/Indexer/DataSerializer.php +++ b/DataExporter/Model/Indexer/DataSerializer.php @@ -7,15 +7,20 @@ namespace Magento\DataExporter\Model\Indexer; +use Magento\DataExporter\Model\FeedExportStatus; +use Magento\DataExporter\Status\ExportStatusCodeProvider; use Magento\Framework\Serialize\SerializerInterface; /** * Class responsible for feed data serialization - * "mapping" field determinate the unique field for feed table based on data from et_schema. Support both single and multi dimension values. Example format: + * @link self::$mapping determinate the unique fields of feed's table their relationship to feed items from et_schema. + * Support dot.notation * [ * "feed_table_column_name" => "field name in et_schema", // 'id' => 'product_id' - * "feed_table_column_name" => ["complex field type", "in et_schema"], // 'id' => ["product", "id"] + * "feed_table_column_name" => "parent_field_name.field_name"], // 'id' => ["product.id"] * ] + * + * @link self::$unserializeKeys allows to specify fields from et_schem that should be unserizlised before processing */ class DataSerializer implements DataSerializerInterface { @@ -29,49 +34,157 @@ class DataSerializer implements DataSerializerInterface */ private $mapping; + /** + * @var array + */ + private array $unserializeKeys; + /** * @param SerializerInterface $serializer * @param array $mapping + * @param array $unserializeKeys */ public function __construct( SerializerInterface $serializer, - array $mapping = [] + array $mapping = [], + array $unserializeKeys = [], ) { $this->serializer = $serializer; $this->mapping = $mapping; + $this->unserializeKeys = $unserializeKeys; } /** * Serialize data * * @param array $data + * @param ?FeedExportStatus $exportStatus + * @param FeedIndexMetadata $metadata * @return array */ - public function serialize(array $data): array + public function serialize(array $data, ?FeedExportStatus $exportStatus, FeedIndexMetadata $metadata): array { + if ($metadata->isExportImmediately()) { + if ($exportStatus === null) { + throw new \InvalidArgumentException('FeedExportStatus can\'t be null'); + } + return $this->serializeForImmediateExport($data, $exportStatus, $metadata); + } + $output = []; + + foreach ($data as $feedData) { + $outputRow = $this->buildRow($feedData); + $outputRow[FeedIndexMetadata::FEED_TABLE_FIELD_FEED_DATA] = $this->serializer->serialize($feedData); + + $output[] = $outputRow; + } + return $output; + } + + /** + * Serialize for immediate export + * + * @param array $data + * @param FeedExportStatus $exportStatus + * @param FeedIndexMetadata $metadata + * @return array + */ + private function serializeForImmediateExport( + array $data, + FeedExportStatus $exportStatus, + FeedIndexMetadata $metadata + ): array { $output = []; + $status = $exportStatus->getStatus(); + $exportFailedItems = $exportStatus->getFailedItems(); + $feedItemFields = array_values($metadata->getFeedIdentifierMappingFields()); + $feedItemFieldsToPersist = array_merge( + $metadata->getMinimalPayloadFieldsList(), + // required to build feed's table primary key if entity was deleted + array_combine($feedItemFields, $feedItemFields), + ); + + $itemN = -1; foreach ($data as $row) { - $outputRow = []; - $outputRow['feed_data'] = $this->serializer->serialize($row); - foreach ($this->mapping as $field => $index) { - if (\is_array($index)) { - $indexValue = null; - foreach ($index as $key) { - $indexValue = $indexValue - ? $indexValue[$key] ?? null - : $row[$key] ?? null; - } - $outputRow[$field] = $indexValue; - } elseif (isset($row[$index])) { - $outputRow[$field] = is_array($row[$index]) ? - $this->serializer->serialize($row[$index]) : - $row[$index]; - } else { - $outputRow[$field] = null; + $itemN++; + $feedData = $row['feed']; + + foreach ($this->unserializeKeys as $unserializeKey) { + $feedData[$unserializeKey] = $this->serializer->unserialize($feedData[$unserializeKey]); + } + $outputRow = $this->buildRow($feedData); + + // get the first available value [feed.deleted, row.delete, 0] + $outputRow[FeedIndexMetadata::FEED_TABLE_FIELD_IS_DELETED] = $feedData['deleted'] ?? $row['deleted'] ?? 0; + $outputRow[FeedIndexMetadata::FEED_TABLE_FIELD_STATUS] = $status->getValue(); + $outputRow[FeedIndexMetadata::FEED_TABLE_FIELD_MODIFIED_AT] = $feedData['modifiedAt']; + + if (!empty($exportFailedItems)) { + $failedFeedItem = $exportFailedItems[$itemN]['message'] ?? null; + $outputRow[FeedIndexMetadata::FEED_TABLE_FIELD_ERRORS] = $failedFeedItem ?? ''; + // if _specific_ item failed mark only that item as failed, otherwise set status successful + if ($failedFeedItem !== null) { + $outputRow[FeedIndexMetadata::FEED_TABLE_FIELD_STATUS] + = ExportStatusCodeProvider::FAILED_ITEM_ERROR; } + } elseif (!$status->isSuccess()) { + $outputRow[FeedIndexMetadata::FEED_TABLE_FIELD_ERRORS] = $exportStatus->getReasonPhrase(); + } else { + $outputRow[FeedIndexMetadata::FEED_TABLE_FIELD_ERRORS] = ''; + } + + if (!$metadata->isPersistExportedFeed()) { + // Save bare minimum of data: feed identity + items required to reconstruct primary keys of feed table + // Used to cover "delete entity" use case to construct feed item with "deleted:true" field + $feedData = \array_intersect_key($feedData, $feedItemFieldsToPersist); } + $outputRow[FeedIndexMetadata::FEED_TABLE_FIELD_FEED_DATA] = $this->serializer->serialize($feedData); + $outputRow[FeedIndexMetadata::FEED_TABLE_FIELD_FEED_HASH] = $row['hash']; + $output[] = $outputRow; } return $output; } + + /** + * Build row + * + * @param mixed $row + * @return array + */ + private function buildRow(mixed &$row): array + { + foreach ($this->unserializeKeys as $unserializeKey) { + $row[$unserializeKey] = $this->serializer->unserialize($row[$unserializeKey]); + } + + $outputRow = []; + foreach ($this->mapping as $field => $index) { + $value = $this->getNestedValue($row, $index); + if (isset($value)) { + $outputRow[$field] = is_array($value) ? + $this->serializer->serialize($value) : + $value; + } else { + $outputRow[$field] = null; + } + } + return $outputRow; + } + + /** + * Get nested array value. + * + * @param array $array + * @param string $path + * @return mixed + */ + private function getNestedValue(array $array, string $path): mixed + { + $arrayPath = explode('.', $path); + $reduce = function (array $source, $key) { + return (array_key_exists($key, $source)) ? $source[$key] : null; + }; + return array_reduce($arrayPath, $reduce, $array); + } } diff --git a/DataExporter/Model/Indexer/DataSerializerInterface.php b/DataExporter/Model/Indexer/DataSerializerInterface.php index 562014545..cd7999587 100644 --- a/DataExporter/Model/Indexer/DataSerializerInterface.php +++ b/DataExporter/Model/Indexer/DataSerializerInterface.php @@ -7,6 +7,8 @@ namespace Magento\DataExporter\Model\Indexer; +use Magento\DataExporter\Model\FeedExportStatus; + /** * Feed data serializer interface */ @@ -16,7 +18,9 @@ interface DataSerializerInterface * Serialize data * * @param array $data + * @param ?FeedExportStatus $exportStatus + * @param FeedIndexMetadata $metadata * @return array */ - public function serialize(array $data): array; + public function serialize(array $data, ?FeedExportStatus $exportStatus, FeedIndexMetadata $metadata): array; } diff --git a/DataExporter/Model/Indexer/FeedIndexMetadata.php b/DataExporter/Model/Indexer/FeedIndexMetadata.php index 84f91757f..d42a6a98c 100644 --- a/DataExporter/Model/Indexer/FeedIndexMetadata.php +++ b/DataExporter/Model/Indexer/FeedIndexMetadata.php @@ -9,48 +9,76 @@ /** * Feed indexer metadata provider + * + * @SuppressWarnings(PHPMD.TooManyFields) */ class FeedIndexMetadata { + public const FEED_TABLE_FIELD_IS_DELETED = 'is_deleted'; + public const FEED_TABLE_FIELD_MODIFIED_AT = 'modified_at'; + public const FEED_TABLE_FIELD_FEED_HASH = 'feed_hash'; + public const FEED_TABLE_FIELD_FEED_DATA = 'feed_data'; + public const FEED_TABLE_FIELD_STATUS = 'status'; + public const FEED_TABLE_FIELD_ERRORS = 'errors'; + /** + * Default columns that must be updated each time when feed persisted to storage + */ + private const FEED_TABLE_MUTABLE_COLUMNS_DEFAULT = [ + self::FEED_TABLE_FIELD_FEED_DATA => self::FEED_TABLE_FIELD_FEED_DATA, + self::FEED_TABLE_FIELD_IS_DELETED => self::FEED_TABLE_FIELD_IS_DELETED, + self::FEED_TABLE_FIELD_FEED_HASH => self::FEED_TABLE_FIELD_FEED_HASH, + self::FEED_TABLE_FIELD_STATUS => self::FEED_TABLE_FIELD_STATUS, + self::FEED_TABLE_FIELD_ERRORS => self::FEED_TABLE_FIELD_ERRORS, + self::FEED_TABLE_FIELD_MODIFIED_AT => self::FEED_TABLE_FIELD_MODIFIED_AT + ]; + + /** + * Default feed fields that have to be excluded from hash calculation + */ + private const EXCLUDE_FROM_HASH_FIELDS_DEFAULT = [ + 'modifiedAt', + 'updatedAt' + ]; + /** * @var string */ - protected $feedName; + private $feedName; /** * @var string */ - protected $sourceTableName; + private $sourceTableName; /** * @var string */ - protected $sourceTableField; + private $sourceTableField; /** * @var string */ - protected $feedIdentity; + private $feedIdentity; /** * @var string */ - protected $feedTableName; + private $feedTableName; /** * @var string */ - protected $feedTableField; + private $feedTableField; /** * @var string[] */ - protected $feedTableMutableColumns; + private $feedTableMutableColumns; /** * @var int */ - protected $batchSize; + private $batchSize; /** * Field used in WHERE & ORDER statement during select IDs from source table @@ -79,6 +107,25 @@ class FeedIndexMetadata */ private bool $truncateFeedOnFullReindex; + /** + * @var bool + */ + private bool $exportImmediately; + + /** + * @var bool + */ + private bool $persistExportedFeed; + + /** + * @var array + */ + private array $minimalPayload; + + private array $excludeFromHashFields; + + private array $feedIdentifierMapping; + /** * @param string $feedName * @param string $sourceTableName @@ -88,9 +135,18 @@ class FeedIndexMetadata * @param string $feedTableField * @param array $feedTableMutableColumns * @param int $batchSize + * @param int $feedOffsetLimit * @param string|null $sourceTableIdentityField * @param int $fullReIndexSecondsLimit * @param string $sourceTableFieldOnFullReIndexLimit + * @param bool $truncateFeedOnFullReindex + * @param array $feedIdentifierMapping + * @param array $minimalPayload + * @param array $excludeFromHashFields + * @param bool $exportImmediately + * @param bool $persistExportedFeed + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( string $feedName, @@ -99,13 +155,18 @@ public function __construct( string $feedIdentity, string $feedTableName, string $feedTableField, - array $feedTableMutableColumns, + array $feedTableMutableColumns = [], int $batchSize = 100, int $feedOffsetLimit = 100, string $sourceTableIdentityField = null, int $fullReIndexSecondsLimit = 0, string $sourceTableFieldOnFullReIndexLimit = 'updated_at', - bool $truncateFeedOnFullReindex = true + bool $truncateFeedOnFullReindex = true, + array $feedIdentifierMapping = [], + array $minimalPayload = [], + array $excludeFromHashFields = [], + bool $exportImmediately = false, + bool $persistExportedFeed = false ) { $this->feedName = $feedName; $this->sourceTableName = $sourceTableName; @@ -113,13 +174,25 @@ public function __construct( $this->feedIdentity = $feedIdentity; $this->feedTableName = $feedTableName; $this->feedTableField = $feedTableField; - $this->feedTableMutableColumns = $feedTableMutableColumns; + $this->feedTableMutableColumns = array_unique(array_merge( + $feedTableMutableColumns, + self::FEED_TABLE_MUTABLE_COLUMNS_DEFAULT + )); $this->batchSize = $batchSize; $this->sourceTableIdentityField = $sourceTableIdentityField ?? $sourceTableField; $this->fullReindexSecondsLimit = $fullReIndexSecondsLimit; $this->sourceTableFieldOnFullReIndexLimit = $sourceTableFieldOnFullReIndexLimit; $this->feedOffsetLimit = $feedOffsetLimit; $this->truncateFeedOnFullReindex = $truncateFeedOnFullReindex; + $this->exportImmediately = $exportImmediately; + $this->persistExportedFeed = $persistExportedFeed; + $this->minimalPayload = $minimalPayload; + $this->excludeFromHashFields = array_unique(array_merge( + $excludeFromHashFields, + self::EXCLUDE_FROM_HASH_FIELDS_DEFAULT + )); + + $this->feedIdentifierMapping = $feedIdentifierMapping; } /** @@ -214,6 +287,7 @@ public function getFeedTableMutableColumns(): array /** * Determines the amount of seconds back in time when triggering a full reindex + * * @return int the amount in seconds, 0 means no limit */ public function getFullReIndexSecondsLimit(): int @@ -223,6 +297,7 @@ public function getFullReIndexSecondsLimit(): int /** * Table field name to use when full reindex is limited (see fullReindexSecondsLimit) + * * @return string the field name */ public function getSourceTableFieldOnFullReIndexLimit(): string @@ -249,4 +324,54 @@ public function isTruncateFeedOnFullReindex(): bool { return $this->truncateFeedOnFullReindex; } + + /** + * Check is export immediately + * + * @return bool + */ + public function isExportImmediately(): bool + { + return $this->exportImmediately; + } + + /** + * Check is persist exported feed + * + * @return bool + */ + public function isPersistExportedFeed(): bool + { + return $this->persistExportedFeed; + } + + /** + * Bare minimum list of fields. Used to send request with deleted entity + * + * @return array + */ + public function getMinimalPayloadFieldsList(): array + { + return $this->minimalPayload; + } + + /** + * Get feed identifier mapping fields + * + * @return array + */ + public function getFeedIdentifierMappingFields(): array + { + return $this->feedIdentifierMapping; + } + + /** + * Get exclude from hash fields + * + * @return array + */ + public function getExcludeFromHashFields(): array + { + return $this->excludeFromHashFields; + } } diff --git a/DataExporter/Model/Indexer/FeedIndexProcessorCreateUpdate.php b/DataExporter/Model/Indexer/FeedIndexProcessorCreateUpdate.php index 614182f85..28605c29d 100644 --- a/DataExporter/Model/Indexer/FeedIndexProcessorCreateUpdate.php +++ b/DataExporter/Model/Indexer/FeedIndexProcessorCreateUpdate.php @@ -8,62 +8,110 @@ namespace Magento\DataExporter\Model\Indexer; use Magento\DataExporter\Model\Logging\CommerceDataExportLoggerInterface; +use Magento\DataExporter\Status\ExportStatusCodeProvider; use Magento\Framework\App\ResourceConnection; use \Magento\DataExporter\Export\Processor as ExportProcessor; +use Magento\Framework\Serialize\SerializerInterface; +use Magento\DataExporter\Model\ExportFeedInterface; +use Magento\DataExporter\Model\FeedHashBuilder; /** * Base implementation of feed indexing behaviour, does not care about deleted entities + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class FeedIndexProcessorCreateUpdate implements FeedIndexProcessorInterface { + private const MODIFIED_AT_FORMAT = 'Y-m-d H:i:s'; + + /** + * @var ResourceConnection + */ private ResourceConnection $resourceConnection; private ExportProcessor $exportProcessor; private CommerceDataExportLoggerInterface $logger; + /** + * @var ExportFeedInterface + */ + private $exportFeedProcessor; + private FeedUpdater $feedUpdater; + private FeedHashBuilder $hashBuilder; + private SerializerInterface $serializer; + + private $feedTablePrimaryKey; + + private string $modifiedAtTimeInDBFormat; + /** * @param ResourceConnection $resourceConnection * @param ExportProcessor $exportProcessor + * @param ExportFeedInterface $exportFeedProcessor + * @param FeedUpdater $feedUpdater + * @param FeedHashBuilder $hashBuilder + * @param SerializerInterface $serializer * @param CommerceDataExportLoggerInterface $logger */ public function __construct( ResourceConnection $resourceConnection, ExportProcessor $exportProcessor, + ExportFeedInterface $exportFeedProcessor, + FeedUpdater $feedUpdater, + FeedHashBuilder $hashBuilder, + SerializerInterface $serializer, CommerceDataExportLoggerInterface $logger ) { $this->resourceConnection = $resourceConnection; $this->exportProcessor = $exportProcessor; + $this->exportFeedProcessor = $exportFeedProcessor; + $this->feedUpdater = $feedUpdater; + $this->hashBuilder = $hashBuilder; + $this->serializer = $serializer; $this->logger = $logger; } /** - * {@inerhitDoc} + * @inerhitDoc * * @param FeedIndexMetadata $metadata * @param DataSerializerInterface $serializer * @param EntityIdsProviderInterface $idsProvider * @param array $ids + * @param callable|null $callback */ public function partialReindex( FeedIndexMetadata $metadata, DataSerializerInterface $serializer, EntityIdsProviderInterface $idsProvider, - array $ids = [] + array $ids = [], + callable $callback = null ): void { $feedIdentity = $metadata->getFeedIdentity(); $arguments = []; foreach ($idsProvider->getAffectedIds($metadata, $ids) as $id) { $arguments[] = [$feedIdentity => $id]; } + $this->modifiedAtTimeInDBFormat = (new \DateTime())->format(self::MODIFIED_AT_FORMAT); $data = $this->exportProcessor->process($metadata->getFeedName(), $arguments); - $chunks = array_chunk($data, $metadata->getBatchSize()); - $connection = $this->resourceConnection->getConnection(); - foreach ($chunks as $chunk) { - $connection->insertOnDuplicate( - $this->resourceConnection->getTableName($metadata->getFeedTableName()), - $serializer->serialize($chunk), - $metadata->getFeedTableMutableColumns() + + $exportStatus = null; + if ($metadata->isExportImmediately()) { + $feedItemsToDelete = $callback !== null ? $callback() : []; + $data = $this->prepareFeedBeforeSubmit($data, $feedItemsToDelete, $metadata); + if (empty($data)) { + return; + } + $exportStatus = $this->exportFeedProcessor->export( + array_column($data, 'feed'), + $metadata ); } + + $this->feedUpdater->execute($data, $exportStatus, $metadata, $serializer); + + if ($callback !== null && !$metadata->isExportImmediately()) { + $callback(); + } } /** @@ -100,11 +148,141 @@ public function fullReindex( */ private function truncateIndexTable(FeedIndexMetadata $metadata): void { - if (!$metadata->isTruncateFeedOnFullReindex()) { + if (!$metadata->isTruncateFeedOnFullReindex() || $metadata->isExportImmediately()) { return ; } $connection = $this->resourceConnection->getConnection(); $feedTable = $this->resourceConnection->getTableName($metadata->getFeedTableName()); $connection->truncateTable($feedTable); } + + /** + * Prepare feed data before submit: + * - calculate feed identifier and feed hash + * - remove unchanged feed items (items with the same hash) + * + * @param array $feedItems + * @param array|null $feedItemsToDelete + * @param FeedIndexMetadata $metadata + * @return array + * @throws \Zend_Db_Statement_Exception + */ + private function prepareFeedBeforeSubmit( + array $feedItems, + ?array $feedItemsToDelete, + FeedIndexMetadata $metadata + ) : array { + $feedItemsToDelete = !empty($feedItemsToDelete) ? $this->addHashes($feedItemsToDelete, $metadata, true) : []; + $feedItems = $this->addHashes($feedItems, $metadata); + $feedItems = array_merge($feedItems, $feedItemsToDelete); + if (empty($feedItems)) { + return []; + } + return $this->filterFeedItems($feedItems, $metadata); + } + + /** + * Remove feed items from further processing if all true: + * - item hash didn't change + * - previous export status is non-retryable + * + * @param array $feedItems + * @param FeedIndexMetadata $metadata + * @return array + * @throws \Zend_Db_Statement_Exception + */ + private function filterFeedItems(array $feedItems, FeedIndexMetadata $metadata) : array + { + $connection = $this->resourceConnection->getConnection(); + $primaryKeyFields = $this->getFeedTablePrimaryKey($metadata); + $primaryKeys = \array_keys($feedItems); + $primaryKeys = count($primaryKeyFields) == 1 + ? \implode(',', $primaryKeys) + : '(' . \implode('),(', $primaryKeys) . ')'; + + $select = $connection->select() + ->from( + ['f' => $this->resourceConnection->getTableName($metadata->getFeedTableName())], + array_merge($primaryKeyFields, ['feed_hash', 'status']) + )->where(sprintf('(%s) IN (%s)', \implode(', ', $primaryKeyFields), $primaryKeys)); + + $cursor = $connection->query($select); + + while ($row = $cursor->fetch()) { + $identifier = $this->hashBuilder->buildIdentifierFromFeedTableRow($row, $metadata); + $feedHash = $row['feed_hash']; + + if (\in_array((int)$row['status'], ExportStatusCodeProvider::NON_RETRYABLE_HTTP_STATUS_CODE, true) + && isset($feedItems[$identifier]['hash']) + && $feedHash == $feedItems[$identifier]['hash']) { + unset($feedItems[$identifier]); + } + } + return $feedItems; + } + + /** + * Add hashes + * + * @param array $data + * @param FeedIndexMetadata $metadata + * @param bool $deleted + * @return array + */ + private function addHashes(array $data, FeedIndexMetadata $metadata, bool $deleted = false): array + { + foreach ($data as $key => $row) { + if ($deleted) { + if (!isset($row[FeedIndexMetadata::FEED_TABLE_FIELD_FEED_HASH])) { + $this->logger->error("Feed hash is not set for the product id: ". $row['productId']); + continue ; + } + $identifier = $this->hashBuilder->buildIdentifierFromFeedTableRow($row, $metadata); + $row = $this->serializer->unserialize($row['feed_data']); + $row['deleted'] = true; + } else { + $identifier = $this->hashBuilder->buildIdentifierFromFeedItem($row, $metadata); + } + unset($data[$key]); + + $hash = $this->hashBuilder->buildHash($row, $metadata); + $this->addModifiedAtField($row); + $data[$identifier] = [ + 'hash' => $hash, + 'feed' => $row, + 'deleted' => $deleted + ]; + } + return $data; + } + + /** + * Get feed table primary key + * + * @param FeedIndexMetadata $metadata + * @return array + */ + private function getFeedTablePrimaryKey(FeedIndexMetadata $metadata): array + { + if (!isset($this->feedTablePrimaryKey[$metadata->getFeedName()])) { + $connection = $this->resourceConnection->getConnection(); + $table = $this->resourceConnection->getTableName($metadata->getFeedTableName()); + $indexList = $connection->getIndexList($table); + $this->feedTablePrimaryKey[$metadata->getFeedName()] = $indexList[ + $connection->getPrimaryKeyName($table) + ]['COLUMNS_LIST']; + } + return $this->feedTablePrimaryKey[$metadata->getFeedName()]; + } + + /** + * Add modified at field to each row + * + * @param array $dataRow + * @return void + */ + private function addModifiedAtField(&$dataRow): void + { + $dataRow['modifiedAt'] = $this->modifiedAtTimeInDBFormat; + } } diff --git a/DataExporter/Model/Indexer/FeedIndexProcessorCreateUpdateDelete.php b/DataExporter/Model/Indexer/FeedIndexProcessorCreateUpdateDelete.php index f0fa9d20a..be823f208 100644 --- a/DataExporter/Model/Indexer/FeedIndexProcessorCreateUpdateDelete.php +++ b/DataExporter/Model/Indexer/FeedIndexProcessorCreateUpdateDelete.php @@ -8,8 +8,11 @@ namespace Magento\DataExporter\Model\Indexer; use Magento\DataExporter\Export\Processor as ExportProcessor; +use Magento\DataExporter\Model\FeedHashBuilder; use Magento\DataExporter\Model\Logging\CommerceDataExportLoggerInterface; +use Magento\DataExporter\Model\ExportFeedInterface; use Magento\Framework\App\ResourceConnection; +use Magento\Framework\Serialize\SerializerInterface; /** * Feed indexer processor strategy, support creation, updates and deletion of an entity @@ -23,38 +26,63 @@ class FeedIndexProcessorCreateUpdateDelete extends FeedIndexProcessorCreateUpdat * @param ResourceConnection $resourceConnection * @param ExportProcessor $exportProcessor * @param MarkRemovedEntitiesInterface $markRemovedEntities + * @param FeedUpdater $feedUpdater + * @param ExportFeedInterface $exportFeedProcessor + * @param FeedHashBuilder $hashBuilder + * @param SerializerInterface $serializer * @param CommerceDataExportLoggerInterface $logger */ public function __construct( ResourceConnection $resourceConnection, ExportProcessor $exportProcessor, MarkRemovedEntitiesInterface $markRemovedEntities, + FeedUpdater $feedUpdater, + ExportFeedInterface $exportFeedProcessor, + FeedHashBuilder $hashBuilder, + SerializerInterface $serializer, CommerceDataExportLoggerInterface $logger ) { - parent::__construct($resourceConnection, $exportProcessor, $logger); + parent::__construct( + $resourceConnection, + $exportProcessor, + $exportFeedProcessor, + $feedUpdater, + $hashBuilder, + $serializer, + $logger + ); $this->markRemovedEntities = $markRemovedEntities; $this->logger = $logger; } /** - * @inheridoc + * @inerhitDoc * * @param FeedIndexMetadata $metadata * @param DataSerializerInterface $serializer * @param EntityIdsProviderInterface $idsProvider * @param array $ids + * @param callable|null $callback + * @return void */ - public function partialReindex(FeedIndexMetadata $metadata, DataSerializerInterface $serializer, EntityIdsProviderInterface $idsProvider, array $ids = []): void - { - parent::partialReindex($metadata, $serializer, $idsProvider, $ids); + public function partialReindex( + FeedIndexMetadata $metadata, + DataSerializerInterface $serializer, + EntityIdsProviderInterface $idsProvider, + array $ids = [], + callable $callback = null + ): void { + $callback = function () use ($ids, $metadata) { + try { + return $this->markRemovedEntities->execute($ids, $metadata); + } catch (\Throwable $e) { + $this->logger->error( + sprintf("Cannot delete feed items. product ids: %s", implode(', ', $ids)), + ['exception' => $e] + ); + } + }; - try { - $this->markRemovedEntities->execute($ids, $metadata); - } catch (\Throwable $e) { - $this->logger->error( - sprintf("Cannot delete feed items. product ids: %s", implode(', ', $ids)), - ['exception' => $e] - ); - } + parent::partialReindex($metadata, $serializer, $idsProvider, $ids, $callback); } } diff --git a/DataExporter/Model/Indexer/FeedIndexProcessorInterface.php b/DataExporter/Model/Indexer/FeedIndexProcessorInterface.php index efe196a73..a1564a24e 100644 --- a/DataExporter/Model/Indexer/FeedIndexProcessorInterface.php +++ b/DataExporter/Model/Indexer/FeedIndexProcessorInterface.php @@ -20,12 +20,15 @@ interface FeedIndexProcessorInterface * @param DataSerializerInterface $serializer * @param EntityIdsProviderInterface $idsProvider * @param array $ids + * @param callable|null $callback + * @return void */ public function partialReindex( FeedIndexMetadata $metadata, DataSerializerInterface $serializer, EntityIdsProviderInterface $idsProvider, - array $ids = [] + array $ids = [], + callable $callback = null ): void; /** diff --git a/DataExporter/Model/Indexer/FeedIndexer.php b/DataExporter/Model/Indexer/FeedIndexer.php index 6c4870f55..1da319e19 100644 --- a/DataExporter/Model/Indexer/FeedIndexer.php +++ b/DataExporter/Model/Indexer/FeedIndexer.php @@ -7,6 +7,7 @@ namespace Magento\DataExporter\Model\Indexer; +use Magento\DataExporter\Model\ExportFeedInterface; use Magento\Framework\Indexer\ActionInterface as IndexerActionInterface; use Magento\Framework\Mview\ActionInterface as MviewActionInterface; diff --git a/DataExporter/Model/Indexer/FeedUpdater.php b/DataExporter/Model/Indexer/FeedUpdater.php new file mode 100644 index 000000000..f37babca9 --- /dev/null +++ b/DataExporter/Model/Indexer/FeedUpdater.php @@ -0,0 +1,100 @@ +resourceConnection = $resourceConnection; + $this->logger = $logger; + } + + /** + * Execute data + * + * @param array $feedData + * @param ?FeedExportStatus $exportStatus + * @param FeedIndexMetadata $metadata + * @param DataSerializerInterface $serializer + */ + public function execute( + array $feedData, + ?FeedExportStatus $exportStatus, + FeedIndexMetadata $metadata, + DataSerializerInterface $serializer + ): void { + try { + $connection = $this->resourceConnection->getConnection(); + + $dataForInsert = $serializer->serialize($feedData, $exportStatus, $metadata); + $chunks = array_chunk($dataForInsert, $metadata->getBatchSize()); + foreach ($chunks as $chunk) { + $fieldsToUpdateOnDuplicate = array_intersect_key( + $metadata->getFeedTableMutableColumns(), + $this->getFeedTableColumns($metadata) + ); + $connection->insertOnDuplicate( + $this->resourceConnection->getTableName($metadata->getFeedTableName()), + $chunk, + $fieldsToUpdateOnDuplicate + ); + } + } catch (\Throwable $e) { + $this->logger->error( + 'Cannot log export status to feed table', + [ + 'export_status' => $exportStatus->getStatus(), + 'export_failed_items' => $exportStatus->getFailedItems(), + 'export_phrase' => $exportStatus->getReasonPhrase(), + 'error' => $e->getMessage() + ] + ); + } + } + + /** + * Get feed table columns + * + * @param FeedIndexMetadata $metadata + * @return array + */ + private function getFeedTableColumns(FeedIndexMetadata $metadata): array + { + if (!isset($this->feedTableColumns[$metadata->getFeedName()])) { + $columns = array_keys( + $this->resourceConnection->getConnection()->describeTable( + $this->resourceConnection->getTableName($metadata->getFeedTableName()) + ) + ); + $this->feedTableColumns[$metadata->getFeedName()] = array_combine($columns, $columns); + } + return $this->feedTableColumns[$metadata->getFeedName()]; + } +} diff --git a/DataExporter/Model/Indexer/MarkRemovedEntities.php b/DataExporter/Model/Indexer/MarkRemovedEntities.php index 15b78b957..b541ad386 100644 --- a/DataExporter/Model/Indexer/MarkRemovedEntities.php +++ b/DataExporter/Model/Indexer/MarkRemovedEntities.php @@ -33,9 +33,12 @@ public function __construct( /** * @inheritdoc */ - public function execute(array $ids, FeedIndexMetadata $metadata): void + public function execute(array $ids, FeedIndexMetadata $metadata): ?array { $select = $this->markRemovedEntitiesQuery->getQuery($ids, $metadata); + if ($metadata->isExportImmediately()) { + return $this->resourceConnection->getConnection()->fetchAll($select); + } // convert select-object to sql-string with staging future $sqlSelect = $select->assemble(); @@ -45,5 +48,7 @@ public function execute(array $ids, FeedIndexMetadata $metadata): void $sqlUpdate = str_replace("WHERE", 'SET `f`.`is_deleted` = 1 WHERE', $sqlUpdate); $this->resourceConnection->getConnection()->query($sqlUpdate); + + return null; } } diff --git a/DataExporter/Model/Indexer/MarkRemovedEntitiesInterface.php b/DataExporter/Model/Indexer/MarkRemovedEntitiesInterface.php index 065046f3a..cbfbefefe 100644 --- a/DataExporter/Model/Indexer/MarkRemovedEntitiesInterface.php +++ b/DataExporter/Model/Indexer/MarkRemovedEntitiesInterface.php @@ -18,7 +18,7 @@ interface MarkRemovedEntitiesInterface * @param int[] $ids * @param FeedIndexMetadata $metadata * - * @return void + * @return ?array */ - public function execute(array $ids, FeedIndexMetadata $metadata): void; + public function execute(array $ids, FeedIndexMetadata $metadata): ?array; } diff --git a/DataExporter/Model/Query/FeedQuery.php b/DataExporter/Model/Query/FeedQuery.php index 2d544fb90..1de1c4574 100644 --- a/DataExporter/Model/Query/FeedQuery.php +++ b/DataExporter/Model/Query/FeedQuery.php @@ -36,20 +36,30 @@ public function __construct( * @param FeedIndexMetadata $metadata * @param string $modifiedAt * @param int $offset + * @param array|null $ignoredExportStatus * @return Select */ - public function getLimitSelect(FeedIndexMetadata $metadata, string $modifiedAt, int $offset): Select - { + public function getLimitSelect( + FeedIndexMetadata $metadata, + string $modifiedAt, + int $offset, + array $ignoredExportStatus = null + ): Select { $modifiedAt = $modifiedAt === '1' ? (int)$modifiedAt : $modifiedAt; $connection = $this->resourceConnection->getConnection(); - return $connection->select() + $feedTableName = $this->resourceConnection->getTableName($metadata->getFeedTableName()); + $select = $connection->select() ->from( - ['t' => $this->resourceConnection->getTableName($metadata->getFeedTableName())], + ['t' => $feedTableName], ['modified_at'] ) ->where('t.modified_at > ?', $modifiedAt) ->order('modified_at') ->limit(1, $offset); + + $this->addFilterByStatus($select, $feedTableName, $ignoredExportStatus); + + return $select; } /** @@ -58,10 +68,15 @@ public function getLimitSelect(FeedIndexMetadata $metadata, string $modifiedAt, * @param FeedIndexMetadata $metadata * @param string $modifiedAt * @param string|null $limit + * @param array|null $ignoredExportStatus * @return Select */ - public function getDataSelect(FeedIndexMetadata $metadata, string $modifiedAt, ?string $limit): Select - { + public function getDataSelect( + FeedIndexMetadata $metadata, + string $modifiedAt, + ?string $limit, + array $ignoredExportStatus = null + ): Select { $modifiedAt = $modifiedAt === '1' ? (int)$modifiedAt : $modifiedAt; $connection = $this->resourceConnection->getConnection(); $columns = [ @@ -81,6 +96,30 @@ public function getDataSelect(FeedIndexMetadata $metadata, string $modifiedAt, ? if ($limit) { $select->where('t.modified_at <= ?', $limit); } + $this->addFilterByStatus($select, $feedTableName, $ignoredExportStatus); return $select; } + + /** + * Add filter by status + * + * @param Select $select + * @param string $feedTableName + * @param ?array $ignoredExportStatus + * @return void + */ + private function addFilterByStatus(Select $select, string $feedTableName, array $ignoredExportStatus = null): void + { + if ($ignoredExportStatus === null) { + return ; + } + $connection = $this->resourceConnection->getConnection(); + if (!$connection->tableColumnExists($feedTableName, FeedIndexMetadata::FEED_TABLE_FIELD_STATUS)) { + throw new \RuntimeException(sprintf( + 'Feed table "%s" doesn\'t have "status" column.', + $feedTableName + )); + } + $select->where('t.status NOT IN (?)', $ignoredExportStatus); + } } diff --git a/DataExporter/Status/ExportStatusCode.php b/DataExporter/Status/ExportStatusCode.php new file mode 100644 index 000000000..c820b15a7 --- /dev/null +++ b/DataExporter/Status/ExportStatusCode.php @@ -0,0 +1,51 @@ +statusCode = $statusCode; + } + + /** + * Check is success + * + * @return bool + */ + public function isSuccess(): bool + { + return self::SUCCESS === $this->statusCode; + } + + /** + * Get value + * + * @return int + */ + public function getValue(): int + { + return $this->statusCode; + } +} diff --git a/DataExporter/Status/ExportStatusCodeProvider.php b/DataExporter/Status/ExportStatusCodeProvider.php new file mode 100644 index 000000000..6497fd930 --- /dev/null +++ b/DataExporter/Status/ExportStatusCodeProvider.php @@ -0,0 +1,28 @@ + - - + + + diff --git a/DataExporter/etc/di.xml b/DataExporter/etc/di.xml index a6c4e720c..59b86b0af 100644 --- a/DataExporter/etc/di.xml +++ b/DataExporter/etc/di.xml @@ -63,4 +63,6 @@ Magento\DataExporter\Model\Logging\CommerceDataExportLoggerInterface::EXPORTER_PROFILER + + diff --git a/ParentProductDataExporter/Model/Provider/Parents.php b/ParentProductDataExporter/Model/Provider/Parents.php index 09d94ed88..e3897e837 100644 --- a/ParentProductDataExporter/Model/Provider/Parents.php +++ b/ParentProductDataExporter/Model/Provider/Parents.php @@ -33,8 +33,6 @@ class Parents private $logger; /** - * Prices constructor. - * * @param ResourceConnection $resourceConnection * @param ProductParentQuery $productParentQuery * @param LoggerInterface $logger diff --git a/ProductPriceDataExporter/Model/Provider/DeleteFeedItems.php b/ProductPriceDataExporter/Model/Provider/DeleteFeedItems.php index 12a93f6f0..2c84d81ea 100644 --- a/ProductPriceDataExporter/Model/Provider/DeleteFeedItems.php +++ b/ProductPriceDataExporter/Model/Provider/DeleteFeedItems.php @@ -1,15 +1,16 @@ resourceConnection = $resourceConnection; + $this->serializer = $serializer; + $this->logger = $logger; + $this->dateTime = $dateTime; } /** + * Execute data + * * @param array $newFeedItems - * @return void + * @return array + * @throws \Zend_Db_Statement_Exception */ - public function execute(array $newFeedItems): void + public function execute(array $newFeedItems): array { $connection = $this->resourceConnection->getConnection(); @@ -49,14 +65,30 @@ public function execute(array $newFeedItems): void $websiteIds[] = $feedItem['websiteId']; } - $connection->update( - $this->resourceConnection->getTableName('catalog_data_exporter_product_prices'), - ['is_deleted' => new \Zend_Db_Expr('1')], - [ - 'CONCAT_WS("-", product_id, website_id, customer_group_code) not IN(?)' => $ids, - 'product_id IN (?)' => \array_unique($productIds), - 'website_id IN (?)' => \array_unique($websiteIds), - ] - ); + $select = $connection + ->select() + ->from(['f' => $this->resourceConnection->getTableName('catalog_data_exporter_product_prices')]) + ->where('CONCAT_WS("-", product_id, website_id, customer_group_code) not IN(?)', $ids) + ->where('product_id IN (?)', \array_unique($productIds)) + ->where('website_id IN (?)', \array_unique($websiteIds)); + $cursor = $this->resourceConnection->getConnection()->query($select); + $output = []; + while ($row = $cursor->fetch()) { + try { + $feed = $this->serializer->unserialize($row['feed_data']); + } catch (\InvalidArgumentException $e) { + $this->logger->warning( + 'Prices Feed error: can not parse feed data for deleted items: ' . $e->getMessage() + ); + continue; + } + // mark feed as deleted + $feed['deleted'] = true; + // set updated at + $feed['updatedAt'] = $this->dateTime->formatDate(time()); + $output[] = $feed; + } + + return $output; } } diff --git a/ProductPriceDataExporter/Model/Provider/ProductPrice.php b/ProductPriceDataExporter/Model/Provider/ProductPrice.php index 74d79eaa5..920973633 100644 --- a/ProductPriceDataExporter/Model/Provider/ProductPrice.php +++ b/ProductPriceDataExporter/Model/Provider/ProductPrice.php @@ -7,6 +7,8 @@ namespace Magento\ProductPriceDataExporter\Model\Provider; +use Magento\Bundle\Model\Product\Price as BundlePrice; +use Magento\Catalog\Api\Data\ProductAttributeInterface; use Magento\Catalog\Model\Product; use Magento\Catalog\Model\Product\Type; use Magento\ConfigurableProduct\Model\Product\Type\Configurable; @@ -28,6 +30,8 @@ * Fallback price - price used as fallback if price for given scope (, ) not found * Fallback price scope - * If Customer Group "All Groups" selected with qty=1, group price will be added to fallback price + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class ProductPrice { @@ -35,51 +39,30 @@ class ProductPrice private const PRICE_SCOPE_KEY_PART = 0; private const REGULAR_PRICE = 'price'; private const UNKNOWN_PRICE_CODE = 'unknown'; + private const BUNDLE_FIXED = 'BUNDLE_FIXED'; + private const BUNDLE_DYNAMIC = 'BUNDLE_DYNAMIC'; /** * mapping for ProductPriceAggregate.type */ private const PRODUCT_TYPE = [ + self::BUNDLE_FIXED, + self::BUNDLE_DYNAMIC, Type::TYPE_SIMPLE, Configurable::TYPE_CODE, - Type::TYPE_BUNDLE, Downloadable::TYPE_DOWNLOADABLE, Grouped::TYPE_CODE, - 'giftcard', // represet giftcard product type + Type::TYPE_BUNDLE, + 'giftcard', // represent giftcard product type ]; - /** - * @var ResourceConnection - */ private ResourceConnection $resourceConnection; - - /** - * @var ProductPricesQuery - */ private ProductPricesQuery $pricesQuery; - - /** - * @var CustomerGroupPricesQuery - */ private CustomerGroupPricesQuery $customerGroupPricesQuery; - - /** - * @var DateTime - */ private DateTime $dateTime; - - /** - * @var CatalogRulePricesQuery - */ private CatalogRulePricesQuery $catalogRulePricesQuery; - - /** - * @var DeleteFeedItems - */ private DeleteFeedItems $deleteFeedItems; - private CommerceDataExportLoggerInterface $logger; - private Config $eavConfig; /** @@ -98,12 +81,12 @@ class ProductPrice * @param CommerceDataExportLoggerInterface $logger */ public function __construct( - ProductPricesQuery $pricesQuery, + ProductPricesQuery $pricesQuery, CustomerGroupPricesQuery $customerGroupPricesQuery, - CatalogRulePricesQuery $catalogRulePricesQuery, - ResourceConnection $resourceConnection, - DeleteFeedItems $deleteFeedItems, - DateTime $dateTime, + CatalogRulePricesQuery $catalogRulePricesQuery, + ResourceConnection $resourceConnection, + DeleteFeedItems $deleteFeedItems, + DateTime $dateTime, Config $eavConfig, CommerceDataExportLoggerInterface $logger ) { @@ -122,8 +105,9 @@ public function __construct( * * @param array $values * @return array - * @throws UnableRetrieveData - * @throws LocalizedException + * @throws UnableRetrieveData|LocalizedException + * @throws \Zend_Db_Statement_Exception + * @throws \Exception */ public function get(array $values): array { @@ -133,34 +117,45 @@ public function get(array $values): array ); $output = []; while ($row = $cursor->fetch()) { + $percentageDiscount = null; + $priceAttributeCode = $this->resolvePriceCode($row); + if ($row['type_id'] === Type::TYPE_BUNDLE) { + $row['type_id'] = (int)$row['price_type'] === BundlePrice::PRICE_TYPE_FIXED + ? self::BUNDLE_FIXED + : self::BUNDLE_DYNAMIC; + if ($priceAttributeCode === ProductAttributeInterface::CODE_SPECIAL_PRICE) { + $percentageDiscount = $row['price']; + } + } $key = $this->buildKey($row['entity_id'], $row['website_id'], self::FALLBACK_CUSTOMER_GROUP); if (!isset($output[$key])) { $output[$key] = $this->fillOutput($row, $key); } - $priceAttributeCode = $this->resolvePriceCode($row); if ($priceAttributeCode === self::REGULAR_PRICE) { - $output[$key]['regular'] = $row['price']; + $output[$key]['regular'] = (float)$row['price']; } elseif ($priceAttributeCode !== self::UNKNOWN_PRICE_CODE) { - $this->addDiscountPrice($output[$key], $priceAttributeCode, (float)$row['price']); + $this->addDiscountPrice($output[$key], $priceAttributeCode, $row['price'], $percentageDiscount); } // cover case when _this_ product type doesn't have regular price, but this field is required in schema if (!isset($output[$key]['regular'])) { - $output[$key]['regular'] = 0; + $output[$key]['regular'] = 0.; } } $filteredIds = array_unique(array_column($output, 'productId')); $this->addCustomerGroupPrices($output, $filteredIds); $this->addCatalogRulePrices($output, $filteredIds); - $this->deleteFeedItems->execute($output); + $this->setItemsToDelete($output); + return $output; } /** + * Get price attributes + * * @return array - * @throws UnableRetrieveData - * @throws \Magento\Framework\Exception\LocalizedException + * @throws UnableRetrieveData|LocalizedException */ private function getPriceAttributes(): array { @@ -173,6 +168,10 @@ private function getPriceAttributes(): array if ($attribute) { $this->priceAttributes[$attribute->getId()] = 'special_price'; } + $attribute = $this->eavConfig->getAttribute(Product::ENTITY, 'price_type'); + if ($attribute) { + $this->priceAttributes[$attribute->getId()] = 'price_type'; + } } if (!$this->priceAttributes) { throw new UnableRetrieveData('Price attributes not found'); @@ -181,11 +180,13 @@ private function getPriceAttributes(): array } /** + * Resolve price code + * * @param array $row - * @return mixed|string + * @return string * @throws UnableRetrieveData|LocalizedException */ - private function resolvePriceCode(array $row) + private function resolvePriceCode(array $row): string { return $this->getPriceAttributes()[$row['attributeId']] ?? self::UNKNOWN_PRICE_CODE; } @@ -196,6 +197,7 @@ private function resolvePriceCode(array $row) * @param array $prices * @param array $productIds * @return void + * @throws \Zend_Db_Statement_Exception */ private function addCustomerGroupPrices(array &$prices, array $productIds): void { @@ -208,25 +210,22 @@ private function addCustomerGroupPrices(array &$prices, array $productIds): void $fallbackPrice = $prices[$keyFallback] ?? null; if (!$fallbackPrice) { $this->logger->error('Fallback price not found when adding customer group' . var_export($row, true)); - continue ; + continue; } - $priceValue = (float)$row['value']; - //calculate percentage discount if so - if (empty($priceValue) && !empty($row['percentage_value'])) { - $priceValue = $this->calculatePercentDiscountValue($fallbackPrice['regular'], $row['percentage_value']); - } + $priceValue = $row['value'] ?? null; + $pricePercentage = $row['percentage_value'] ?? null; // add "group" price to fallback price if ((int)$row['all_groups'] === 1) { - $this->addDiscountPrice($prices[$keyFallback], 'group', $priceValue); + $this->addDiscountPrice($prices[$keyFallback], 'group', $priceValue, $pricePercentage); continue; } // copy feed data from fallbackPrice for each row of customer group price $prices[$key] = $fallbackPrice; // override customer group specific fields - $this->addDiscountPrice($prices[$key], 'group', $priceValue, true); + $this->addDiscountPrice($prices[$key], 'group', $priceValue, $pricePercentage, true); $prices[$key]['customerGroupCode'] = $this->buildCustomerGroupCode($customerGroupId); $prices[$key]['productPriceId'] = $key; } @@ -238,6 +237,7 @@ private function addCustomerGroupPrices(array &$prices, array $productIds): void * @param array $prices * @param array $productIds * @return void + * @throws \Zend_Db_Statement_Exception */ private function addCatalogRulePrices(array &$prices, array $productIds): void { @@ -255,7 +255,7 @@ private function addCatalogRulePrices(array &$prices, array $productIds): void $fallbackPrice = $prices[$keyFallback] ?? null; if (!$fallbackPrice) { $this->logger->error('Fallback price not found when adding catalog rule' . var_export($row, true)); - continue ; + continue; } // copy feed data from fallbackPrice for each row of customer group price @@ -263,8 +263,7 @@ private function addCatalogRulePrices(array &$prices, array $productIds): void $prices[$key] = $fallbackPrice; } - // override customer group specific fields - $this->addDiscountPrice($prices[$key], 'catalog_rule', (float)$row['value'], true); + $this->addDiscountPrice($prices[$key], 'catalog_rule', $row['value']); $prices[$key]['customerGroupCode'] = sha1($customerGroupId); $prices[$key]['productPriceId'] = $key; } @@ -273,12 +272,12 @@ private function addCatalogRulePrices(array &$prices, array $productIds): void /** * Build Key * - * @param string $productId - * @param string $websiteId - * @param string $customerGroup + * @param int|string $productId + * @param int|string $websiteId + * @param int|string $customerGroup * @return string */ - private function buildKey(string $productId, string $websiteId, string $customerGroup): string + private function buildKey(int|string $productId, int|string $websiteId, int|string $customerGroup): string { return implode('-', [$productId, $websiteId, $customerGroup]); } @@ -288,24 +287,35 @@ private function buildKey(string $productId, string $websiteId, string $customer * * @param array $prices * @param string $code - * @param float $price + * @param ?string $price + * @param ?string $percentage * @param bool $override * @return void */ - private function addDiscountPrice(array &$prices, string $code, float $price, bool $override = false): void - { + private function addDiscountPrice( + array &$prices, + string $code, + string $price = null, + string $percentage = null, + bool $override = false + ): void { if ($override) { foreach ($prices['discounts'] as &$discount) { if ($discount['code'] === $code) { - $discount['price'] = $price; + $this->setPriceOrPercentageDiscount($discount, $price, $percentage); return; } } } - $prices['discounts'][] = [ - 'code' => $code, - 'price' => $price - ]; + unset($discount); + + if (null === $price && null === $percentage) { + return; + } + + $priceDiscount['code'] = $code; + $this->setPriceOrPercentageDiscount($priceDiscount, $price, $percentage); + $prices['discounts'][] = $priceDiscount; } /** @@ -324,7 +334,7 @@ private function fillOutput(array $row, string $key): array [$parentType, $parentSku] = explode(':', $parent); $parents[] = [ 'type' => $this->convertProductType(trim($parentType)), - 'sku' => $parentSku + 'sku' => $parentSku, ]; } @@ -342,23 +352,10 @@ private function fillOutput(array $row, string $key): array 'parents' => !empty($parents) ? $parents : null, 'discounts' => [], 'deleted' => false, - 'productPriceId' => $key + 'productPriceId' => $key, ]; } - /** - * Calculate Percent DiscountValue - * - * @param string $price - * @param string $percent - * @return float - */ - private function calculatePercentDiscountValue(string $price, string $percent): float - { - $groupDiscountValue = ((float)$percent / 100) * (float)$price; - return round((float)$price - $groupDiscountValue, 2); - } - /** * Convert Product Type * @@ -382,4 +379,39 @@ private function buildCustomerGroupCode(string $customerGroupId): string { return sha1($customerGroupId); } + + /** + * Check if fixed price or percentage discount should be applied + * + * @param array $discount + * @param ?string $price + * @param ?string $percent + * @return void + */ + private function setPriceOrPercentageDiscount(array &$discount, string $price = null, string $percent = null): void + { + if (null !== $percent) { + $discount['percentage'] = (float)$percent; + unset($discount['price']); + } elseif (null !== $price) { + $discount['price'] = (float)$price; + unset($discount['percentage']); + } + } + + /** + * Delete items from the feed + * + * @param array $output + * @return void + */ + private function setItemsToDelete(array &$output): void + { + $feedItemsToDelete = $this->deleteFeedItems->execute($output); + foreach ($feedItemsToDelete as $item) { + $key = $this->buildKey($item['productId'], $item['websiteId'], $item['customerGroupCode']); + $item['productPriceId'] = $key; + $output[$key] = $item; + } + } } diff --git a/ProductPriceDataExporter/Model/Query/ProductPricesQuery.php b/ProductPriceDataExporter/Model/Query/ProductPricesQuery.php index b03a1b639..d9b8ea54e 100644 --- a/ProductPriceDataExporter/Model/Query/ProductPricesQuery.php +++ b/ProductPriceDataExporter/Model/Query/ProductPricesQuery.php @@ -7,11 +7,13 @@ namespace Magento\ProductPriceDataExporter\Model\Query; +use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Model\Product\Type; use Magento\ConfigurableProduct\Model\Product\Type\Configurable; use Magento\Framework\App\ResourceConnection; use Magento\Framework\DB\Select; use Magento\Framework\DB\Sql\Expression; +use Magento\Framework\EntityManager\EntityMetadataInterface; use Magento\Framework\EntityManager\MetadataPool; /** @@ -29,7 +31,7 @@ class ProductPricesQuery */ private MetadataPool $metadataPool; - private const IGNORED_TYPES = [Configurable::TYPE_CODE, Type::TYPE_BUNDLE]; + private const IGNORED_TYPES = [Configurable::TYPE_CODE, 'giftcard']; /** * @param ResourceConnection $resourceConnection @@ -44,6 +46,8 @@ public function __construct( } /** + * Get query for product price + * * @param array $productIds * @param array $priceAttributes * @return Select @@ -51,8 +55,8 @@ public function __construct( */ public function getQuery(array $productIds, array $priceAttributes = []): Select { - /** @var \Magento\Framework\EntityManager\EntityMetadataInterface $metadata */ - $metadata = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class); + /** @var EntityMetadataInterface $metadata */ + $metadata = $this->metadataPool->getMetadata(ProductInterface::class); $connection = $this->resourceConnection->getConnection(); $eavAttributeTable = $this->resourceConnection->getTableName('catalog_product_entity_decimal'); $linkField = $metadata->getLinkField(); @@ -78,29 +82,25 @@ public function getQuery(array $productIds, array $priceAttributes = []): Select ['websiteCode' => 'code'] ) ->joinInner( - ['sg' => $this->resourceConnection->getTableName('store_group')], - 'sg.website_id = store_website.website_id', + ['store_group' => $this->resourceConnection->getTableName('store_group')], + 'store_group.website_id = store_website.website_id', [] ) ->joinLeft( - ['eav' => $eavAttributeTable], - \sprintf('product.%1$s = eav.%1$s', $linkField) . - $connection->quoteInto(' AND eav.attribute_id IN (?)', $priceAttributes) . - ' AND eav.store_id = 0', - [] + ['eavi' => $this->resourceConnection->getTableName('catalog_product_entity_int')], + \sprintf('product.%1$s = eavi.%1$s', $linkField) . + $connection->quoteInto(' AND eavi.attribute_id IN (?)', $priceAttributes) . + ' AND eavi.store_id = 0', + ['price_type' => 'value'] ) ->joinLeft( ['eav_store' => $eavAttributeTable], \sprintf('product.%1$s = eav_store.%1$s', $linkField) . $connection->quoteInto(' AND eav_store.attribute_id IN (?)', $priceAttributes) . - ' AND eav_store.store_id = sg.default_store_id', + ' AND eav_store.store_id IN (store_group.default_store_id, 0)', [ - 'price' => new Expression( - 'IF (eav_store.value_id, eav_store.value, eav.value)' - ), - 'attributeId' => new Expression( - 'IF(eav_store.value_id, eav_store.attribute_id, eav.attribute_id)' - ) + 'price' => 'eav_store.value', + 'attributeId' => 'attribute_id' ] ) // get parent skus @@ -131,8 +131,13 @@ public function getQuery(array $productIds, array $priceAttributes = []): Select ) ->where('product.entity_id IN (?)', $productIds) ->where('product.type_id NOT IN (?)', self::IGNORED_TYPES) + ->order('product.entity_id') + ->order('product_website.website_id') + ->order('eav_store.attribute_id') + ->order('eav_store.store_id') ->group('product.entity_id') ->group('product_website.website_id') - ->group('attributeId'); + ->group('eav_store.attribute_id') + ->group('eav_store.store_id'); } } diff --git a/ProductPriceDataExporter/README.md b/ProductPriceDataExporter/README.md index 93ae3c693..53e4e91d1 100644 --- a/ProductPriceDataExporter/README.md +++ b/ProductPriceDataExporter/README.md @@ -1,3 +1,29 @@ -## Release notes +# Overview +Module is created to support feeds which carrying information with product prices data. -*Magento_ProductPriceDataExporter* module +The "price" field will be eliminated from the products feed. Instead of that - use the "Product Prices" feed with stored products pricing data. +Feed fields overview: +```json{ +"websiteId": website id of the pricing value (as price can be unique per website) + +"productId": product ID + +"sku": product SKU + +"type": type of the product (SIMPLE, CONFIGURABLE, GROUPED, BUNDLE_FIXED, BUNDLE_DYNAMMIC, etc) + +"customerGroupCode": customer group code which price is relevant for (sha1 representation of customer groupd id. See \Magento\ProductPriceDataExporter\Model\Provider\ProductPrice::buildCustomerGroupCode) + +"websiteCode": website code of the price relevant for + +"regular": regular price of the product. For some products it's could be null (like DYNAMIC and CONFIGURABLE) + +"discounts": array of additional discounts for the product. Each discount has two fields "code" and "price". Currently we are using two codes - "special_price" (for any special price discounts) and "group" (for tier-price discounts) + +"deleted": is price deleted (0 or 1) + +"updatedAt": time and day when price was updated. Service specific field + +"modifiedAt": time and day when price was modified. Service specific field +} +``` \ No newline at end of file diff --git a/ProductPriceDataExporter/Test/Integration/ExportComplexProductPricePerWebsiteTest.php b/ProductPriceDataExporter/Test/Integration/ExportComplexProductPricePerWebsiteTest.php index 04484a41b..d85bef98c 100644 --- a/ProductPriceDataExporter/Test/Integration/ExportComplexProductPricePerWebsiteTest.php +++ b/ProductPriceDataExporter/Test/Integration/ExportComplexProductPricePerWebsiteTest.php @@ -53,7 +53,7 @@ public function __construct( parent::__construct($name, $data, $dataName); $this->indexer = Bootstrap::getObjectManager()->create(Indexer::class); $this->productRepository = Bootstrap::getObjectManager()->create(ProductRepositoryInterface::class); - $this->productPricesFeed = Bootstrap::getObjectManager()->get(FeedPool::class)->getFeed('productPrices'); + $this->productPricesFeed = Bootstrap::getObjectManager()->get(FeedPool::class)->getFeed('prices'); $this->resourceConnection = Bootstrap::getObjectManager()->create(ResourceConnection::class); } @@ -155,15 +155,6 @@ private function expectedBundleFixedProductPricesDataProvider(): array 'discounts' => null, 'type' => 'BUNDLE' ], - [ - 'sku' => 'bundle_fixed_product_with_regular_price', - 'customerGroupCode' => '0', - 'websiteCode' => 'test', - 'regular' => 105.1, - 'deleted' => false, - 'discounts' => null, - 'type' => 'BUNDLE' - ], [ 'sku' => 'bundle_fixed_product_with_special_price', 'customerGroupCode' => '0', diff --git a/ProductPriceDataExporter/Test/Integration/ExportComplexProductPriceTest.php b/ProductPriceDataExporter/Test/Integration/ExportComplexProductPriceTest.php index bf5a661e6..8a7cf4619 100644 --- a/ProductPriceDataExporter/Test/Integration/ExportComplexProductPriceTest.php +++ b/ProductPriceDataExporter/Test/Integration/ExportComplexProductPriceTest.php @@ -53,7 +53,7 @@ public function __construct( parent::__construct($name, $data, $dataName); $this->indexer = Bootstrap::getObjectManager()->create(Indexer::class); $this->productRepository = Bootstrap::getObjectManager()->create(ProductRepositoryInterface::class); - $this->productPricesFeed = Bootstrap::getObjectManager()->get(FeedPool::class)->getFeed('productPrices'); + $this->productPricesFeed = Bootstrap::getObjectManager()->get(FeedPool::class)->getFeed('prices'); $this->resourceConnection = Bootstrap::getObjectManager()->create(ResourceConnection::class); } @@ -65,7 +65,6 @@ public function __construct( */ public function testExportBundleFixedProductsPrices(array $expectedBundleFixedProductPricesDataProvider): void { - self::markTestSkipped('Will be implemented in https://jira.corp.adobe.com/browse/DCAT-640'); $this->checkExpectedItemsAreExportedInFeed($expectedBundleFixedProductPricesDataProvider); } @@ -136,10 +135,10 @@ private function expectedBundleFixedProductPricesDataProvider(): array 'sku' => 'bundle_fixed_product_with_regular_price', 'customerGroupCode' => '0', 'websiteCode' => 'base', - 'regular' => 100.1, + 'regular' => 105.1, 'deleted' => false, 'discounts' => null, - 'type' => 'BUNDLE' + 'type' => 'BUNDLE_FIXED' ], [ 'sku' => 'bundle_fixed_product_with_regular_price', @@ -148,61 +147,61 @@ private function expectedBundleFixedProductPricesDataProvider(): array 'regular' => 105.1, 'deleted' => false, 'discounts' => null, - 'type' => 'BUNDLE' + 'type' => 'BUNDLE_FIXED' ], [ 'sku' => 'bundle_fixed_product_with_special_price', 'customerGroupCode' => '0', 'websiteCode' => 'base', - 'regular' => 150.15, + 'regular' => 155.15, 'deleted' => false, - 'discounts' => [0 => ['code' => 'special_price', 'price' => 50.5]], - 'type' => 'BUNDLE' + 'discounts' => [0 => ['code' => 'special_price', 'percentage' => 55.55]], + 'type' => 'BUNDLE_FIXED' ], [ 'sku' => 'bundle_fixed_product_with_special_price', 'customerGroupCode' => '0', 'websiteCode' => 'test', - 'regular' => 150.15, + 'regular' => 155.15, 'deleted' => false, - 'discounts' => [0 => ['code' => 'special_price', 'price' => 55.55]], - 'type' => 'BUNDLE' + 'discounts' => [0 => ['code' => 'special_price', 'percentage' => 55.55]], + 'type' => 'BUNDLE_FIXED' ], [ 'sku' => 'bundle_fixed_product_with_tier_price', 'customerGroupCode' => '0', 'websiteCode' => 'base', - 'regular' => 150.15, + 'regular' => 100.1, 'deleted' => false, 'discounts' => [0 => ['code' => 'group', 'price' => 16.16]], - 'type' => 'BUNDLE' + 'type' => 'BUNDLE_FIXED' ], [ 'sku' => 'bundle_fixed_product_with_tier_price', 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', 'websiteCode' => 'base', - 'regular' => 150.15, + 'regular' => 100.1, 'deleted' => false, 'discounts' => [0 => ['code' => 'group', 'price' => 15.15]], - 'type' => 'BUNDLE' + 'type' => 'BUNDLE_FIXED' ], [ 'sku' => 'bundle_fixed_product_with_tier_price', 'customerGroupCode' => '0', 'websiteCode' => 'test', - 'regular' => 150.15, + 'regular' => 100.1, 'deleted' => false, - 'discounts' => [0 => ['code' => 'group', 'price' => 14.14]], - 'type' => 'BUNDLE' + 'discounts' => [0 => ['code' => 'group', 'percentage' => 10]], + 'type' => 'BUNDLE_FIXED' ], [ 'sku' => 'bundle_fixed_product_with_tier_price', 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', 'websiteCode' => 'test', - 'regular' => 150.15, + 'regular' => 100.1, 'deleted' => false, - 'discounts' => [0 => ['code' => 'group', 'price' => 13.13]], - 'type' => 'BUNDLE' + 'discounts' => [0 => ['code' => 'group', 'percentage' => 10]], + 'type' => 'BUNDLE_FIXED' ], ] ] diff --git a/ProductPriceDataExporter/Test/Integration/ExportSingleProductPricePerWebsiteTest.php b/ProductPriceDataExporter/Test/Integration/ExportSingleProductPricePerWebsiteTest.php index f63199ac3..27ccfe9d1 100644 --- a/ProductPriceDataExporter/Test/Integration/ExportSingleProductPricePerWebsiteTest.php +++ b/ProductPriceDataExporter/Test/Integration/ExportSingleProductPricePerWebsiteTest.php @@ -53,7 +53,7 @@ public function __construct( parent::__construct($name, $data, $dataName); $this->indexer = Bootstrap::getObjectManager()->create(Indexer::class); $this->productRepository = Bootstrap::getObjectManager()->create(ProductRepositoryInterface::class); - $this->productPricesFeed = Bootstrap::getObjectManager()->get(FeedPool::class)->getFeed('productPrices'); + $this->productPricesFeed = Bootstrap::getObjectManager()->get(FeedPool::class)->getFeed('prices'); $this->resourceConnection = Bootstrap::getObjectManager()->create(ResourceConnection::class); } @@ -151,7 +151,7 @@ private function expectedSimpleProductPricesDataProvider(): array 'websiteCode' => 'base', 'regular' => 100.1, 'deleted' => false, - 'discounts' => [0 => ['code' => 'group', 'price' => 16.16]], + 'discounts' => [0 => ['code' => 'group', 'percentage' => 10]], 'type' => 'SIMPLE' ], [ diff --git a/ProductPriceDataExporter/Test/Integration/ExportSingleProductPriceTest.php b/ProductPriceDataExporter/Test/Integration/ExportSingleProductPriceTest.php index 84405d0ab..b4ecdbd76 100644 --- a/ProductPriceDataExporter/Test/Integration/ExportSingleProductPriceTest.php +++ b/ProductPriceDataExporter/Test/Integration/ExportSingleProductPriceTest.php @@ -10,6 +10,10 @@ use DateTime; use DateTimeInterface; use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\CatalogRule\Api\CatalogRuleRepositoryInterface; +use Magento\CatalogRule\Api\Data\RuleInterface; +use Magento\CatalogRule\Model\Indexer\Rule\RuleProductProcessor; +use Magento\CatalogRule\Model\ResourceModel\RuleFactory as ResourceRuleFactory; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Exception\NoSuchEntityException; use Magento\DataExporter\Model\FeedInterface; @@ -40,6 +44,8 @@ class ExportSingleProductPriceTest extends TestCase private ResourceConnection $resourceConnection; + private CatalogRuleRepositoryInterface $catalogRuleRepository; + /** * @param string|null $name * @param array $data @@ -53,8 +59,9 @@ public function __construct( parent::__construct($name, $data, $dataName); $this->indexer = Bootstrap::getObjectManager()->create(Indexer::class); $this->productRepository = Bootstrap::getObjectManager()->create(ProductRepositoryInterface::class); - $this->productPricesFeed = Bootstrap::getObjectManager()->get(FeedPool::class)->getFeed('productPrices'); + $this->productPricesFeed = Bootstrap::getObjectManager()->get(FeedPool::class)->getFeed('prices'); $this->resourceConnection = Bootstrap::getObjectManager()->create(ResourceConnection::class); + $this->catalogRuleRepository = Bootstrap::getObjectManager()->get(CatalogRuleRepositoryInterface::class); } /** @@ -68,6 +75,35 @@ public function testExportSimpleProductsPrices(array $expectedSimpleProductPrice $this->checkExpectedItemsAreExportedInFeed($expectedSimpleProductPrices); } + /** + * @magentoDataFixture Magento_ProductPriceDataExporter::Test/_files/simple_products.php + * @magentoDataFixture Magento/CatalogRule/_files/catalog_rule_25_customer_group_all.php + * @dataProvider expectedSimpleProductPricesWithCatalogRuleDataProvider + * @throws NoSuchEntityException + * @throws Zend_Db_Statement_Exception + */ + public function testExportSimpleProductsWithCatalogPriceRulePrices(array $expectedSimpleProductPrices): void + { + $this->checkExpectedItemsAreExportedInFeed($expectedSimpleProductPrices); + } + + /** + * @magentoDataFixture Magento_ProductPriceDataExporter::Test/_files/simple_products.php + * @magentoDataFixture Magento/CatalogRule/_files/catalog_rule_25_customer_group_all.php + * @dataProvider expectedSimpleProductPricesWithCatalogRuleDisabledDataProvider + * @throws NoSuchEntityException + * @throws Zend_Db_Statement_Exception + */ + public function testExportSimpleProductsWithDisabledCatalogPriceRulePrices(array $expectedSimpleProductPrices): void + { + $ruleProductProcessor = Bootstrap::getObjectManager()->get(RuleProductProcessor::class); + $rule = $this->getRuleByName('Test Catalog Rule With 25 Percent Off'); + $rule->setIsActive(0); + $this->catalogRuleRepository->save($rule); + $ruleProductProcessor->getIndexer()->reindexAll(); + $this->checkExpectedItemsAreExportedInFeed($expectedSimpleProductPrices); + } + /** * @magentoDataFixture Magento_ProductPriceDataExporter::Test/_files/downloadable_products.php * @dataProvider expectedDownloadableProductPricesDataProvider @@ -147,7 +183,7 @@ private function expectedSimpleProductPricesDataProvider(): array 'websiteCode' => 'base', 'regular' => 100.1, 'deleted' => false, - 'discounts' => [0 => ['code' => 'group', 'price' => 16.16]], + 'discounts' => [0 => ['code' => 'group', 'percentage' => 10]], 'type' => 'SIMPLE' ], [ @@ -182,6 +218,283 @@ private function expectedSimpleProductPricesDataProvider(): array ]; } + /** + * @return \array[][] + */ + private function expectedSimpleProductPricesWithCatalogRuleDataProvider(): array + { + return [ + [ + [ + [ + 'sku' => 'simple_product_with_regular_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'base', + 'regular' => 55.55, + 'deleted' => false, + 'discounts' => null, + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_regular_price', + 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', + 'websiteCode' => 'base', + 'regular' => 55.55, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'catalog_rule', 'price' => 41.66]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_regular_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'test', + 'regular' => 55.55, + 'deleted' => false, + 'discounts' => null, + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_special_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'special_price', 'price' => 55.55]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_special_price', + 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [ + 0 => ['code' => 'special_price', 'price' => 55.55], + 1 => ['code' => 'catalog_rule', 'price' => 75.08] + ], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_special_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'test', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'special_price', 'price' => 55.55]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'virtual_product_with_special_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'special_price', 'price' => 55.55]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'virtual_product_with_special_price', + 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [ + 0 => ['code' => 'special_price', 'price' => 55.55], + 1 => ['code' => 'catalog_rule', 'price' => 75.08] + ], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'virtual_product_with_special_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'test', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'special_price', 'price' => 55.55]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'group', 'percentage' => 10]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [ + 0 => ['code' => 'group', 'price' => 15.15], + 1 => ['code' => 'catalog_rule', 'price' => 75.08] + ], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'test', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'group', 'price' => 14.14]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', + 'websiteCode' => 'test', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'group', 'price' => 13.13]], + 'type' => 'SIMPLE' + ] + ] + ] + ]; + } + + /** + * @return \array[][] + */ + private function expectedSimpleProductPricesWithCatalogRuleDisabledDataProvider(): array + { + return [ + [ + [ + [ + 'sku' => 'simple_product_with_regular_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'base', + 'regular' => 55.55, + 'deleted' => false, + 'discounts' => null, + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_regular_price', + 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', + 'websiteCode' => 'base', + 'regular' => 55.55, + 'deleted' => true, + 'discounts' => [0 => ['code' => 'catalog_rule', 'price' => 41.66]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_regular_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'test', + 'regular' => 55.55, + 'deleted' => false, + 'discounts' => null, + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_special_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'special_price', 'price' => 55.55]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_special_price', + 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => true, + 'discounts' => [ + 0 => ['code' => 'special_price', 'price' => 55.55], + 1 => ['code' => 'catalog_rule', 'price' => 75.08] + ], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_special_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'test', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'special_price', 'price' => 55.55]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'virtual_product_with_special_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'special_price', 'price' => 55.55]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'virtual_product_with_special_price', + 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => true, + 'discounts' => [ + 0 => ['code' => 'special_price', 'price' => 55.55], + 1 => ['code' => 'catalog_rule', 'price' => 75.08] + ], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'virtual_product_with_special_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'test', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'special_price', 'price' => 55.55]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'group', 'percentage' => 10]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [ + 0 => ['code' => 'group', 'price' => 15.15] + ], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'test', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'group', 'price' => 14.14]], + 'type' => 'SIMPLE' + ], + [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', + 'websiteCode' => 'test', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'group', 'price' => 13.13]], + 'type' => 'SIMPLE' + ] + ] + ] + ]; + } + /** * @return array[] */ @@ -293,7 +606,11 @@ private function checkExpectedItemsAreExportedInFeed(array $expectedItems): void if (!isset($actualProductPricesFeed['feed'][$index])) { self::fail("Cannot find product price feed"); } - + self::assertNotEmpty($actualProductPricesFeed['feed'][$index]['modifiedAt']); + self::assertEquals( + strtotime($actualProductPricesFeed['feed'][$index]['modifiedAt']), + strtotime($actualProductPricesFeed['feed'][$index]['updatedAt']) + ); // unset fields from feed that we don't care about for test $actualFeed = $this->unsetNotImportantField($actualProductPricesFeed['feed'][$index]); self::assertEquals($product, $actualFeed, "Some items are missing in product price feed $index"); @@ -351,4 +668,21 @@ private function truncateIndexTable(): void $feedTable = $this->resourceConnection->getTableName('catalog_data_exporter_product_prices'); $connection->truncateTable($feedTable); } + + /** + * Retrieve catalog rule by name from db. + * + * @param string $name + * @return RuleInterface + */ + private function getRuleByName(string $name): RuleInterface + { + $catalogRuleResource = Bootstrap::getObjectManager()->get(ResourceRuleFactory::class)->create(); + $select = $catalogRuleResource->getConnection()->select(); + $select->from($catalogRuleResource->getMainTable(), RuleInterface::RULE_ID); + $select->where(RuleInterface::NAME . ' = ?', $name); + $ruleId = $catalogRuleResource->getConnection()->fetchOne($select); + + return $this->catalogRuleRepository->get((int)$ruleId); + } } diff --git a/ProductPriceDataExporter/Test/Integration/ExportSingleProductPriceUpdateOperationsTest.php b/ProductPriceDataExporter/Test/Integration/ExportSingleProductPriceUpdateOperationsTest.php index 4ba520611..bc974f694 100644 --- a/ProductPriceDataExporter/Test/Integration/ExportSingleProductPriceUpdateOperationsTest.php +++ b/ProductPriceDataExporter/Test/Integration/ExportSingleProductPriceUpdateOperationsTest.php @@ -17,6 +17,7 @@ use Magento\Framework\ObjectManagerInterface; use Magento\Indexer\Model\Indexer; use Magento\Indexer\Model\Processor; +use Magento\Store\Api\StoreRepositoryInterface; use Magento\TestFramework\Helper\Bootstrap; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -60,7 +61,7 @@ public function __construct( $this->objectManager = Bootstrap::getObjectManager(); $this->indexer = $this->objectManager->create(Indexer::class); $this->productRepository = $this->objectManager->create(ProductRepositoryInterface::class); - $this->productPricesFeed = $this->objectManager->get(FeedPool::class)->getFeed('productPrices'); + $this->productPricesFeed = $this->objectManager->get(FeedPool::class)->getFeed('prices'); $this->resourceConnection = $this->objectManager->create(ResourceConnection::class); } @@ -84,8 +85,68 @@ public function testUnassignProductFromWebsite(array $expectedSimpleProductPrice $websiteRepository = $this->objectManager->get(WebsiteRepositoryInterface::class); $secondWebsiteId = $websiteRepository->get('test')->getId(); $product->setWebsiteIds([$secondWebsiteId]); + sleep(1); // Make sure that more than one second passed after previous save $this->productRepository->save($product); - self::markTestSkipped("System uses DELETE logic instead of UPDATE. Should be changed in MDEE-442"); + $this->checkExpectedItemsAreExportedInFeed($expectedSimpleProductPrices, $expectedIds); + } + + /** + * @magentoConfigFixture current_store catalog/price/scope 1 + * @magentoDataFixture Magento_ProductPriceDataExporter::Test/_files/configure_website_scope_price.php + * @magentoDataFixture Magento_ProductPriceDataExporter::Test/_files/catalog_data_exporter_product_prices_indexer_update_on_schedule.php + * @magentoDataFixture Magento_ProductPriceDataExporter::Test/_files/simple_products_all_websites_grouped_price.php + * @dataProvider expectedSimpleProductDisabledGlobalDataProvider + * @throws NoSuchEntityException + * @throws Zend_Db_Statement_Exception + */ + public function testDisableProductGlobally(array $expectedSimpleProductPrices): void + { + $expectedIds = []; + foreach ($expectedSimpleProductPrices as $expectedItem) { + $expectedIds[] = $this->productRepository->get($expectedItem['sku'])->getId(); + } + $this->runIndexer($expectedIds); + //Get product for edit in general scope (all websites) + $product = $this->productRepository->get('simple_product_with_tier_price', true, 0); + //Disable it on general level + $product->setStatus(2); + $this->productRepository->save($product); + $this->checkExpectedItemsAreExportedInFeed($expectedSimpleProductPrices, $expectedIds); + } + + /** + * @magentoConfigFixture current_store catalog/price/scope 1 + * @magentoDataFixture Magento_ProductPriceDataExporter::Test/_files/configure_website_scope_price.php + * @magentoDataFixture Magento_ProductPriceDataExporter::Test/_files/catalog_data_exporter_product_prices_indexer_update_on_schedule.php + * @magentoDataFixture Magento_ProductPriceDataExporter::Test/_files/simple_products_all_websites_grouped_price.php + * @dataProvider expectedSimpleProductEnabledOneStoreDataProvider + * @throws NoSuchEntityException + * @throws Zend_Db_Statement_Exception + */ + public function testEnableProductOnWebsite(array $expectedSimpleProductPrices): void + { + $expectedIds = []; + foreach ($expectedSimpleProductPrices as $expectedItem) { + $expectedIds[] = $this->productRepository->get($expectedItem['sku'])->getId(); + } + $this->runIndexer($expectedIds); + //Get product for edit in general scope (all websites) + $product = $this->productRepository->get('simple_product_with_tier_price', true, 0); + //Disable it on general level + $product->setStatus(2); + $this->productRepository->save($product); + $storeRepository = $this->objectManager->get(StoreRepositoryInterface::class); + $store = $storeRepository->getActiveStoreByCode('fixture_second_store'); + $secondWebsiteStoreId = $store->getId(); + //Get product for edit in general scope (all websites) + $secondStoreProduct = $this->productRepository->get( + 'simple_product_with_tier_price', + true, + $secondWebsiteStoreId + ); + //Enable for second store + $secondStoreProduct->setStatus(1); + $this->productRepository->save($secondStoreProduct); $this->checkExpectedItemsAreExportedInFeed($expectedSimpleProductPrices, $expectedIds); } @@ -146,6 +207,37 @@ public function testReassignGroupPriceToProduct(array $expectedSimpleProductPric * @return \array[][] */ private function expectedSimpleProductPricesUnassignedWebsiteDataProvider(): array + { + return [ + [ + [ + 'simple_product_with_tier_price_test_0' => [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'test', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'group', 'price' => 15.15]], + 'type' => 'SIMPLE' + ], + 'simple_product_with_tier_price_test_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c' => [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', + 'websiteCode' => 'test', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'group', 'price' => 16.16]], + 'type' => 'SIMPLE' + ], + ] + ] + ]; + } + + /** + * @return array[] + */ + private function expectedSimpleProductDisabledGlobalDataProvider(): array { return [ [ @@ -155,7 +247,7 @@ private function expectedSimpleProductPricesUnassignedWebsiteDataProvider(): arr 'customerGroupCode' => '0', 'websiteCode' => 'base', 'regular' => 100.1, - 'deleted' => true, + 'deleted' => false, 'discounts' => [0 => ['code' => 'group', 'price' => 15.15]], 'type' => 'SIMPLE' ], @@ -164,7 +256,56 @@ private function expectedSimpleProductPricesUnassignedWebsiteDataProvider(): arr 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', 'websiteCode' => 'base', 'regular' => 100.1, - 'deleted' => true, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'group', 'price' => 16.16]], + 'type' => 'SIMPLE' + ], + 'simple_product_with_tier_price_test_0' => [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'test', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'group', 'price' => 15.15]], + 'type' => 'SIMPLE' + ], + 'simple_product_with_tier_price_test_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c' => [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', + 'websiteCode' => 'test', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'group', 'price' => 16.16]], + 'type' => 'SIMPLE' + ], + ] + ] + ]; + } + + /** + * @return array[] + */ + private function expectedSimpleProductEnabledOneStoreDataProvider(): array + { + return [ + [ + [ + 'simple_product_with_tier_price_base_0' => [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => '0', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => false, + 'discounts' => [0 => ['code' => 'group', 'price' => 15.15]], + 'type' => 'SIMPLE' + ], + 'simple_product_with_tier_price_base_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c' => [ + 'sku' => 'simple_product_with_tier_price', + 'customerGroupCode' => 'b6589fc6ab0dc82cf12099d1c2d40ab994e8410c', + 'websiteCode' => 'base', + 'regular' => 100.1, + 'deleted' => false, 'discounts' => [0 => ['code' => 'group', 'price' => 16.16]], 'type' => 'SIMPLE' ], diff --git a/ProductPriceDataExporter/Test/_files/bundle_fixed_products.php b/ProductPriceDataExporter/Test/_files/bundle_fixed_products.php index 0335fbfe9..c6cce68fe 100644 --- a/ProductPriceDataExporter/Test/_files/bundle_fixed_products.php +++ b/ProductPriceDataExporter/Test/_files/bundle_fixed_products.php @@ -91,18 +91,24 @@ [ 'sku' => $product->getSku(), 'selection_qty' => 1, + 'selection_price_value' => 10, + 'selection_price_type' => 0, 'selection_can_change_qty' => 1, 'delete' => '', ], [ 'sku' => $product2->getSku(), 'selection_qty' => 1, + 'selection_price_value' => 25, + 'selection_price_type' => 1, 'selection_can_change_qty' => 1, 'delete' => '', ], [ 'sku' => $product3->getSku(), 'selection_qty' => 1, + 'selection_price_value' => 50, + 'selection_price_type' => 1, 'selection_can_change_qty' => 1, 'delete' => '', ], @@ -202,6 +208,7 @@ // Create TierPrice $tierPriceExtensionAttributesFirstWs = $tierPriceExtensionAttributesFactory->create()->setWebsiteId($firstWebsiteId); $tierPriceExtensionAttributesSecondWs = $tierPriceExtensionAttributesFactory->create()->setWebsiteId($secondWebsiteId); +$tierPriceExtensionAttributesSecondWs->setPercentageValue(10); /** First website tier prices */ $productTierPrices[] = $tierPriceFactory->create([ @@ -225,7 +232,6 @@ $productTierPrices[] = $tierPriceFactory->create([ 'data' => [ 'customer_group_id' => \Magento\Customer\Model\Group::CUST_GROUP_ALL, - 'percentage_value'=> null, 'qty'=> 1, 'value'=> 14.14 ] @@ -233,7 +239,6 @@ $productTierPrices[] = $tierPriceFactory->create([ 'data' => [ 'customer_group_id' => \Magento\Customer\Model\Group::NOT_LOGGED_IN_ID, - 'percentage_value'=> null, 'qty'=> 1, 'value'=> 13.13 ] diff --git a/ProductPriceDataExporter/Test/_files/simple_products.php b/ProductPriceDataExporter/Test/_files/simple_products.php index 7ce07d5a4..e2b29955f 100644 --- a/ProductPriceDataExporter/Test/_files/simple_products.php +++ b/ProductPriceDataExporter/Test/_files/simple_products.php @@ -107,21 +107,22 @@ // Create TierPrice $tierPriceExtensionAttributesFirstWs = $tierPriceExtensionAttributesFactory->create()->setWebsiteId($firstWebsiteId); +$tierPriceExtensionAttributesFirstWsFirstGroup = $tierPriceExtensionAttributesFactory->create() + ->setWebsiteId($firstWebsiteId); +$tierPriceExtensionAttributesFirstWsFirstGroup->setPercentageValue(10); $tierPriceExtensionAttributesSecondWs = $tierPriceExtensionAttributesFactory->create()->setWebsiteId($secondWebsiteId); /** First website tier prices */ $productTierPrices[] = $tierPriceFactory->create([ 'data' => [ 'customer_group_id' => Group::CUST_GROUP_ALL, - 'percentage_value'=> null, 'qty'=> 1, - 'value'=> 16.16 ] -])->setExtensionAttributes($tierPriceExtensionAttributesFirstWs); +])->setExtensionAttributes($tierPriceExtensionAttributesFirstWsFirstGroup); + $productTierPrices[] = $tierPriceFactory->create([ 'data' => [ 'customer_group_id' => Group::NOT_LOGGED_IN_ID, - 'percentage_value'=> null, 'qty'=> 1, 'value'=> 15.15 ] diff --git a/ProductPriceDataExporter/composer.json b/ProductPriceDataExporter/composer.json index 76a08e947..a5d113df4 100644 --- a/ProductPriceDataExporter/composer.json +++ b/ProductPriceDataExporter/composer.json @@ -24,6 +24,7 @@ "magento/module-downloadable": ">=100.4.4", "magento/module-catalog-rule": ">=101.2.4", "magento/module-catalog": ">=104.0.4", + "magento/module-bundle": ">=101.0.4", "magento/module-data-exporter": "self.version" } } diff --git a/ProductPriceDataExporter/etc/db_schema.xml b/ProductPriceDataExporter/etc/db_schema.xml index c3097ecab..fa62c7202 100644 --- a/ProductPriceDataExporter/etc/db_schema.xml +++ b/ProductPriceDataExporter/etc/db_schema.xml @@ -49,6 +49,26 @@ default="0" comment="Product Deleted" /> + + + diff --git a/ProductPriceDataExporter/etc/di.xml b/ProductPriceDataExporter/etc/di.xml index 123d8c782..c88272c82 100644 --- a/ProductPriceDataExporter/etc/di.xml +++ b/ProductPriceDataExporter/etc/di.xml @@ -8,22 +8,35 @@ - productPrices + prices + productId catalog_product_entity entity_id catalog_data_exporter_product_prices product_id - - feed_data - is_deleted - 1000 false + true + Magento\DataExporter\Model\ExportFeedInterface::PERSIST_EXPORTED_FEED + + + sku + customerGroupCode + websiteCode + updatedAt + + + + productId + websiteId + customerGroupCode + + - + productId websiteId @@ -31,6 +44,7 @@ + Magento\ProductPriceDataExporter\Model\Indexer\ProductPriceFeedIndexMetadata @@ -46,8 +60,19 @@ - Magento\ProductPriceDataExporter\Model\ProductPriceFeed + Magento\ProductPriceDataExporter\Model\ProductPriceFeed + + + + + + + + + + Magento\ProductPriceDataExporter\Model\Indexer\ProductPriceFeedIndexMetadata + diff --git a/ProductPriceDataExporter/etc/et_schema.xml b/ProductPriceDataExporter/etc/et_schema.xml index 536c5b3b1..24687c099 100644 --- a/ProductPriceDataExporter/etc/et_schema.xml +++ b/ProductPriceDataExporter/etc/et_schema.xml @@ -25,12 +25,13 @@ - + - + + - diff --git a/ProductReviewDataExporter/Model/Indexer/RatingDataSerializer.php b/ProductReviewDataExporter/Model/Indexer/RatingDataSerializer.php index 7587717d4..582138f57 100644 --- a/ProductReviewDataExporter/Model/Indexer/RatingDataSerializer.php +++ b/ProductReviewDataExporter/Model/Indexer/RatingDataSerializer.php @@ -8,6 +8,8 @@ namespace Magento\ProductReviewDataExporter\Model\Indexer; +use Magento\DataExporter\Model\FeedExportStatus; +use Magento\DataExporter\Model\Indexer\FeedIndexMetadata; use Magento\Framework\Serialize\SerializerInterface; use Magento\DataExporter\Model\Indexer\DataSerializerInterface; @@ -50,12 +52,13 @@ public function __construct( * Serialize feed data * * @param array $data - * + * @param ?FeedExportStatus $exportStatus + * @param FeedIndexMetadata $metadata * @return array * - * @throws \InvalidArgumentException + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function serialize(array $data): array + public function serialize(array $data, ?FeedExportStatus $exportStatus, FeedIndexMetadata $metadata): array { $output = []; foreach ($data as $row) { diff --git a/ProductVariantDataExporter/Model/Query/ProductVariantsQuery.php b/ProductVariantDataExporter/Model/Query/ProductVariantsQuery.php index 208623685..bff4680c4 100644 --- a/ProductVariantDataExporter/Model/Query/ProductVariantsQuery.php +++ b/ProductVariantDataExporter/Model/Query/ProductVariantsQuery.php @@ -7,26 +7,35 @@ namespace Magento\ProductVariantDataExporter\Model\Query; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Eav\Model\Config; use Magento\Framework\App\ResourceConnection; use Magento\Framework\DB\Select; +use Magento\Framework\Exception\LocalizedException; /** * Build Select object to fetch configurable product variant option values */ class ProductVariantsQuery { + private const STATUS_ATTRIBUTE_CODE = "status"; + /** * @var ResourceConnection */ private $resourceConnection; + private Config $eavConfig; /** * @param ResourceConnection $resourceConnection + * @param Config $eavConfig */ public function __construct( - ResourceConnection $resourceConnection + ResourceConnection $resourceConnection, + Config $eavConfig ) { $this->resourceConnection = $resourceConnection; + $this->eavConfig = $eavConfig; } /** @@ -34,6 +43,7 @@ public function __construct( * * @param array $parentIds * @return Select + * @throws LocalizedException */ public function getQuery(array $parentIds): Select { @@ -42,6 +52,9 @@ public function getQuery(array $parentIds): Select $this->resourceConnection->getTableName('catalog_product_entity') ); + $statusAttribute = $this->eavConfig->getAttribute('catalog_product', self::STATUS_ATTRIBUTE_CODE); + $statusAttributeId = $statusAttribute?->getId(); + $subSelect = $connection->select() ->from( ['cpsa' => $this->resourceConnection->getTableName('catalog_product_super_attribute')], @@ -50,12 +63,12 @@ public function getQuery(array $parentIds): Select ->where(\sprintf('cpsa.product_id IN (cpep.%s)', $joinField)); $select = $connection->select() ->from( - ['cpec' => $this->resourceConnection->getTableName('catalog_product_entity')], + ['product' => $this->resourceConnection->getTableName('catalog_product_entity')], [] ) ->joinInner( ['cpsl' => $this->resourceConnection->getTableName('catalog_product_super_link')], - \sprintf('cpsl.product_id = cpec.entity_id'), + \sprintf('cpsl.product_id = product.entity_id'), [] ) ->joinInner( @@ -66,7 +79,7 @@ public function getQuery(array $parentIds): Select ->joinInner( ['cpei' => $this->resourceConnection->getTableName(['catalog_product_entity', 'int'])], \sprintf( - 'cpei.%1$s = cpec.%1$s AND cpei.attribute_id IN (%2$s)', + 'cpei.%1$s = product.%1$s AND cpei.attribute_id IN (%2$s)', $joinField, $subSelect->assemble() ), @@ -82,19 +95,32 @@ public function getQuery(array $parentIds): Select 'option_value.option_id = cpei.value', [] ) + ->joinLeft( + ['product_status' => $this->resourceConnection->getTableName('catalog_product_entity_int')], + \sprintf( + 'product_status.%s = product.%s + AND product_status.attribute_id = %s + AND product_status.store_id = 0', + $joinField, + $joinField, + $statusAttributeId, + ), + [] + ) ->columns( [ 'parentId' => 'cpep.entity_id', - 'childId' => 'cpec.entity_id', + 'childId' => 'product.entity_id', 'attributeId' => 'cpei.attribute_id', 'attributeCode' => 'ea.attribute_code', 'optionValueId' => 'cpei.value', - 'productSku' => 'cpec.sku', + 'productSku' => 'product.sku', 'parentSku' => 'cpep.sku', 'optionLabel' => 'option_value.value' ] ) - ->where('cpec.entity_id IN (?)', $parentIds); + ->where('product.entity_id IN (?)', $parentIds) + ->where('product_status.value = ?', Status::STATUS_ENABLED); return $select; } diff --git a/ProductVariantDataExporter/Test/Integration/ConfigurableProductVariantsTest.php b/ProductVariantDataExporter/Test/Integration/ConfigurableProductVariantsTest.php index 9ce22fd49..71d6caafd 100644 --- a/ProductVariantDataExporter/Test/Integration/ConfigurableProductVariantsTest.php +++ b/ProductVariantDataExporter/Test/Integration/ConfigurableProductVariantsTest.php @@ -10,6 +10,11 @@ use Magento\ProductVariantDataExporter\Model\Provider\ProductVariants\ConfigurableId; use RuntimeException; use Throwable; +use Magento\Catalog\Api\Data\ProductAttributeInterface; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product\Action; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\TestFramework\Helper\Bootstrap; /** * Test class for configurable product variants export @@ -146,15 +151,15 @@ public function testUnassignedChildFromConfigurableProductVariants(): void } /** - * Test that deleted flag is true when one of the children is disabled + * Test that product variant can be disabled and re-enabled * * @magentoDbIsolation disabled * @magentoAppIsolation enabled - * @magentoDataFixture Magento/ConfigurableProduct/_files/product_configurable_disable_first_child.php + * @magentoDataFixture Magento/ConfigurableProduct/_files/product_configurable_sku.php * * @return void */ - public function testWithDisabledChildFromConfigurableProductVariants(): void + public function testDisabledAndReanableChildFromConfigurableProductVariants(): void { try { $configurable = $this->productRepository->get('configurable'); @@ -171,6 +176,11 @@ public function testWithDisabledChildFromConfigurableProductVariants(): void ConfigurableId::CHILD_SKU_KEY => 'simple_20' ]); + // initiate feed state + $this->runIndexer([$configurableId, $simple10->getId()]); + + // disable variant && verify "deleted" is set to true + $this->changeProductStatusProduct('simple_10', Status::STATUS_DISABLED); $this->runIndexer([$configurableId, $simple10->getId()]); $variantsData = $this->getVariantByIds([$variantSimple10, $variantSimple20]); @@ -180,11 +190,42 @@ public function testWithDisabledChildFromConfigurableProductVariants(): void $this->assertEquals('simple_20', $variantsData[1]['productSku']); $this->assertFalse($variantsData[1]['deleted'], "simple_20 should not have been flag as deleted"); + + // enable variant && verify "deleted" is set to false + // verify enabling child product back works + $this->changeProductStatusProduct('simple_10', Status::STATUS_ENABLED); + $this->runIndexer([$configurableId, $simple10->getId()]); + + $variantsData = $this->getVariantByIds([$variantSimple10, $variantSimple20]); + $this->assertCount(2, $variantsData); //id20, id10 (re-enabled) + $this->assertEquals('simple_10', $variantsData[0]['productSku']); + $this->assertFalse($variantsData[0]['deleted'], "simple_10 should not have flag 'deleted'"); + } catch (Throwable $e) { $this->fail($e->getMessage()); } } + /** + * @param $childSku + * @param $status + * @return void + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + private function changeProductStatusProduct($childSku, $status) + { + $objectManager = Bootstrap::getObjectManager(); + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $objectManager->create(ProductRepositoryInterface::class); + $childProduct = $productRepository->get($childSku); + $productAction = Bootstrap::getObjectManager()->get(Action::class); + $productAction->updateAttributes( + [$childProduct->getEntityId()], + [ProductAttributeInterface::CODE_STATUS => $status], + $childProduct->getStoreId() + ); + } + /** * Returns variants by IDs * @@ -204,7 +245,6 @@ private function getVariantByIds(array $ids, bool $excludeDeleted = false): arra return $output; } - /** * Delete product variant * @@ -276,4 +316,23 @@ private function getExpectedProductVariants(array $simples): array ]; return array_values(array_intersect_key($variants, array_flip($simples))); } + + /** + * @return void + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->truncateIndexTable(); + } + + /** + * Truncates index table + */ + private function truncateIndexTable(): void + { + $connection = $this->resource->getConnection(); + $feedTable = $this->resource->getTableName($this->productVariantsFeed->getFeedMetadata()->getFeedTableName()); + $connection->truncateTable($feedTable); + } } diff --git a/README.md b/README.md index 29904c38c..ac91d7705 100644 --- a/README.md +++ b/README.md @@ -15,4 +15,74 @@ The export component is a set Magento modules and requires Magento 2.4.4 and hig Contributions are welcomed! Read the [Contributing Guide](./CONTRIBUTING.md) for more information. ### Licensing -This project is licensed under the OSL-3.0 License. See [LICENSE](./LICENSE.md) for more information. \ No newline at end of file +This project is licensed under the OSL-3.0 License. See [LICENSE](./LICENSE.md) for more information. + +## Export process +This extension allows to collect and export entity (called "feed") to consumer immediately after feed items have been collected. +Consumer must implement interface `Magento\DataExporter\Model\ExportFeedInterface::export` (see default implementation in [magento-commerce/saas-export](https://github.com/magento-commerce/saas-export)) + +Implementation of `ExportFeedInterface::export` must return status of operation `Magento\DataExporter\Model\FeedExportStatus` with the following statuses: +- `SUCCESS` - exported successfully +- `CLIENT_ERROR` - client can't process request +- `APPLICATION_ERROR` - something happened in side of Adobe Commerce configuration or processing +- `SERVER_ERROR` - happens when server can't process request + + +### Immediate export flow: +- collect entities during reindex or save action +- get entities that have to be deleted from feed (instead updating feed table with is_deleted=true) +- filter entities with identical hash (only if "export status in [SUCCESS, APPLICATION_ERROR]) +- submit entities to consumer via `ExportFeedInterface::export` and return status of submitted entities +- persist to feed table state of exported entities +- save record state status according to exporting result + +### Retry Logic for failed entities (only server error code): +- by cron check is any entities with status `SERVER_ERROR` or `APPLICATION_ERROR` in the feed table +- select entities with filter by modified_at && status = `SERVER_ERROR`, `APPLICATION_ERROR` +- partial reindex + +### Migration to immediate export approach: +- Add new columns (required for immediate feed processing) to db_schema of the feed table: + ```xml + + + +- di.xml changes (in case if virtual type is created for the `FeedIndexMetadata` type. Otherwise - add these arguments to real class): +-- Change the `exportImmediately` value to `true` for metadata configuration: + ```xml + true +- There is also an option for debugging purposes to keep saving whole data to the feed table with argument `persistExportedFeed` set to `true` +- Add `minimalPayload` argument with a minimal set of fields required by Feed Ingestion Service. Used to handle cases when feed item has been deleted. + for example: + ```xml + + sku + customerGroupCode + websiteCode + updatedAt + +- Add `feedIdentifierMapping` argument: describes the mapping between primary key columns in the feed table and corresponding fields in the feed item: + for example: + ```xml + + productId + websiteId + customerGroupCode + \ No newline at end of file diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php new file mode 100644 index 000000000..c5d3e8c2f --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductSearchTest.php @@ -0,0 +1,2682 @@ +objectManager = Bootstrap::getObjectManager(); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $this->eavConfig = $this->objectManager->get(eavConfig::class); + $this->getCategoryByName = $this->objectManager->get(GetCategoryByName::class); + $this->categoryCollection = $this->objectManager->get(Collection::class); + $this->indexer = $this->objectManager->get(Processor::class); + $this->categoryLinkManagement = $this->objectManager->get(CategoryLinkManagementInterface::class); + $this->categoryRepository = $this->objectManager->get(CategoryRepositoryInterface::class); + $this->config = $this->objectManager->get(Config::class); + $this->cache = $this->objectManager->get(Cache::class); + $this->fixture = DataFixtureStorageManager::getStorage(); + } + + /** + * Verify that filters for non-existing category are empty + * + * @throws \Exception + */ + public function testFilterForNonExistingCategory() + { + $query = <<graphQlQuery($query); + + $this->assertArrayHasKey( + 'filters', + $response['products'], + 'Filters are missing in product query result.' + ); + + $this->assertEmpty( + $response['products']['filters'], + 'Returned filters data set does not empty' + ); + } + + /** + * Verify that filters id and uid can't be used at the same time + */ + public function testUidAndIdUsageErrorOnProductFilteringCategory() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('`category_id` and `category_uid` can\'t be used at the same time'); + $query = <<graphQlQuery($query); + + $this->assertArrayHasKey( + 'filters', + $response['products'], + 'Filters are missing in product query result.' + ); + + $expectedFilters = $this->getExpectedFiltersDataSet(); + $actualFilters = $response['products']['filters']; + // presort expected and actual results as different search engines have different orders + usort($expectedFilters, [$this, 'compareFilterNames']); + usort($actualFilters, [$this, 'compareFilterNames']); + + $this->assertFilters( + ['products' => ['filters' => $actualFilters]], + $expectedFilters, + 'Returned filters data set does not match the expected value' + ); + } + + /** + * Compare arrays by value in 'name' field. + * + * @param array $a + * @param array $b + * @return int + */ + private function compareFilterNames(array $a, array $b) + { + return strcmp($a['name'], $b['name']); + } + + /** + * Layered navigation for Configurable products with out of stock options + * Two configurable products each having two variations and one of the child products + * of one Configurable set to OOS + * + * @magentoApiDataFixture Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php + * @magentoApiDataFixture Magento/Indexer/_files/reindex_all_invalid.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testLayeredNavigationForConfigurableProducts() + { + $attributeCode = 'test_configurable'; + $attribute = $this->eavConfig->getAttribute('catalog_product', $attributeCode); + /** @var AttributeOptionInterface[] $options */ + $options = $attribute->getOptions(); + array_shift($options); + $firstOption = $options[0]->getValue(); + $secondOption = $options[1]->getValue(); + $query = $this->getQueryProductsWithArrayOfCustomAttributes($attributeCode, $firstOption, $secondOption); + $response = $this->graphQlQuery($query); + + $this->assertEquals(2, $response['products']['total_count']); + $this->assertNotEmpty($response['products']['aggregations']); + $this->assertNotEmpty($response['products']['filters'], 'Filters is empty'); + $this->assertCount( + 2, + $response['products']['aggregations'], + 'Aggregation count does not match' + ); + + // Custom attribute filter layer data + $this->assertResponseFields( + $response['products']['aggregations'][1], + [ + 'attribute_code' => $attribute->getAttributeCode(), + 'label' => $attribute->getDefaultFrontendLabel(), + 'count' => 2, + 'position' => 0, + 'options' => [ + [ + 'label' => 'Option 1', + 'value' => $firstOption, + 'count' => '2' + ], + [ + 'label' => 'Option 2', + 'value' => $secondOption, + 'count' => '2' + ] + ], + ] + ); + } + + /** + * + * @return string + */ + private function getQueryProductsWithArrayOfCustomAttributes($attributeCode, $firstOption, $secondOption): string + { + return <<getDefaultAttributeOptionValue($attributeCode); + $query = <<productRepository->get('simple'); + $product2 = $this->productRepository->get('12345'); + $product3 = $this->productRepository->get('simple-4'); + $filteredProducts = [$product3, $product2, $product1]; + $countOfFilteredProducts = count($filteredProducts); + $response = $this->graphQlQuery($query); + $this->assertEquals( + 3, + $response['products']['total_count'], + 'Number of products returned is incorrect' + ); + $this->assertTrue( + count($response['products']['filters']) > 0, + 'Product filters is not empty' + ); + $this->assertCount( + 3, + $response['products']['aggregations'], + 'Incorrect count of aggregations' + ); + + $productItemsInResponse = array_map(null, $response['products']['items'], $filteredProducts); + for ($itemIndex = 0; $itemIndex < $countOfFilteredProducts; $itemIndex++) { + $this->assertNotEmpty($productItemsInResponse[$itemIndex]); + //validate that correct products are returned + $this->assertResponseFields( + $productItemsInResponse[$itemIndex][0], + [ + 'name' => $filteredProducts[$itemIndex]->getName(), + 'sku' => $filteredProducts[$itemIndex]->getSku() + ] + ); + } + + $attribute = $this->eavConfig->getAttribute('catalog_product', 'second_test_configurable'); + // Validate custom attribute filter layer data from aggregations + $this->assertResponseFields( + $response['products']['aggregations'][2], + [ + 'attribute_code' => $attribute->getAttributeCode(), + 'count' => 1, + 'label' => $attribute->getDefaultFrontendLabel(), + 'position' => $attribute->getPosition(), + 'options' => [ + [ + 'label' => 'Option 3', + 'count' => 3, + 'value' => $optionValue + ], + ], + ] + ); + } + + /** + * Filter products using an array of multi select custom attributes + * + * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_with_multiselect_attribute.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testFilterProductsByMultiSelectCustomAttributes() + { + $attributeCode = 'multiselect_attribute'; + $attribute = $this->eavConfig->getAttribute('catalog_product', $attributeCode); + /** @var AttributeOptionInterface[] $options */ + $options = $attribute->getOptions(); + array_shift($options); + $countOptions = count($options); + $optionValues = []; + for ($i = 0; $i < $countOptions; $i++) { + $optionValues[] = $options[$i]->getValue(); + } + $query = <<graphQlQuery($query); + $this->assertArrayNotHasKey('errors', $response, 'Response has errors.'); + $this->assertEquals(3, $response['products']['total_count']); + $this->assertNotEmpty($response['products']['filters']); + $this->assertNotEmpty($response['products']['aggregations']); + $this->assertCount(2, $response['products']['aggregations']); + } + + /** + * Get the option value for the custom attribute to be used in the graphql query + * + * @param string $attributeCode + * @return string + */ + private function getDefaultAttributeOptionValue(string $attributeCode): string + { + $attribute = $this->eavConfig->getAttribute('catalog_product', $attributeCode); + /** @var AttributeOptionInterface[] $options */ + $options = $attribute->getOptions(); + array_shift($options); + $defaultOptionValue = $options[0]->getValue(); + return $defaultOptionValue; + } + + /** + * Full text search for Products and then filter the results by custom attribute (default sort is relevance) + * + * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php + * @magentoApiDataFixture Magento/Indexer/_files/reindex_all_invalid.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testSearchAndFilterByCustomAttribute() + { + $attribute_code = 'second_test_configurable'; + $optionValue = $this->getDefaultAttributeOptionValue($attribute_code); + + $query = <<graphQlQuery($query); + //Verify total count of the products returned + $this->assertEquals(3, $response['products']['total_count']); + $this->assertArrayHasKey('filters', $response['products']); + $this->assertCount(3, $response['products']['aggregations']); + $expectedFilterLayers = + [ + [ + 'name' => 'Category', + 'request_var' => 'cat' + ], + [ + 'name' => 'Second Test Configurable', + 'request_var' => 'second_test_configurable' + ] + ]; + $layers = array_map(null, $expectedFilterLayers, $response['products']['filters']); + + //Verify all the three layers from filters : Price, Category and Custom attribute layers + foreach ($layers as $layerIndex => $layerFilterData) { + $this->assertNotEmpty($layerFilterData); + $this->assertEquals( + $layers[$layerIndex][0]['name'], + $response['products']['filters'][$layerIndex]['name'], + 'Layer name does not match' + ); + $this->assertEquals( + $layers[$layerIndex][0]['request_var'], + $response['products']['filters'][$layerIndex]['request_var'], + 'request_var does not match' + ); + } + + // Validate the price layer of aggregations from the response + $this->assertResponseFields( + $response['products']['aggregations'][0], + [ + 'attribute_code' => 'price', + 'count' => 2, + 'label' => 'Price', + 'options' => [ + [ + 'count' => 2, + 'label' => '10-20', + 'value' => '10_20', + + ], + [ + 'count' => 1, + 'label' => '40-50', + 'value' => '40_50', + + ], + ], + ] + ); + // Validate the custom attribute layer of aggregations from the response + $this->assertResponseFields( + $response['products']['aggregations'][2], + [ + 'attribute_code' => $attribute_code, + 'count' => 1, + 'label' => 'Second Test Configurable', + 'position' => 1, + 'options' => [ + [ + 'count' => 3, + 'label' => 'Option 3', + 'value' => $optionValue, + + ] + + ], + ] + ); + // 7 categories including the subcategories to which the items belong to , are returned + $this->assertCount(7, $response['products']['aggregations'][1]['options']); + unset($response['products']['aggregations'][1]['options']); + $this->assertResponseFields( + $response['products']['aggregations'][1], + [ + 'attribute_code' => 'category_uid', + 'count' => 7, + 'label' => 'Category' + ] + ); + } + + /** + * Filter by category and custom attribute + * + * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_custom_attribute.php + * @magentoApiDataFixture Magento/Indexer/_files/reindex_all_invalid.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testFilterByCategoryIdAndCustomAttribute() + { + $category = $this->getCategoryByName->execute('Category 1.2'); + $optionValue = $this->getDefaultAttributeOptionValue('second_test_configurable'); + $categoryUid = base64_encode($category->getId()); + $query = <<graphQlQuery($query); + $this->assertEquals(2, $response['products']['total_count']); + $product1 = $this->productRepository->get('simple'); + $product2 = $this->productRepository->get('simple-4'); + $filteredProducts = [$product2, $product1]; + $productItemsInResponse = array_map(null, $response['products']['items'], $filteredProducts); + //phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall + for ($itemIndex = 0; $itemIndex < count($filteredProducts); $itemIndex++) { + $this->assertNotEmpty($productItemsInResponse[$itemIndex]); + //validate that correct products are returned + $this->assertResponseFields( + $productItemsInResponse[$itemIndex][0], + [ + 'name' => $filteredProducts[$itemIndex]->getName(), + 'sku' => $filteredProducts[$itemIndex]->getSku() + ] + ); + } + $this->assertNotEmpty($response['products']['filters'], 'filters is empty'); + $this->assertNotEmpty($response['products']['aggregations'], 'Aggregations should not be empty'); + $this->assertCount(3, $response['products']['aggregations']); + + $actualCategoriesFromResponse = $response['products']['aggregations'][1]['options']; + + //Validate the number of categories/sub-categories that contain the products with the custom attribute + $this->assertCount(6, $actualCategoriesFromResponse); + + $expectedCategoryInAggregations = + [ + [ + 'count' => 2, + 'label' => 'Category 1', + 'value' => '3' + ], + [ + 'count' => 1, + 'label' => 'Category 1.1', + 'value' => '4' + + ], + [ + 'count' => 1, + 'label' => 'Movable Position 2', + 'value' => '10' + + ], + [ + 'count' => 1, + 'label' => 'Movable Position 3', + 'value' => '11' + ], + [ + 'count' => 1, + 'label' => 'Category 12', + 'value' => '12' + + ], + [ + 'count' => 2, + 'label' => 'Category 1.2', + 'value' => '13' + ], + ]; + // presort expected and actual results as different search engines have different orders + usort($expectedCategoryInAggregations, [$this, 'compareLabels']); + usort($actualCategoriesFromResponse, [$this, 'compareLabels']); + $categoryInAggregations = array_map( + null, + $expectedCategoryInAggregations, + $actualCategoriesFromResponse + ); + + //Validate the categories and sub-categories data in the filter layer + foreach ($categoryInAggregations as $index => $categoryAggregationsData) { + $this->assertNotEmpty($categoryAggregationsData); + $this->assertEquals( + $categoryInAggregations[$index][0]['label'], + $actualCategoriesFromResponse[$index]['label'], + 'Category is incorrect' + ); + $this->assertEquals( + $categoryInAggregations[$index][0]['count'], + $actualCategoriesFromResponse[$index]['count'], + 'Products count in the category is incorrect' + ); + } + } + + /** + * Compare arrays by value in 'label' field. + * + * @param array $a + * @param array $b + * @return int + */ + private function compareLabels(array $a, array $b) + { + return strcmp($a['label'], $b['label']); + } + + /** + * Filter by exact match of product url key + * + * @magentoApiDataFixture Magento/Catalog/_files/categories.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testFilterBySingleProductUrlKey() + { + /** @var Product $product */ + $product = $this->productRepository->get('simple-4'); + $urlKey = $product->getUrlKey(); + + $query = <<graphQlQuery($query); + $this->assertEquals(1, $response['products']['total_count'], 'More than 1 product found'); + $this->assertCount(2, $response['products']['aggregations']); + $this->assertResponseFields( + $response['products']['items'][0], + [ + 'name' => $product->getName(), + 'sku' => $product->getSku(), + 'url_key' => $product->getUrlKey() + ] + ); + $this->assertEquals('Price', $response['products']['aggregations'][0]['label']); + $this->assertEquals('Category', $response['products']['aggregations'][1]['label']); + //Disable the product + $product->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_DISABLED); + $this->productRepository->save($product); + $query2 = <<graphQlQuery($query2); + $this->assertEquals(0, $response['products']['total_count'], 'Total count should be zero'); + $this->assertEmpty($response['products']['items']); + $this->assertEmpty($response['products']['aggregations']); + } + + /** + * Filter by multiple product url keys + * + * @magentoApiDataFixture Magento/Catalog/_files/categories.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testFilterByMultipleProductUrlKeys() + { + /** @var Product $product */ + $product1 = $this->productRepository->get('simple'); + $product2 = $this->productRepository->get('12345'); + $product3 = $this->productRepository->get('simple-4'); + $filteredProducts = [$product3, $product2, $product1]; + $urlKey = []; + foreach ($filteredProducts as $product) { + $urlKey[] = $product->getUrlKey(); + } + + $query = <<graphQlQuery($query); + $this->assertEquals(3, $response['products']['total_count'], 'Total count is incorrect'); + $this->assertCount(2, $response['products']['aggregations']); + + $productItemsInResponse = array_map(null, $response['products']['items'], $filteredProducts); + //phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall + for ($itemIndex = 0; $itemIndex < count($filteredProducts); $itemIndex++) { + $this->assertNotEmpty($productItemsInResponse[$itemIndex]); + //validate that correct products are returned + $this->assertResponseFields( + $productItemsInResponse[$itemIndex][0], + [ + 'name' => $filteredProducts[$itemIndex]->getName(), + 'sku' => $filteredProducts[$itemIndex]->getSku(), + 'url_key' => $filteredProducts[$itemIndex]->getUrlKey() + ] + ); + } + } + + /** + * Get array with expected data for layered navigation filters + * + * @return array + */ + private function getExpectedFiltersDataSet() + { + $attribute = $this->eavConfig->getAttribute('catalog_product', 'test_configurable'); + /** @var \Magento\Eav\Api\Data\AttributeOptionInterface[] $options */ + $options = $attribute->getOptions(); + // Fetching option ID is required for continuous debug as of autoincrement IDs. + return [ + [ + 'name' => 'Category', + 'filter_items_count' => 1, + 'request_var' => 'cat', + 'filter_items' => [ + [ + 'label' => 'Category 1', + 'value_string' => '333', + 'items_count' => 3, + ], + ], + ], + [ + 'name' => 'Test Configurable', + 'filter_items_count' => 1, + 'request_var' => 'test_configurable', + 'filter_items' => [ + [ + 'label' => 'Option 1', + 'value_string' => $options[1]->getValue(), + 'items_count' => 1, + ], + ], + ], + [ + 'name' => 'Price', + 'filter_items_count' => 2, + 'request_var' => 'price', + 'filter_items' => [ + [ + 'label' => '$0.00 - $9.99', + 'value_string' => '-10', + 'items_count' => 1, + ], + [ + 'label' => '$10.00 and above', + 'value_string' => '10-', + 'items_count' => 1, + ], + ], + ], + ]; + } + + /** + * Assert filters data. + * + * @param array $response + * @param array $expectedFilters + * @param string $message + */ + private function assertFilters($response, $expectedFilters, $message = '') + { + $this->assertArrayHasKey('filters', $response['products'], 'Product has filters'); + $this->assertIsArray(($response['products']['filters']), 'Product filters is not array'); + $this->assertTrue(count($response['products']['filters']) > 0, 'Product filters is empty'); + foreach ($expectedFilters as $expectedFilter) { + $found = false; + foreach ($response['products']['filters'] as $responseFilter) { + if ($responseFilter['name'] == $expectedFilter['name'] + && $responseFilter['request_var'] == $expectedFilter['request_var']) { + $found = true; + break; + } + } + if (!$found) { + $this->fail($message); + } + } + } + + /** + * Verify product filtering using price range AND matching skus AND name sorted in DESC order + * + * @magentoApiDataFixture Magento/Catalog/_files/category.php + * @magentoApiDataFixture Magento/Catalog/_files/multiple_products.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testFilterWithinSpecificPriceRangeSortedByNameDesc() + { + $query + = <<productRepository->get('simple1'); + $product2 = $this->productRepository->get('simple2'); + $filteredProducts = [$product2, $product1]; + + $response = $this->graphQlQuery($query); + $this->assertArrayHasKey('products', $response); + $this->assertArrayHasKey('total_count', $response['products']); + $this->assertProductItems($filteredProducts, $response); + $this->assertEquals(4, $response['products']['page_info']['page_size']); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/category_with_three_products.php + * @SuppressWarnings(PHPMD.UnusedLocalVariable) + */ + public function testSortByPosition() + { + // Get category ID for filtering + $category = $this->categoryCollection->addFieldToFilter( + 'name', + 'Category 999' + )->getFirstItem(); + $categoryId = $category->getId(); + + $queryAsc = <<graphQlQuery($queryAsc); + $this->assertArrayNotHasKey('errors', $resultAsc); + $productsAsc = array_column($resultAsc['products']['items'], 'sku'); + $expectedProductsAsc = ['simple1002', 'simple1001', 'simple1000']; + // position equal and secondary sort by entity_id DESC + $this->assertEquals($expectedProductsAsc, $productsAsc); + + $queryDesc = <<graphQlQuery($queryDesc); + $this->assertArrayNotHasKey('errors', $resultDesc); + $productsDesc = array_column($resultDesc['products']['items'], 'sku'); + // position equal and secondary sort by entity_id DESC + $this->assertEquals($expectedProductsAsc, $productsDesc); + + //revert position + $productPositions = $category->getProductsPosition(); + $count = 1; + foreach ($productPositions as $productId => $position) { + $productPositions[$productId] = $count; + $count++; + } + ksort($productPositions); + + $category->setPostedProducts($productPositions); + $category->save(); + + // Reindex products from the result to invalidate query cache. + $this->indexer->reindexList(array_keys($productPositions)); + + $queryDesc = <<graphQlQuery($queryDesc); + $this->assertArrayNotHasKey('errors', $resultDesc); + $productsDesc = array_column($resultDesc['products']['items'], 'sku'); + // position NOT equal and oldest entity first + $this->assertEquals(array_reverse($expectedProductsAsc), $productsDesc); + } + + /** + * @dataProvider sortByPositionWithMultipleCategoriesDataProvider + */ + #[ + DataFixture(ProductFixture::class, as: 'prod1'), + DataFixture(ProductFixture::class, as: 'prod2'), + DataFixture(ProductFixture::class, as: 'prod3'), + DataFixture(ProductFixture::class, as: 'prod4'), + DataFixture(ProductFixture::class, as: 'prod5'), + DataFixture(ProductFixture::class, as: 'prod6'), + DataFixture(ProductFixture::class, as: 'prod7'), + DataFixture(ProductFixture::class, as: 'prod8'), + DataFixture(ProductFixture::class, as: 'prod9'), + DataFixture(CategoryFixture::class, as: 'cat1'), + DataFixture(CategoryFixture::class, ['parent_id' => '$cat1.id$'], 'cat11'), + DataFixture(CategoryFixture::class, ['parent_id' => '$cat1.id$'], 'cat12'), + DataFixture(CategoryFixture::class, ['parent_id' => '$cat1.id$'], 'cat13'), + ] + public function testSortByPositionWithMultipleCategories( + array $config, + array $filterBy, + array $expectedOrder + ): void { + $expectedOrderSku = []; + $categoryIds = []; + + foreach ($expectedOrder as $productName) { + $expectedOrderSku[] = $this->fixture->get($productName)->getSku(); + } + + foreach ($filterBy as $categoryName) { + $categoryIds[] = $this->fixture->get($categoryName)->getId(); + } + $filter = json_encode($categoryIds); + + foreach ($config as $categoryName => $products) { + $categoryId = $this->fixture->get($categoryName)->getId(); + $category = $this->categoryRepository->get($categoryId); + $productPositions = []; + foreach ($products as $position => $productName) { + $product = $this->fixture->get($productName); + $productPositions[$product->getId()] = $position; + } + $category->setPostedProducts($productPositions); + $category->save(); + } + + $this->indexer->reindexAll(); + + $query = <<graphQlQuery($query); + $this->assertArrayNotHasKey('errors', $resultDesc); + $this->assertEquals($expectedOrderSku, array_column($resultDesc['products']['items'], 'sku')); + } + + /** + * @return array + */ + public function sortByPositionWithMultipleCategoriesDataProvider(): array + { + return [ + [ + [ + 'cat11' => ['prod9', 'prod8', 'prod7'], + 'cat12' => ['prod2', 'prod5', 'prod3', 'prod4', 'prod1', 'prod8'], + 'cat13' => ['prod1', 'prod4', 'prod9', 'prod6'], + ], + [ + 'cat11', 'cat12' + ], + ['prod9', 'prod2', 'prod8', 'prod5', 'prod7', 'prod3', 'prod4', 'prod1'] + ], + [ + [ + 'cat11' => ['prod9', 'prod8', 'prod7'], + 'cat12' => ['prod2', 'prod5', 'prod3', 'prod4', 'prod1', 'prod8'], + 'cat13' => ['prod1', 'prod4', 'prod9', 'prod6'], + ], + [ + 'cat11', 'cat12', 'cat13' + ], + ['prod9', 'prod2', 'prod1', 'prod8', 'prod5', 'prod4', 'prod7', 'prod3', 'prod6'] + ], + [ + [ + 'cat11' => ['prod9', 'prod8', 'prod7'], + 'cat12' => ['prod2', 'prod5', 'prod3', 'prod4', 'prod1', 'prod8'], + 'cat13' => ['prod1', 'prod4', 'prod9', 'prod6'], + ], + [ + 'cat1' + ], + ['prod9', 'prod2', 'prod1', 'prod8', 'prod5', 'prod4', 'prod7', 'prod3', 'prod6'] + ], + ]; + } + + /** + * Test products with the same relevance reverse position with ASC and DESC sorting + * + * @magentoApiDataFixture Magento/Catalog/_files/category_with_three_products.php + */ + public function testSortByEqualRelevanceAndAscDescReversePosition() + { + $category = $this->categoryCollection->addFieldToFilter( + 'name', + 'Category 999' + )->getFirstItem(); + $categoryId = (int) $category->getId(); + + $expectedProductsAsc = ['simple1000', 'simple1001', 'simple1002']; + $queryAsc = $this->getCategoryFilterRelevanceQuery($categoryId, 'ASC'); + $resultAsc = $this->graphQlQuery($queryAsc); + $this->assertArrayNotHasKey('errors', $resultAsc); + $productsAsc = array_column($resultAsc['products']['items'], 'sku'); + $this->assertEquals($expectedProductsAsc, $productsAsc); + + $expectedProductsDesc = array_reverse($expectedProductsAsc); + $queryDesc = $this->getCategoryFilterRelevanceQuery($categoryId, 'DESC'); + $resultDesc = $this->graphQlQuery($queryDesc); + $this->assertArrayNotHasKey('errors', $resultDesc); + $productsDesc = array_column($resultDesc['products']['items'], 'sku'); + $this->assertEquals($expectedProductsDesc, $productsDesc); + } + + /** + * Query for category filter relevance + * + * @param int $categoryId + * @param string $direction + * @return string + */ + protected function getCategoryFilterRelevanceQuery(int $categoryId, string $direction): string + { + $query = <<expectException(\Exception::class); + $this->expectExceptionMessage( + 'GraphQL response contains errors: currentPage value 2 specified is greater ' . + 'than the 1 page(s) available' + ); + $this->graphQlQuery($query); + } + + /** + * Filtering for products and sorting using multiple sort parameters + * + * @magentoApiDataFixture Magento/Catalog/_files/category.php + * @magentoApiDataFixture Magento/Catalog/_files/multiple_mixed_products_2.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testFilterByMultipleFilterFieldsSortedByMultipleSortFields() + { + $query + = <<productRepository->get('simple1'); + $childProduct2 = $this->productRepository->get('simple2'); + $filteredChildProducts = [$childProduct1, $childProduct2]; + + $response = $this->graphQlQuery($query); + $this->assertArrayHasKey('products', $response); + $this->assertArrayHasKey('total_count', $response['products']); + $this->assertEquals(2, $response['products']['total_count']); + $this->assertProductItems($filteredChildProducts, $response); + $this->assertEquals(4, $response['products']['page_info']['page_size']); + $this->assertEquals(1, $response['products']['page_info']['current_page']); + $this->assertArrayHasKey('sort_fields', $response['products']); + $this->assertArrayHasKey('options', $response['products']['sort_fields']); + $this->assertArrayHasKey('default', $response['products']['sort_fields']); + $this->assertEquals('position', $response['products']['sort_fields']['default']); + $this->assertArrayHasKey('value', $response['products']['sort_fields']['options'][0]); + $this->assertArrayHasKey('label', $response['products']['sort_fields']['options'][0]); + $this->assertEquals('position', $response['products']['sort_fields']['options'][0]['value']); + } + + /** + * Filtering products by fuzzy name match + * + * @magentoApiDataFixture Magento/Catalog/_files/products_for_relevance_sorting.php + */ + public function testFilterProductsForExactMatchingName() + { + $query + = <<productRepository->get('grey_shorts'); + $product2 = $this->productRepository->get('white_shorts'); + $response = $this->graphQlQuery($query); + $this->assertEquals(2, $response['products']['total_count']); + $this->assertEquals(['page_size' => 2, 'current_page' => 1], $response['products']['page_info']); + $this->assertEquals( + [ + ['sku' => $product1->getSku(), 'name' => $product1->getName()], + ['sku' => $product2->getSku(), 'name' => $product2->getName()] + ], + $response['products']['items'] + ); + $this->assertArrayHasKey('aggregations', $response['products']); + $this->assertCount(2, $response['products']['aggregations']); + $expectedAggregations = [ + [ + 'attribute_code' => 'price', + 'count' => 2, + 'label' => 'Price', + 'options' => [ + [ + 'label' => '10-20', + 'value' => '10_20', + 'count' => 1, + ], + [ + 'label' => '20-30', + 'value' => '20_30', + 'count' => 1, + ] + ] + ], + [ + 'attribute_code' => 'category_uid', + 'count' => 1, + 'label' => 'Category', + 'options' => [ + [ + 'label' => 'Colorful Category', + 'value' => 'MzMw', + 'count' => 2, + ], + ], + ] + ]; + $this->assertEquals($expectedAggregations, $response['products']['aggregations']); + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/categories.php + */ + public function testFilteringForProductsFromMultipleCategories() + { + $categoriesIds = ["4","5","12"]; + $query + = <<graphQlQuery($query); + $this->assertEquals(3, $response['products']['total_count']); + $actualProducts = []; + foreach ($categoriesIds as $categoriesId) { + $links = $this->categoryLinkManagement->getAssignedProducts($categoriesId); + $links = array_reverse($links); + foreach ($links as $linkProduct) { + $product = $this->productRepository->get($linkProduct->getSku()); + $actualProducts[$linkProduct->getSku()] = $product->getName(); + } + } + $expectedProducts = array_column($response['products']['items'], "name", "sku"); + $this->assertEquals($expectedProducts, $actualProducts); + } + + /** + * Filter products by single category + * + * @magentoApiDataFixture Magento/Catalog/_files/product_in_multiple_categories.php + * @return void + * @dataProvider filterProductsBySingleCategoryIdDataProvider + */ + public function testFilterProductsBySingleCategoryId(string $fieldName, string $queryCategoryId) + { + $this->markTestSkipped('Skip this test as it fails with commerce data export extension'); + } + + /** + * Sorting the search results by relevance (DESC => most relevant) + * + * Sorting by relevance may return different results depending on the ES. + * To check that sorting works, we compare results with ASC and DESC relevance sorting + * + * Search for products for a fuzzy match and checks if all matching results returned including + * results based on matching keywords from description + * + * @magentoApiDataFixture Magento/Catalog/_files/products_for_relevance_sorting.php + * @return void + * + * @throws \Exception + */ + public function testSearchAndSortByRelevance() + { + $search_term = "blue"; + $query + = <<graphQlQuery(sprintf($query, 'DESC')); + $responseAsc = $this->graphQlQuery(sprintf($query, 'ASC')); + $this->assertEquals(3, $responseDesc['products']['total_count']); + $this->assertNotEmpty($responseDesc['products']['filters'], 'Filters should have the Category layer'); + $this->assertEquals( + 'Colorful Category', + $responseDesc['products']['filters'][0]['filter_items'][0]['label'] + ); + $this->assertCount(2, $responseDesc['products']['aggregations']); + $expectedProductsInResponse = ['Blue briefs', 'Navy Blue Striped Shoes', 'Grey shorts']; + $namesDesc = array_column($responseDesc['products']['items'], 'name'); + $this->assertEqualsCanonicalizing($expectedProductsInResponse, $namesDesc); + $this->assertEquals( + $namesDesc, + array_reverse(array_column($responseAsc['products']['items'], 'name')) + ); + } + + /** + * Filtering for product with sku "equals" a specific value + * If pageSize and current page are not requested, default values are returned + * + * @magentoApiDataFixture Magento/Catalog/_files/category.php + * @magentoApiDataFixture Magento/Catalog/_files/multiple_mixed_products_2.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testFilterByExactSkuAndSortByPriceDesc() + { + $query + = <<productRepository->get('simple1'); + + $filteredProducts = [$visibleProduct1]; + $response = $this->graphQlQuery($query); + $this->assertEquals(1, $response['products']['total_count']); + $this->assertProductItems($filteredProducts, $response); + $this->assertEquals(20, $response['products']['page_info']['page_size']); + $this->assertEquals(1, $response['products']['page_info']['current_page']); + } + + /** + * Fuzzy search filtered for price and sorted by price and name + * + * @magentoApiDataFixture Magento/Catalog/_files/products_for_relevance_sorting.php + */ + public function testProductBasicFullTextSearchQuery() + { + $textToSearch = 'blue'; + $query + = <<productRepository->get('blue_briefs'); + $prod2 = $this->productRepository->get('grey_shorts'); + $prod3 = $this->productRepository->get('navy-striped-shoes'); + $response = $this->graphQlQuery($query); + $this->assertEquals(3, $response['products']['total_count']); + + $filteredProducts = [$prod1, $prod2, $prod3]; + $productItemsInResponse = array_map(null, $response['products']['items'], $filteredProducts); + foreach ($productItemsInResponse as $itemIndex => $itemArray) { + $this->assertNotEmpty($itemArray); + $this->assertResponseFields( + $productItemsInResponse[$itemIndex][0], + [ + 'sku' => $filteredProducts[$itemIndex]->getSku(), + 'name' => $filteredProducts[$itemIndex]->getName(), + 'price' => [ + 'minimalPrice' => [ + 'amount' => [ + 'value' => $filteredProducts[$itemIndex]->getPrice(), + 'currency' => 'USD' + ] + ] + ] + ] + ); + } + } + + /** + * Partial search filtered for price and sorted by price and name + * + * @magentoApiDataFixture Magento/Catalog/_files/category.php + * @magentoApiDataFixture Magento/Catalog/_files/multiple_products.php + */ + public function testProductPartialNameFullTextSearchQuery() + { + $textToSearch = 'Sim'; + $query + = <<productRepository->get('simple1'); + $prod2 = $this->productRepository->get('simple2'); + $response = $this->graphQlQuery($query); + $this->assertEquals(2, $response['products']['total_count']); + + $filteredProducts = [$prod1, $prod2]; + $productItemsInResponse = array_map(null, $response['products']['items'], $filteredProducts); + foreach ($productItemsInResponse as $itemIndex => $itemArray) { + $this->assertNotEmpty($itemArray); + $this->assertResponseFields( + $productItemsInResponse[$itemIndex][0], + [ + 'sku' => $filteredProducts[$itemIndex]->getSku(), + 'name' => $filteredProducts[$itemIndex]->getName(), + 'price' => [ + 'minimalPrice' => [ + 'amount' => [ + 'value' => $filteredProducts[$itemIndex]->getSpecialPrice(), + 'currency' => 'USD' + ] + ] + ] + ] + ); + } + } + + /** + * Partial search on sku filtered for price and sorted by price and sku + * + * @magentoApiDataFixture Magento/Catalog/_files/category.php + * @magentoApiDataFixture Magento/Catalog/_files/multiple_products_with_different_sku_and_name.php + */ + public function testProductPartialSkuFullTextSearchQuery() + { + $textToSearch = 'prd'; + $query + = <<productRepository->get('prd1sku'); + $prod2 = $this->productRepository->get('prd2-sku2'); + $response = $this->graphQlQuery($query); + $this->assertEquals(2, $response['products']['total_count']); + + $filteredProducts = [$prod1, $prod2]; + $productItemsInResponse = array_map(null, $response['products']['items'], $filteredProducts); + foreach ($productItemsInResponse as $itemIndex => $itemArray) { + $this->assertNotEmpty($itemArray); + $this->assertResponseFields( + $productItemsInResponse[$itemIndex][0], + [ + 'sku' => $filteredProducts[$itemIndex]->getSku(), + 'name' => $filteredProducts[$itemIndex]->getName(), + 'price' => [ + 'minimalPrice' => [ + 'amount' => [ + 'value' => $filteredProducts[$itemIndex]->getSpecialPrice(), + 'currency' => 'USD' + ] + ] + ] + ] + ); + } + } + + /** + * Partial search on hyphenated sku filtered for price and sorted by price and sku + * + * @magentoApiDataFixture Magento/Catalog/_files/category.php + * @magentoApiDataFixture Magento/Catalog/_files/multiple_products_with_different_sku_and_name.php + */ + public function testProductPartialSkuHyphenatedFullTextSearchQuery() + { + $prod2 = $this->productRepository->get('prd2-sku2'); + $textToSearch = 'sku2'; + $query + = <<graphQlQuery($query); + $this->assertEquals(1, $response['products']['total_count']); + + $filteredProducts = [$prod2]; + $productItemsInResponse = array_map(null, $response['products']['items'], $filteredProducts); + foreach ($productItemsInResponse as $itemIndex => $itemArray) { + $this->assertNotEmpty($itemArray); + $this->assertResponseFields( + $productItemsInResponse[$itemIndex][0], + [ + 'sku' => $filteredProducts[$itemIndex]->getSku(), + 'name' => $filteredProducts[$itemIndex]->getName(), + 'price' => [ + 'minimalPrice' => [ + 'amount' => [ + 'value' => $filteredProducts[$itemIndex]->getSpecialPrice(), + 'currency' => 'USD' + ] + ] + ] + ] + ); + } + } + + /** + * Filter products purely in a given price range + * + * @magentoApiDataFixture Magento/Catalog/_files/category.php + * @magentoApiDataFixture Magento/Catalog/_files/multiple_mixed_products_2.php + */ + public function testFilterWithinASpecificPriceRangeSortedByPriceDESC() + { + $prod1 = $this->productRepository->get('simple1'); + $prod2 = $this->productRepository->get('simple2'); + $filteredProducts = [$prod1, $prod2]; + /** @var \Magento\Catalog\Api\CategoryLinkManagementInterface $categoryLinkManagement */ + foreach ($filteredProducts as $product) { + $this->categoryLinkManagement->assignProductToCategories( + $product->getSku(), + [333] + ); + } + + $query + = <<graphQlQuery($query); + $this->assertEquals(2, $response['products']['total_count']); + $this->assertProductItemsWithPriceCheck($filteredProducts, $response); + //verify that by default Price and category are the only layers available + $filterNames = ['Category', 'Price']; + $this->assertCount(2, $response['products']['filters'], 'Filter count does not match'); + $productCount = count($response['products']['filters']); + for ($i = 0; $i < $productCount; $i++) { + $this->assertEquals($filterNames[$i], $response['products']['filters'][$i]['name']); + } + } + + /** + * No items are returned if the conditions are not met + * + * @magentoApiDataFixture Magento/Catalog/_files/category.php + * @magentoApiDataFixture Magento/Catalog/_files/multiple_mixed_products_2.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testQueryFilterNoMatchingItems() + { + $query + = <<graphQlQuery($query); + $this->assertEquals(0, $response['products']['total_count']); + $this->assertEmpty($response['products']['items'], "No items should be returned."); + } + + /** + * Asserts that exception is thrown when current page > totalCount of items returned + * + * @magentoApiDataFixture Magento/Catalog/_files/category.php + * @magentoApiDataFixture Magento/Catalog/_files/multiple_mixed_products_2.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testQueryPageOutOfBoundException() + { + $query + = <<expectException(\Exception::class); + $this->expectExceptionMessage( + 'GraphQL response contains errors: currentPage value 2 specified is greater ' . + 'than the 1 page(s) available.' + ); + $this->graphQlQuery($query); + } + + /** + * No filter or search arguments used + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testQueryWithNoSearchOrFilterArgumentException() + { + $query + = <<expectException(\Exception::class); + $this->expectExceptionMessage( + 'GraphQL response contains errors: \'search\' or \'filter\' input argument is ' . + 'required.' + ); + $this->graphQlQuery($query); + } + + /** + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @magentoApiDataFixture Magento/Catalog/_files/multiple_products_with_few_out_of_stock.php + */ + public function testFilterProductsThatAreOutOfStockWithConfigSettings() + { + $query + = <<config->saveConfig( + \Magento\CatalogInventory\Model\Configuration::XML_PATH_SHOW_OUT_OF_STOCK, + 0, + ScopeConfigInterface::SCOPE_TYPE_DEFAULT, + 0 + ); + $this->cache->clean(\Magento\Framework\App\Config::CACHE_TAG); + $response = $this->graphQlQuery($query); + $responseObject = new DataObject($response); + self::assertEquals( + 'simple_visible_in_stock', + $responseObject->getData('products/items/0/sku') + ); + self::assertEquals( + 'Simple Product Visible and InStock', + $responseObject->getData('products/items/0/name') + ); + $this->assertEquals(1, $response['products']['total_count']); + } + + /** + * Verify that invalid current page return an error + * + * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_attribute.php + */ + public function testInvalidCurrentPage() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('currentPage value must be greater than 0'); + + $query = <<graphQlQuery($query); + } + + /** + * Verify that invalid page size returns an error. + * + * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_attribute.php + */ + public function testInvalidPageSize() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('pageSize value must be greater than 0'); + + $query = <<graphQlQuery($query); + } + + /** + * Asserts the different fields of items returned after search query is executed + * + * @param Product[] $filteredProducts + * @param array $actualResponse + */ + private function assertProductItems(array $filteredProducts, array $actualResponse) + { + $productItemsInResponse = array_map(null, $actualResponse['products']['items'], $filteredProducts); + $count = count($filteredProducts); + for ($itemIndex = 0; $itemIndex < $count; $itemIndex++) { + $this->assertNotEmpty($productItemsInResponse[$itemIndex]); + $this->assertResponseFields( + $productItemsInResponse[$itemIndex][0], + [ + 'sku' => $filteredProducts[$itemIndex]->getSku(), + 'name' => $filteredProducts[$itemIndex]->getName(), + 'price' => [ + 'minimalPrice' => [ + 'amount' => [ + 'value' => $filteredProducts[$itemIndex]->getFinalPrice(), + 'currency' => 'USD' + ] + ] + ], + 'type_id' => $filteredProducts[$itemIndex]->getTypeId(), + 'weight' => $filteredProducts[$itemIndex]->getWeight() + ] + ); + } + } + + private function assertProductItemsWithPriceCheck(array $filteredProducts, array $actualResponse) + { + $productItemsInResponse = array_map(null, $actualResponse['products']['items'], $filteredProducts); + + foreach ($productItemsInResponse as $itemIndex => $itemArray) { + $this->assertNotEmpty($itemArray); + $this->assertResponseFields( + $productItemsInResponse[$itemIndex][0], + [ + 'sku' => $filteredProducts[$itemIndex]->getSku(), + 'name' => $filteredProducts[$itemIndex]->getName(), + 'price' => [ + 'minimalPrice' => [ + 'amount' => [ + 'value' => $filteredProducts[$itemIndex]->getSpecialPrice(), + 'currency' => 'USD' + ] + ], + 'maximalPrice' => [ + 'amount' => [ + 'value' => $filteredProducts[$itemIndex]->getSpecialPrice(), + 'currency' => 'USD' + ] + ], + 'regularPrice' => [ + 'amount' => [ + 'value' => $filteredProducts[$itemIndex]->getPrice(), + 'currency' => 'USD' + ] + ] + + ], + 'type_id' => $filteredProducts[$itemIndex]->getTypeId(), + 'weight' => $filteredProducts[$itemIndex]->getWeight() + ] + ); + } + } + + /** + * Data provider for product single category filtering + * + * @return array[][] + */ + public function filterProductsBySingleCategoryIdDataProvider(): array + { + return [ + [ + 'fieldName' => 'category_id', + 'categoryId' => '333', + ], + [ + 'fieldName' => 'category_uid', + 'categoryId' => base64_encode('333'), + ], + ]; + } +} diff --git a/dev/tests/integration/_files/Magento/TestModuleDataExporterPersistFeed/etc/di.xml b/dev/tests/integration/_files/Magento/TestModuleDataExporterPersistFeed/etc/di.xml new file mode 100644 index 000000000..1c74125de --- /dev/null +++ b/dev/tests/integration/_files/Magento/TestModuleDataExporterPersistFeed/etc/di.xml @@ -0,0 +1,20 @@ + + + + + + + true + + + + + true + + + diff --git a/dev/tests/integration/_files/Magento/TestModuleDataExporterPersistFeed/etc/module.xml b/dev/tests/integration/_files/Magento/TestModuleDataExporterPersistFeed/etc/module.xml new file mode 100644 index 000000000..a248abc89 --- /dev/null +++ b/dev/tests/integration/_files/Magento/TestModuleDataExporterPersistFeed/etc/module.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/dev/tests/integration/_files/Magento/TestModuleDataExporterPersistFeed/registration.php b/dev/tests/integration/_files/Magento/TestModuleDataExporterPersistFeed/registration.php new file mode 100644 index 000000000..51d99657d --- /dev/null +++ b/dev/tests/integration/_files/Magento/TestModuleDataExporterPersistFeed/registration.php @@ -0,0 +1,12 @@ +getPath(ComponentRegistrar::MODULE, 'Magento_TestModuleDataExporterPersistFeed') === null) { + ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magento_TestModuleDataExporterPersistFeed', __DIR__); +}