Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.1.0] - 2024-07-27

### Added
- New "Configurables Export" system configuration option to control how configurable products are exported to the feed
- Support for exporting configurable parent products instead of child products
- Support for exporting only visible child products (Catalog/Search/Both visibility levels)
- Per-store view configuration for different configurable product export strategies

### Changed
- Refactored configurable product handling logic in GenerateFeedForStore service
- Separated configurable and grouped product processing for better maintainability
- Enhanced configurable product export with proper price aggregation, stock status calculation, and image handling

### Technical Details
- Added ConfigurableExportType source model for configuration dropdown options
- Extended FeedConfigProvider to include new configuration setting
- Updated system configuration XML with new field
- Comprehensive unit test coverage for all new functionality
- Maintained backward compatibility with existing feed generation behavior
32 changes: 26 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ bin/magento setup:upgrade
Generate product feed every 2 hours with minimal required attributes, for each storeview.
Places file into `pub/media/run_as_root/feed/%s_store_%s_feed.xml`.

### Configurable Product Export Control

Configure how configurable products are exported to the feed:
- **Only Parent Products**: Export the configurable product itself with aggregated data from available children (lowest price, stock status based on child availability, parent product images)
- **Only Child Products**: Export child products that are visible individually (visibility levels: Catalog, Search, or Catalog + Search)

## Technical Specification

### Commands
Expand Down Expand Up @@ -70,12 +76,26 @@ Performs iteration on all products provided by this collection provider `\RunAsR

## Configuration

| tab | group | section | field |
|:--------|:--------|:----------------------|:-------------------|
| run_as_root | general | Product Feed Exporter | Enable |
| run_as_root | general | Product Feed Exporter | Cron Schedule |
| run_as_root | general | Product Feed Exporter | Category Whitelist |
| run_as_root | general | Product Feed Exporter | Category Blacklist |
| tab | group | section | field |
|:--------|:--------|:----------------------|:----------------------|
| run_as_root | general | Product Feed Exporter | Enable |
| run_as_root | general | Product Feed Exporter | Cron Schedule |
| run_as_root | general | Product Feed Exporter | Category Whitelist |
| run_as_root | general | Product Feed Exporter | Category Blacklist |
| run_as_root | general | Product Feed Exporter | Configurables Export |

### Configurables Export Options

The "Configurables Export" setting controls how configurable products are handled in the feed:

- **Only Child Products** (default): Exports child products that are visible individually (Catalog, Search, or Catalog + Search visibility)
- **Only Parent Products**: Exports the configurable product itself instead of children, with aggregated data:
- Price: Lowest price from available (enabled and in-stock) children
- Stock Status: In-stock if at least one child is available
- Images: Uses the configurable product's base image
- Skips configurable products with no available children

This setting is configurable per store view to allow different export strategies for different stores.


## Extensability points
Expand Down
12 changes: 12 additions & 0 deletions src/ConfigProvider/FeedConfigProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
use RunAsRoot\GoogleShoppingFeed\SourceModel\ConfigurableExportType;
use function explode;

class FeedConfigProvider
{
private const CONFIG_PATH_FEED_IS_ENABLED = 'run_as_root_product_feed/general/enabled';
private const CONFIG_PATH_CATEGORY_WHITELIST = 'run_as_root_product_feed/general/category_whitelist';
private const CONFIG_PATH_CATEGORY_BLACKLIST = 'run_as_root_product_feed/general/category_blacklist';
private const CONFIG_PATH_CONFIGURABLE_EXPORT_TYPE =
'run_as_root_product_feed/general/configurable_export_type';

private ScopeConfigInterface $config;

Expand Down Expand Up @@ -47,4 +50,13 @@ public function getCategoryBlacklist(int $storeId): array

return $categoriesBlacklistString !== null ? explode(',', $categoriesBlacklistString) : [];
}

public function getConfigurableExportType(int $storeId): string
{
return (string) $this->config->getValue(
self::CONFIG_PATH_CONFIGURABLE_EXPORT_TYPE,
ScopeInterface::SCOPE_STORE,
$storeId
) ?: ConfigurableExportType::EXPORT_CHILD_PRODUCTS;
}
}
215 changes: 171 additions & 44 deletions src/Service/GenerateFeedForStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use Magento\Bundle\Model\Product\Type as BundleProduct;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\Product;
use Magento\Catalog\Model\Product\Type\AbstractType;
use Magento\Catalog\Model\Product\Attribute\Source\Status;
use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection;
use Magento\ConfigurableProduct\Model\Product\Type\Configurable;
use Magento\Framework\Exception\FileSystemException;
Expand All @@ -25,11 +25,12 @@
use RunAsRoot\GoogleShoppingFeed\Exception\HandlerIsNotSpecifiedException;
use RunAsRoot\GoogleShoppingFeed\Exception\WrongInstanceException;
use RunAsRoot\GoogleShoppingFeed\Mapper\ProductToFeedAttributesRowMapper;
use RunAsRoot\GoogleShoppingFeed\SourceModel\ConfigurableExportType;
use RunAsRoot\GoogleShoppingFeed\Writer\XmlFileWriterProvider;

class GenerateFeedForStore
{
private const STATUS_ENABLED = 1;
private const STATUS_ENABLED = Status::STATUS_ENABLED;

private FeedConfigProvider $configProvider;
private AttributesConfigListProvider $attributesConfigListProvider;
Expand Down Expand Up @@ -102,51 +103,17 @@ public function execute(StoreInterface $store): void
$storeId
);

/** @var Product[] $items */
$items = $collection->getItems();

foreach ($items as $product) {
if (isset($rows[$product->getId()])) {
continue;
}

$typeInstance = $product->getTypeInstance();
$productRows = $this->processProduct($product, $attributesConfigList);

// CONFIGURABLE AND GROUPED PRODUCTS FLOW
if ($typeInstance instanceof Configurable || $typeInstance instanceof Grouped) {
$confAndGroupedRows =
$this->getConfigurableAndGroupedRows($typeInstance, $product, $attributesConfigList);

// @phpcs:ignore
$rows = array_merge($rows, $confAndGroupedRows);

$currentPage++;
continue;
}

// BUNDLE PRODUCTS FLOW
if ($typeInstance instanceof BundleProduct) {
$bundleRows = $this->getBundleProductRows($typeInstance, $product, $attributesConfigList);
// @phpcs:ignore
$rows = array_merge($rows, $bundleRows);

$currentPage++;
continue;
}

// SIMPLE PRODUCTS FLOW
try {
$rows[$product->getId()] = $this->productToRowMapper->map($product, $attributesConfigList);
} catch (HandlerIsNotSpecifiedException | WrongInstanceException $exception) {
throw new GenerateFeedForStoreException(
__(
'Product can not be mapped to feed row. Product ID: %1 . Error: %2',
$product->getId(),
$exception->getMessage()
),
$exception
);
}
// phpcs:ignore Magento2.Performance.ForeachArrayMerge.ForeachArrayMerge
$rows = array_merge($rows, $productRows);
}

$currentPage++;
Expand All @@ -165,21 +132,85 @@ private function canProceed(ProductCollection $productCollection, int $currentPa
* @throws GenerateFeedForStoreException
* @throws NoSuchEntityException
*/
private function getConfigurableAndGroupedRows(
AbstractType $typeInstance,
private function processProduct(Product $product, AttributeConfigDataList $attributesConfigList): array
{
$typeInstance = $product->getTypeInstance();

if ($typeInstance instanceof Configurable) {
return $this->getConfigurableProductRows($product, $attributesConfigList);
}

if ($typeInstance instanceof Grouped) {
return $this->getGroupedProductRows($typeInstance, $product, $attributesConfigList);
}

if ($typeInstance instanceof BundleProduct) {
return $this->getBundleProductRows($typeInstance, $product, $attributesConfigList);
}

return $this->getSimpleProductRows($product, $attributesConfigList);
}

/**
* @throws GenerateFeedForStoreException
*/
private function getSimpleProductRows(Product $product, AttributeConfigDataList $attributesConfigList): array
{
try {
return [ $product->getId() => $this->productToRowMapper->map($product, $attributesConfigList) ];
} catch (HandlerIsNotSpecifiedException | WrongInstanceException $exception) {
throw new GenerateFeedForStoreException(
__(
'Product can not be mapped to feed row. Product ID: %1 . Error: %2',
$product->getId(),
$exception->getMessage()
),
$exception
);
}
}

/**
* @throws GenerateFeedForStoreException
* @throws NoSuchEntityException
*/
private function getConfigurableProductRows(
Product $product,
AttributeConfigDataList $attributesConfigList
): array {
$rows = [];
$storeId = (int)$product->getStoreId();
$configExportType = $this->configProvider->getConfigurableExportType($storeId);

if ($configExportType === ConfigurableExportType::EXPORT_PARENT_PRODUCTS) {
return $this->getConfigurableParentProductRows($product, $attributesConfigList);
}

return $this->getConfigurableChildProductRows($product, $attributesConfigList);
}

$childProducts = $typeInstance instanceof Grouped ?
$typeInstance->getAssociatedProducts($product) : $typeInstance->getUsedProducts($product);
/**
* @throws GenerateFeedForStoreException
* @throws NoSuchEntityException
*/
private function getGroupedProductRows(
Grouped $typeInstance,
Product $product,
AttributeConfigDataList $attributesConfigList
): array {
$rows = [];
$childProducts = $typeInstance->getAssociatedProducts($product);

foreach ($childProducts as $childProduct) {
if ((int)$childProduct->getStatus() !== self::STATUS_ENABLED) {
continue;
}

$visibility = (int)$childProduct->getVisibility();

if ($visibility === \Magento\Catalog\Model\Product\Visibility::VISIBILITY_NOT_VISIBLE) {
continue;
}

try {
$childProduct = $this->productRepository
->get($childProduct->getSku(), false, $childProduct->getStoreId());
Expand Down Expand Up @@ -239,4 +270,100 @@ private function getBundleProductRows(

return $rows;
}

/**
* @throws GenerateFeedForStoreException
* @throws NoSuchEntityException
*/
private function getConfigurableParentProductRows(
Product $product,
AttributeConfigDataList $attributesConfigList
): array {
$typeInstance = $product->getTypeInstance();
$childProducts = $typeInstance->getUsedProducts($product);

$availableChildren = $this->getAvailableChildProducts($childProducts);

if (empty($availableChildren)) {
return [];
}

try {
return [ $product->getId() => $this->productToRowMapper->map($product, $attributesConfigList) ];
} catch (HandlerIsNotSpecifiedException | WrongInstanceException $exception) {
throw new GenerateFeedForStoreException(
__(
'Product can not be mapped to feed row. Product ID: %1 . Error: %2',
$product->getId(),
$exception->getMessage()
),
$exception
);
}
}

/**
* @throws GenerateFeedForStoreException
* @throws NoSuchEntityException
*/
private function getConfigurableChildProductRows(
Product $product,
AttributeConfigDataList $attributesConfigList
): array {
$rows = [];
$typeInstance = $product->getTypeInstance();
$childProducts = $typeInstance->getUsedProducts($product);

foreach ($childProducts as $childProduct) {
if ((int)$childProduct->getStatus() !== self::STATUS_ENABLED) {
continue;
}

$visibility = (int)$childProduct->getVisibility();

if ($visibility === \Magento\Catalog\Model\Product\Visibility::VISIBILITY_NOT_VISIBLE) {
continue;
}

try {
$childProduct = $this->productRepository
->get($childProduct->getSku(), false, $childProduct->getStoreId());
$rows[$childProduct->getId()] = $this->productToRowMapper
->map($childProduct, $attributesConfigList);
} catch (HandlerIsNotSpecifiedException | WrongInstanceException $exception) {
throw new GenerateFeedForStoreException(
__(
'Product can not be mapped to feed row. Product ID: %1 . Error: %2',
$product->getId(),
$exception->getMessage()
),
$exception
);
}
}

return $rows;
}

/**
* @param Product[] $childProducts
* @return Product[]
*/
private function getAvailableChildProducts(array $childProducts): array
{
$availableChildren = [];

foreach ($childProducts as $childProduct) {
if (
(int) $childProduct->getStatus() !== Status::STATUS_ENABLED ||
!$childProduct->isInStock()
) {
continue;
}

$availableChildren[] = $childProduct;
}

return $availableChildren;
}
}
Loading