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);
+ }
+
+ /**
+ * Verify that layered navigation filters and aggregations are correct for product query
+ *
+ * Filter products by an array of skus
+ * @magentoApiDataFixture Magento/Catalog/_files/products_with_layered_navigation_attribute.php
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
+ */
+ public function testFilterLn()
+ {
+ $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__);
+}