Skip to content

Commit 91a2168

Browse files
authored
MDEE-1043: Export downloadable product as attribute (#492)
1 parent 6eea2d1 commit 91a2168

File tree

8 files changed

+293
-18
lines changed

8 files changed

+293
-18
lines changed

CatalogDataExporter/Model/Provider/Product/ProductOptions/DownloadableLinks.php

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,6 @@
2929
*/
3030
class DownloadableLinks
3131
{
32-
/**
33-
* @var array
34-
*/
35-
private $linkOptions = [];
36-
3732
/**
3833
* @var ResourceConnection
3934
*/
@@ -106,8 +101,9 @@ public function get(array $values): array
106101
foreach ($storeViews as $storeViewCode => $storeId) {
107102
$downloadableLinksSelect = $this->productDownloadableLinksQuery->getQuery($productIds, $storeId);
108103
$downloadableLinksQuery = $this->resourceConnection->getConnection()->query($downloadableLinksSelect);
109-
$this->linkOptions = $downloadableLinksQuery->fetchAll();
104+
$linkOptions = $downloadableLinksQuery->fetchAll();
110105
$output += $this->format(
106+
$linkOptions,
111107
$this->buildProductAttributes($values, $productIds),
112108
$storeViewCode
113109
);
@@ -119,15 +115,15 @@ public function get(array $values): array
119115
/**
120116
* Format provider data
121117
*
118+
* @param array $linkOptions
122119
* @param array $attributes
123120
* @param string $storeViewCode
124121
*
125122
* @return array
126123
*
127124
* @throws NoSuchEntityException
128-
* @throws InvalidArgumentException
129125
*/
130-
private function format(array $attributes, string $storeViewCode): array
126+
private function format(array $linkOptions, array $attributes, string $storeViewCode): array
131127
{
132128
$output = [];
133129
$products = array_keys($attributes);
@@ -140,7 +136,7 @@ private function format(array $attributes, string $storeViewCode): array
140136
'id' => 'link:' . (string)$productId,
141137
'label' => $attributes[(string)$productId]['links_title'],
142138
'type' => DownloadableLinksOptionUid::OPTION_TYPE,
143-
'values' => $this->processOptionValues((string)$productId, $storeViewCode)
139+
'values' => $this->processOptionValues($linkOptions, (string)$productId, $storeViewCode)
144140
]
145141
];
146142
}
@@ -150,18 +146,18 @@ private function format(array $attributes, string $storeViewCode): array
150146
/**
151147
* Process option values.
152148
*
149+
* @param array $linkOptions
153150
* @param string $productId
154151
* @param string $storeViewCode
155152
*
156153
* @return array
157154
*
158155
* @throws NoSuchEntityException
159-
* @throws InvalidArgumentException
160156
*/
161-
private function processOptionValues(string $productId, string $storeViewCode): array
157+
private function processOptionValues(array $linkOptions, string $productId, string $storeViewCode): array
162158
{
163159
$values = [];
164-
foreach ($this->linkOptions as $key => $option) {
160+
foreach ($linkOptions as $option) {
165161
if ($productId == $option['entity_id']) {
166162
$values[] = [
167163
'id' => $this->downloadableLinksOptionUid->resolve(
@@ -172,9 +168,9 @@ private function processOptionValues(string $productId, string $storeViewCode):
172168
'label' => $option['title'],
173169
'sortOrder' => $option['sort_order'],
174170
'infoUrl' => $this->getLinkSampleUrl($option, $storeViewCode),
175-
'price' => (float)$option['price']
171+
'price' => (float)$option['price'],
172+
'qty' => $option['number_of_downloads'] ?? 0
176173
];
177-
unset($this->linkOptions[$key]);
178174
}
179175
}
180176
return $values;

CatalogDataExporter/Model/Query/ProductDownloadableLinksQuery.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ private function getAvailableAttributes(): array
148148
'sample_url' => new \Zend_Db_Expr('IFNULL(main_table.sample_url, main_table.sample_file)'),
149149
'title' => new \Zend_Db_Expr('IFNULL(store_title.title, default_store_title.title)'),
150150
'price' => new \Zend_Db_Expr('IFNULL(store_price.price, default_store_price.price)'),
151+
'number_of_downloads' => 'main_table.number_of_downloads',
151152
];
152153
}
153154
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*
6+
* NOTICE: All information contained herein is, and remains
7+
* the property of Adobe and its suppliers, if any. The intellectual
8+
* and technical concepts contained herein are proprietary to Adobe
9+
* and its suppliers and are protected by all applicable intellectual
10+
* property laws, including trade secret and copyright laws.
11+
* Dissemination of this information or reproduction of this material
12+
* is strictly forbidden unless prior written permission is obtained
13+
* from Adobe.
14+
*/
15+
declare(strict_types=1);
16+
17+
namespace Magento\CatalogDataExporter\Plugin;
18+
19+
use Magento\CatalogDataExporter\Model\Provider\Products;
20+
use Magento\DataExporter\Export\Processor;
21+
use Magento\DataExporter\Model\Indexer\FeedIndexMetadata;
22+
use Magento\DataExporter\Model\Logging\CommerceDataExportLoggerInterface as LoggerInterface;
23+
use Magento\Downloadable\Model\Product\Type;
24+
use Magento\Framework\Serialize\SerializerInterface;
25+
26+
/**
27+
* For downloadable products, adds samples and links to product.attributes[code="ac_downloadable"].
28+
*
29+
* Intentionally keep logic in plugin to simplify future refactoring: eventually legacy approach for downloadable
30+
* product would be eliminated.
31+
*/
32+
class DownloadableAsAttribute
33+
{
34+
private const DOWNLOADABLE_ATTRIBUTE_CODE = 'ac_downloadable';
35+
private const OPTION_TYPE = 'downloadable';
36+
37+
/**
38+
* @param SerializerInterface $serializer
39+
*/
40+
public function __construct(private readonly SerializerInterface $serializer) {}
41+
42+
/**
43+
* After process plugin for the Processor class.
44+
*
45+
* @param Processor $processor
46+
* @param array $feedItems
47+
* @param string $feedName
48+
* @return array
49+
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
50+
*/
51+
public function afterProcess(Processor $processor, array $feedItems, string $feedName): array
52+
{
53+
if ($feedName === 'products') {
54+
$this->addAttributeToProductFeed($feedItems);
55+
} elseif ($feedName === 'productAttributes') {
56+
$this->modifyDownloadableAttributeMetadata($feedItems);
57+
}
58+
59+
return $feedItems;
60+
}
61+
62+
/**
63+
* Adds the downloadable attribute to the product feed items.
64+
*
65+
* @param array $products
66+
* @return void
67+
*/
68+
private function addAttributeToProductFeed(array &$products): void
69+
{
70+
foreach ($products as &$product) {
71+
if ($product['type'] === Type::TYPE_DOWNLOADABLE) {
72+
$downloadableAttributeData = $this->buildAttributeData($product);
73+
if ($downloadableAttributeData) {
74+
$product['attributes'][] = [
75+
'attributeCode' => self::DOWNLOADABLE_ATTRIBUTE_CODE,
76+
'value' => [$downloadableAttributeData],
77+
];
78+
}
79+
}
80+
}
81+
}
82+
83+
/**
84+
* Modifies the metadata for the downloadable attribute in the product attributes feed.
85+
*
86+
* @param array $productAttributes
87+
* @return void
88+
*/
89+
private function modifyDownloadableAttributeMetadata(array &$productAttributes): void
90+
{
91+
foreach ($productAttributes as &$attribute) {
92+
if (isset($attribute['attributeCode'])
93+
&& $attribute['attributeCode'] === self::DOWNLOADABLE_ATTRIBUTE_CODE) {
94+
$attribute['dataType'] = 'OBJECT';
95+
$attribute['visible'] = true; // visible in PDP
96+
}
97+
}
98+
}
99+
100+
/**
101+
* Builds the downloadable attribute data for a product.
102+
*
103+
* @param array $product
104+
* @return string|null
105+
*/
106+
private function buildAttributeData(array &$product): ?string
107+
{
108+
return $this->serializer->serialize([
109+
'purchase_separately' => (bool)($product['linksPurchasedSeparately'] ?? false),
110+
'samples' => $this->getSamples($product['samples'] ?? []),
111+
'links' => $this->getLinks($product['optionsV2'] ?? [])
112+
]);
113+
}
114+
115+
/**
116+
* Extracts sample links from product samples.
117+
*
118+
* @param array $samples
119+
* @return array
120+
*/
121+
private function getSamples(array $samples): array
122+
{
123+
$output = [];
124+
usort($samples, function ($a, $b) {
125+
return ($a['sortOrder'] ?? 0) <=> ($b['sortOrder'] ?? 0);
126+
});
127+
128+
foreach ($samples as $sampleLink) {
129+
if (!isset($sampleLink['resource'])) {
130+
continue;
131+
}
132+
$output[] = [
133+
'label' => $sampleLink['resource']['label'],
134+
'url' => $sampleLink['resource']['url'],
135+
];
136+
137+
}
138+
return $output;
139+
}
140+
141+
/**
142+
* Extracts downloadable links from product options.
143+
*
144+
* @param array $options
145+
* @return array
146+
*/
147+
private function getLinks(array $options): array
148+
{
149+
$links = [];
150+
foreach ($options as $option) {
151+
if ($option['type'] !== self::OPTION_TYPE) {
152+
continue;
153+
}
154+
$values = $option['values'] ?? [];
155+
usort($values, function ($a, $b) {
156+
return ($a['sortOrder'] ?? 0) <=> ($b['sortOrder'] ?? 0);
157+
});
158+
159+
foreach ($values as $n => $value) {
160+
$links[] = [
161+
'uid' => $value['id'],
162+
'label' => $value['label'] ?? __('Link') . ' ' . $n,
163+
'price' => $value['price'] ?? 0,
164+
'number_of_downloads' => $value['qty'] ?? 0,
165+
'sample_url' => $value['infoUrl']
166+
];
167+
}
168+
}
169+
return $links;
170+
}
171+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*
6+
* NOTICE: All information contained herein is, and remains
7+
* the property of Adobe and its suppliers, if any. The intellectual
8+
* and technical concepts contained herein are proprietary to Adobe
9+
* and its suppliers and are protected by all applicable intellectual
10+
* property laws, including trade secret and copyright laws.
11+
* Dissemination of this information or reproduction of this material
12+
* is strictly forbidden unless prior written permission is obtained
13+
* from Adobe.
14+
*/
15+
declare(strict_types=1);
16+
17+
namespace Magento\CatalogDataExporter\Setup\Patch\Data;
18+
19+
use Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface;
20+
use Magento\Eav\Setup\EavSetup;
21+
use Magento\Eav\Setup\EavSetupFactory;
22+
use Magento\Framework\Setup\ModuleDataSetupInterface;
23+
use Magento\Framework\Setup\Patch\DataPatchInterface;
24+
25+
/**
26+
* Create "ac_downloadable" attribute to prevent possible collision with user-created attribute
27+
*/
28+
class AddDownloadableProductAttribute implements DataPatchInterface
29+
{
30+
/**
31+
* @param EavSetupFactory $eavSetupFactory
32+
* @param ModuleDataSetupInterface $moduleDataSetup
33+
*/
34+
public function __construct(
35+
private readonly EavSetupFactory $eavSetupFactory,
36+
private readonly ModuleDataSetupInterface $moduleDataSetup
37+
) {}
38+
39+
/**
40+
* @inheritdoc
41+
*/
42+
public function apply()
43+
{
44+
/** @var EavSetup $eavSetup */
45+
$eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]);
46+
47+
$eavSetup->addAttribute(
48+
\Magento\Catalog\Model\Product::ENTITY,
49+
'ac_downloadable',
50+
[
51+
'type' => 'text',
52+
'label' => 'AC Downloadable product',
53+
'note' => 'Attribute to carry Downloadable product data to SaaS',
54+
'input' => 'textarea',
55+
'class' => '',
56+
'global' => ScopedAttributeInterface::SCOPE_STORE,
57+
'visible' => false,
58+
'required' => false,
59+
'user_defined' => false,
60+
'default' => '',
61+
'searchable' => false,
62+
'filterable' => false,
63+
'comparable' => false,
64+
'visible_on_front' => false,
65+
'visible_in_advanced_search' => false,
66+
'used_in_product_listing' => false,
67+
'unique' => false,
68+
'is_used_in_grid' => false,
69+
'is_visible_in_grid' => false,
70+
'is_filterable_in_grid' => false,
71+
]
72+
);
73+
74+
return $this->moduleDataSetup->getConnection()->endSetup();
75+
}
76+
77+
/**
78+
* @inheritdoc
79+
*/
80+
public static function getDependencies()
81+
{
82+
return [];
83+
}
84+
85+
/**
86+
* @inheritdoc
87+
*/
88+
public function getAliases()
89+
{
90+
return [];
91+
}
92+
}

CatalogDataExporter/Test/Integration/AbstractProductTestHelper.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,9 +376,8 @@ protected function validateImageUrls(ProductInterface $product, array $extracted
376376
* @param array $extractedProduct
377377
* @return void
378378
*/
379-
protected function validateAttributeData(ProductInterface $product, array $extractedProduct) : void
379+
protected function validateAttributeData(ProductInterface $product, array $extractedProduct, ?array $attributes = null) : void
380380
{
381-
$attributes = null;
382381
if ($product->hasData('custom_label')) {
383382
$customLabel = $product->getCustomAttribute('custom_label');
384383
$attributes[$customLabel->getAttributeCode()] = [

CatalogDataExporter/Test/Integration/DownloadableProductsTest.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,27 @@ public function testDownloadableProducts() : void
4444
{
4545
$skus = ['downloadable-product'];
4646
$storeViewCodes = ['default', 'custom_store_view_one', 'custom_store_view_two'];
47+
$expectedProductAttribute = [
48+
'default' => '{"purchase_separately":true,"samples":[{"label":"Downloadable Product Sample Title","url":"http:\/\/localhost\/downloadable\/download\/sample\/sample_id\/1"}],"links":[{"uid":"ZG93bmxvYWRhYmxlLzE=","label":"Downloadable Product Link","price":0,"number_of_downloads":0,"sample_url":null},{"uid":"ZG93bmxvYWRhYmxlLzI=","label":"Downloadable Product Link","price":0,"number_of_downloads":0,"sample_url":"http:\/\/localhost\/downloadable\/download\/linkSample\/link_id\/2"}]}',
49+
'custom_store_view_one' => '{"purchase_separately":true,"samples":[{"label":null,"url":"http:\/\/localhost\/downloadable\/download\/sample\/sample_id\/1"}],"links":[{"uid":"ZG93bmxvYWRhYmxlLzE=","label":"Link 0","price":0,"number_of_downloads":0,"sample_url":null},{"uid":"ZG93bmxvYWRhYmxlLzI=","label":"Link 1","price":0,"number_of_downloads":0,"sample_url":"http:\/\/localhost\/downloadable\/download\/linkSample\/link_id\/2"}]}',
50+
'custom_store_view_two' => '{"purchase_separately":true,"samples":[{"label":null,"url":"http:\/\/localhost\/downloadable\/download\/sample\/sample_id\/1"}],"links":[{"uid":"ZG93bmxvYWRhYmxlLzE=","label":"Link 0","price":0,"number_of_downloads":0,"sample_url":null},{"uid":"ZG93bmxvYWRhYmxlLzI=","label":"Link 1","price":0,"number_of_downloads":0,"sample_url":"http:\/\/localhost\/downloadable\/download\/linkSample\/link_id\/2"}]}'
51+
];
4752

4853
foreach ($skus as $sku) {
4954
foreach ($storeViewCodes as $storeViewCode) {
5055
$store = $this->storeManager->getStore($storeViewCode);
5156
$product = $this->productRepository->get($sku, false, $store->getId());
5257
$product->setTypeInstance(Bootstrap::getObjectManager()->create(Simple::class));
58+
$attribute = [ 'ac_downloadable' => [
59+
'attributeCode' => 'ac_downloadable',
60+
'value' => [$expectedProductAttribute[$storeViewCode]]
61+
]];
5362

5463
$extractedProduct = $this->getExtractedProduct($sku, $storeViewCode);
5564
$this->validateBaseProductData($product, $extractedProduct, $storeViewCode);
5665
$this->validatePricingData($extractedProduct);
5766
$this->validateImageUrls($product, $extractedProduct);
58-
$this->validateAttributeData($product, $extractedProduct);
67+
$this->validateAttributeData($product, $extractedProduct, $attribute);
5968
$this->validateMediaGallery($product, $extractedProduct);
6069
$this->validateVideoData($product, $extractedProduct);
6170
$this->validateImageData($product, $extractedProduct);

0 commit comments

Comments
 (0)