Commerce Cloud

Summary

 

Overview

In this guide, we will explore the different reference implementations available for Salesforce Commerce Cloud, and how they can help you to use the connector and transform your Akeneo Data even further on the Salesforce platform. Reference implementations are suggested solutions that provide a starting point for using Akeneo data in your commerce cloud application and leverage  automations to help you  save time and resources to achieve your specific business requirements. 

These implementations are not available in the installed package so you will need the support of a Salesforce Administrator or developer to implement some of the below. 

 

How to trigger a Webstore Search Index after a successful transfer ?

In order to trigger a wesbtore search index refresh we are providing below an invocable flow action that can be used in any flow to trigger and reindex at the end of each successfull transfer log. 

  • Create invocable Apex Class using the below code

 

public with sharing class ProductIndexing {
    
    @InvocableMethod (label='Trigger Product Index' description='triggers the product search index automatically' category='B2B Commerce')
    public static List<StoreIndexTriggered> triggerProductIndex(List<String> webstoreIds) {
        List<StoreIndexTriggered> triggeredIndexes = new List<StoreIndexTriggered>();
        
        if(webstoreIds != null || !webstoreIds.isEmpty()){
            for(String webstoreId : webstoreIds){
                ConnectApi.CommerceSearchIndex searchIndex = ConnectApi.CommerceSearchSettings.createCommerceSearchIndex(webstoreId,ConnectApi.CommerceSearchIndexBuildType.INCREMENTAL);
                
                StoreIndexTriggered triggeredIndex = new StoreIndexTriggered();
                triggeredIndex.webstoreId = webstoreId;
                triggeredIndex.isSuccessfullyTriggered = searchIndex != null && 
                                        searchIndex.indexStatus != null && 
                                        searchIndex.indexStatus != ConnectApi.CommerceSearchIndexStatus.FAILED;
                triggeredIndexes.add(triggeredIndex);
            }
                
        }
        return triggeredIndexes;
        
    }
    public class StoreIndexTriggered {
        @InvocableVariable public String webstoreId;
        @InvocableVariable public Boolean isSuccessfullyTriggered;
    }
}

 

Once the class has been create you can access the action in your flows

2. Navigate to Setup > Flows > New Flow

3. Create a record triggered flow when an Akeneo Transfer Log Record is updated and the Status equals “SUCCESS”

4. Run an Asynchronous path and connect a Get Store ID Element

5. In the Get Store ID GET Element you can put the name of the commerce store you want to reindex. In the below example we are using “Commerce Demo Store”

6. Add an ACTION element in your flow called Product Reindex

7. Select the ID from your get element and pass it to your invocable Apex Action

8. Now you can debug and test your flow using the debug functionality from the flow. 

9. If flow runs successfully you can proceed to the Activation.

Once the flow is Active you can trigger an Akeneo Catalog Transfer and see if your Webstore Search Index is triggered once the full product transfer was finalised successfully. 

 

How to map your Akeneo Product Catalog to Webstore Catalog and Product Categories?

The current connector data model matches the Salesforce Commerce Cloud data model of Catalogs and Product Categories. As the custom and standard objects directly match, they can be easily synchronized using an external ID field on the standard objects. This external ID will hold the Salesforce ID of the records that it directly matches.

Before proceeding 

The external ID fields must be manually created in the three standard Salesforce objects.

 

 

 Multiple Salesforce platform features can be leveraged to do the actual record synchronisation. For this reference implementation, we choose to use Apex Triggers because it’s the most flexible option. Flows were also considered, but at this moment they don’t support the upsert operation, which would increase quite significantly the complexity of the implementation. 

  1. Create the three below Apex Triggers
    1. SyncProductCatalog
    2. SyncProductCategory
    3. SyncProductCategoryProduct

 

SyncProductCatalog Trigger

This trigger synchronizes the data between the custom Akeneo Product Catalog and the standard Product Catalog object.

trigger SyncProductCatalog on akeneoSF__Akeneo_Product_Catalog__c (after insert, after update, after delete) {
    if (Trigger.isInsert || Trigger.isUpdate) {
        List<ProductCatalog> productCatalogs = new List<ProductCatalog>();
    
        for (akeneoSF__Akeneo_Product_Catalog__c akeneoProductCatalog : Trigger.new) {
    
            ProductCatalog productCatalog = new ProductCatalog();
            productCatalog.Name = akeneoProductCatalog.Name;
            productCatalog.External_ID__c = akeneoProductCatalog.Id;
    
            productCatalogs.add(productCatalog);
        }
    
        if (!productCatalogs.isEmpty()) {
            upsert productCatalogs External_ID__c;
        }
    }
    if (Trigger.isDelete) {
        Set<String> productCatalogExternalIds = new Set<String>();
    
        for (akeneoSF__Akeneo_Product_Catalog__c akeneoProductCatalog : Trigger.old) {
    
            productCatalogExternalIds.add(akeneoProductCatalog.Id);
        }
        List<ProductCatalog> productCatalogs = [SELECT Id FROM ProductCatalog WHERE External_ID__c IN :productCatalogExternalIds];
        if (!productCatalogs.isEmpty()) {
            delete productCatalogs;
        }
    }
}																																							

 

SyncProductCategory Trigger

This trigger synchronizes the data between the custom Akeneo Product Category and the standard Product Category object.

trigger SyncProductCategory on akeneoSF__Akeneo_Product_Category__c (after insert, after update, after delete) {
    if (Trigger.isInsert || Trigger.isUpdate) {
        List<ProductCategory> productCategories = new List<ProductCategory>();
    
        for (akeneoSF__Akeneo_Product_Category__c akeneoProductCategory : Trigger.new) {
    
            ProductCategory productCategoryRecord = new ProductCategory();
            productCategoryRecord.Name = akeneoProductCategory.Name;
            productCategoryRecord.SortOrder = Integer.valueOf(akeneoProductCategory.akeneoSF__Sort_Order__c);
            productCategoryRecord.IsNavigational = true;
            productCategoryRecord.External_ID__c = akeneoProductCategory.Id;
    
            productCategoryRecord.Catalog = new ProductCatalog(External_ID__c = akeneoProductCategory.akeneoSF__Product_Catalog__c);
            
            if (akeneoProductCategory.akeneoSF__Parent_Category__c != null) {
                productCategoryRecord.ParentCategory = new ProductCategory(External_ID__c = akeneoProductCategory.akeneoSF__Parent_Category__c);
            }
    
            productCategories.add(productCategoryRecord);
        }
    
        if (!productCategories.isEmpty()) {
            upsert productCategories External_ID__c;
        }
    }
    if (Trigger.isDelete) {
        Set<String> productCategoryExternalIds = new Set<String>();
    
        for (akeneoSF__Akeneo_Product_Category__c akeneoProductCategory : Trigger.old) {
    
            productCategoryExternalIds.add(akeneoProductCategory.Id);
        }
        List<ProductCategory> productCategories = [SELECT Id FROM ProductCategory WHERE External_ID__c IN :productCategoryExternalIds];
        if (!productCategories.isEmpty()) {
            delete productCategories;
        }
    }
}		

SyncProductCategoryProduct Trigger

This trigger synchronizes the data between the custom Akeneo Product Category Product and the standard Product Category Product object.

trigger SyncProductCatalog on akeneoSF__Akeneo_Product_Catalog__c (after insert, after update, after delete) {
    if (Trigger.isInsert || Trigger.isUpdate) {
        List<ProductCatalog> productCatalogs = new List<ProductCatalog>();
    
        for (akeneoSF__Akeneo_Product_Catalog__c akeneoProductCatalog : Trigger.new) {
    
            ProductCatalog productCatalog = new ProductCatalog();
            productCatalog.Name = akeneoProductCatalog.Name;
            productCatalog.External_ID__c = akeneoProductCatalog.Id;
    
            productCatalogs.add(productCatalog);
        }
    
        if (!productCatalogs.isEmpty()) {
            upsert productCatalogs External_ID__c;
        }
    }
    if (Trigger.isDelete) {
        Set<String> productCatalogExternalIds = new Set<String>();
    
        for (akeneoSF__Akeneo_Product_Catalog__c akeneoProductCatalog : Trigger.old) {
    
            productCatalogExternalIds.add(akeneoProductCatalog.Id);
        }
        List<ProductCatalog> productCatalogs = [SELECT Id FROM ProductCatalog WHERE External_ID__c IN :productCatalogExternalIds];
        if (!productCatalogs.isEmpty()) {
            delete productCatalogs;
        }
    }
}

 

2. Once the triggers have been created every time a transfer of the Akeneo Product Catalog happens, the relevant category tree and category assignments will be replicated into your Commerce App > Catalog.

 

 

 

How to Import your Akeneo Product Media Assets into CMS ?

The current version of the connector allows the transfer of the Product Media Assets into a custom object. In order to bring the assets public URLs to CMS content we can use the CSV product importer available in Commerce Cloud. 

Once you have successfully transferred to your assets from a catalog. 

Export the Akeneo Product Asset object data in a CSV file using Data Loader,Dataloader.io, Salesforce Inspector or any other data export tool. 

The CSV file should have the following fields:

  • Product SKU
  • Asset Type 
  • Asset URL

To import the Assets into CMS navigate to Commerce App > Product Workspace > Click Import 

 

On the next screen upload the CSV file 

 

 

How to map your Akeneo Product Variants and Axes to Salesforce Product Variations and Attributes?

To display variations in a B2B Commerce store Salesforce uses a more complex data model. It uses the objects Product Attribute Set and Product Attribute Set Item to store a set of “variant axes”. Each set can be assigned to multiple variant parents (or models in Akeneo) using the object Product Attribute Set Product. To define the variant axis's actual value, the Product Attribute object is used, and in that object, a field for each axis must be created.

 

Synchronization

The Salesforce data model for variants is quite complex and requires a few data transformations to be done.

First of all, because the variant axes data that is received from Akeno doesn’t include any mapping for Salesforce fields, these fields have to be created manually on the Product Attribute object.

Let’s assume that one of the variant axes is called shirt_size and that in the Product Attribute, a corresponding field called Shirt_Size__c was created.

And let’s also assume that this was the data created by the connector:

Product

akeneoSF__Akeneo_UUID__c Name Type ProductClass akeneoSF__Akeneo_Product_Type__c akeneoSF__Akeneo_Variant_Axes__c akeneoSF__Akeneo_Variant_Model__c
code-001 Summer T-Shirt Base VariationParent MODEL - -
uuid-002 Summer T-Shirt - Small - Variation VARIANT shirt_size code-001
uuid-003 Summer T-Shirt - Large - Variation VARIANT shirt_size code-001

 

Akeneo Variant Axes Detail

akeneoSF__Product__c akeneoSF__Variant_Attribute_Locale__c akeneoSF__Variant_Attribute_API__c akeneoSF__Variant_Attribute_Label__c akeneoSF__Variant_Attribute_Value_API__c akeneoSF__Variant_Attribute_Value__c
uuid-002 en_GB shirt_size Shirt Size small Small
uuid-002 fr_FR shirt_size Taille De Chemise small Petit
uuid-003 en_GB shirt_size Shirt Size large Large
uuid-003 fr_FR shirt_size Taille De Chemise large Grand

 

With this data, the Salesforce B2B Commerce data model can be created in the following manner:

 

Akeneo Attribute to Salesforce Field Mapping

Each Akeneo variant axis must be mapped to a Salesforce field on the Product Attribute object.

Akeneo Attribute Salesforce Field
shirt_size Shirt_Size__c

 

Product Attribute Set

The same attribute set can be linked to multiple variant parents (Akeneo models) but to make things easier, in this example, each variant parent will have its own attribute set, where its name is the model’s code.

Id DeveloperName MasterLabel
id-1 code-001 code-001

 

Product Attribute Set Item

The attribute set items can be taken from the akeneoSF__Akeneo_Variant_Axes__c field of the Product. This field contains a comma-separated list of the variant axes. The right field for each variant axis can be taken from the previous mapping table.

The Sequence field controls the order by which the variant axes will appear in the B2B Commerce store.

ProductAttributeSetId Field Sequence
id-1 Shirt_Size__c 1

 

Product Attribute Set Product

This object is used to link an attribute set to a product. The attribute set should only be linked to the variant parent.

ProductAttributeSetId ProductId
id-1 code-001

 

Product Attribute

This object contains the actual values for each variant axis. It is linked to both the variant parent and the variant product.

VariantParentId ProductId Shirt_Size__c
code-001 uuid-002 small
code-001 uuid-003 large

 

Reference Implementation


Apart from the data model complexity, there are a few other constraints that limit the way that the data can be synchronized.

Product Type
In Salesforce, for a product to be considered as a variant parent its standard Type field must be set to “Base“. The challenge is that this field is not editable, which means that its value must be set when the product is created for the first time.

To overcome this limitation, as part of the reference implementation, it’s recommended to create a before-insert trigger that sets the product type to "Base" when the akeneoSF__Akeneo_Product_Type__c is "MODEL". This way all product models in Akeneo will have the correct product type when they are inserted for the first time.

 

// SyncProductModelType

trigger SyncProductModelType on Product2(before insert) {
    for (Product2 product : Trigger.new) {
        if (product.akeneoSF__Akeneo_Product_Type__c == 'MODEL') {
            product.Type = 'Base';
        }
    }
}

If the product models already exist in Salesforce before this trigger is deployed, a manual step is required to delete these products. Alternatively, instead of deleting the products, the akeneoSF__Akeneo_UUID__c field can be cleared which will result in new product models to be created by the connector.

 

 

Setup and Non-Setup Objects
Salesforce has the concept of setup and non-setup objects. Among other things, one of the most important implications is that DML operations on certain setup objects can’t be mixed with non-setup objects in the same transaction. And because the Product Attribute Set and Product Attribute Set Item objects are considered non-setup objects they can’t be inserted together with the other objects from the variants data model (considered setup objects) in the same transaction.

This limitation causes the reference implementation to be more complex than normal because the DML operations have to split between different transactions. The recommended way to do this is to use the Salesforce Batch Apex feature. This allows the creation of multiple batch jobs, each running in a different transaction, that can process all product records and perform the necessary DMLs on the variants data model.

Therefore, for this reference implementation, three batch jobs were created, each processing different objects.

 

Batch Job Objects
SyncProductAttributeSetBatch Product Attribute Set, Product Attribute Set Item
SyncProductAttributeSetProductBatch Product Attribute Set Product
SyncProductAttributeBatch Product Attribute

 

These batch jobs are calling each other in the correct order, but a different mechanism is required to call the first one and start the process. For that, as part of the reference implementation, a trigger on the Akeneo Transfer Log object was created. This trigger fires when the akeneoSF__CompletionDate__c field of the transfer log is populated, which means the transfer has finished. It then calls the SyncProductAttributeSetBatch and passes to it the akeneoSF__StartDate__c field of the transfer log, which will cause the batch to run only on product records that were updated after the start of the transfer.

 

// TransferLogTrigger

trigger TransferLogTrigger on akeneoSF__Akeneo_Transfer_Log__c(after update) {
    akeneoSF__Akeneo_Transfer_Log__c singleTransferLogNew = Trigger.new[0];
    akeneoSF__Akeneo_Transfer_Log__c singleTransferLogOld = Trigger.old[0];

    if (singleTransferLogOld.akeneoSF__CompletionDate__c == null && singleTransferLogNew.akeneoSF__CompletionDate__c != null) {
        Database.executeBatch(new SyncProductAttributeSetBatch(singleTransferLogOld.akeneoSF__StartDate__c));
    }
}

 

// SyncProductAttributeSetBatch

public class SyncProductAttributeSetBatch implements Database.Batchable<sObject>, Database.Stateful {
    private static Map<String, String> akeneoAttributeFieldMap = new Map<String, String>{ 'shirt_size' => 'Shirt_Size__c' };

    private Datetime lastModifiedDate;
    public Set<Id> productModelIds;

    public SyncProductAttributeSetBatch(Datetime lastModifiedDate) {
        this.lastModifiedDate = lastModifiedDate;
        this.productModelIds = new Set<Id>();
    }

    public Iterable<Product2> start(Database.BatchableContext bc) {
        return [
            SELECT Id, akeneoSF__Akeneo_UUID__c, akeneoSF__Akeneo_Variant_Axes__c
            FROM Product2
            WHERE akeneoSF__Akeneo_Product_Type__c = 'MODEL' AND LastModifiedDate >= :lastModifiedDate
        ];
    }

    public void execute(Database.BatchableContext bc, List<Product2> scope) {
        Map<String, Id> productModelCodeToIdMap = new Map<String, Id>();
        Map<String, Set<String>> productModelIdVariationAxesMap = new Map<String, Set<String>>();

        for (Product2 product : scope) {
            if (product.akeneoSF__Akeneo_Variant_Axes__c != null) {
                productModelCodeToIdMap.put(product.akeneoSF__Akeneo_UUID__c, product.Id);
                productModelIdVariationAxesMap.put(product.akeneoSF__Akeneo_UUID__c, new Set<String>(product.akeneoSF__Akeneo_Variant_Axes__c.split(',')));

                this.productModelIds.add(product.Id);
            }
        }

        // get existing product attribute sets

        Set<String> existingProductAttributeSetModelCodes = new Set<String>();

        for (ProductAttributeSet existingProductAttributeSet : [
            SELECT Id, MasterLabel
            FROM ProductAttributeSet
            WHERE MasterLabel IN :productModelCodeToIdMap.keySet()
        ]) {
            existingProductAttributeSetModelCodes.add(existingProductAttributeSet.MasterLabel);
        }

        // create the product attribute sets

        List<ProductAttributeSet> productAttributeSetList = new List<ProductAttributeSet>();

        for (String productModelCode : productModelCodeToIdMap.keySet()) {
            if (!existingProductAttributeSetModelCodes.contains(productModelCode)) {
                productAttributeSetList.add(new ProductAttributeSet(DeveloperName = productModelCode.replace(' ', '_'), MasterLabel = productModelCode));
            }
        }

        if (!productAttributeSetList.isEmpty()) {
            insert productAttributeSetList;
        }

        Map<String, Id> productAttributeSetModelCodeToIdMap = new Map<String, Id>();

        for (ProductAttributeSet productAttributeSet : productAttributeSetList) {
            productAttributeSetModelCodeToIdMap.put(productAttributeSet.MasterLabel, productAttributeSet.Id);
        }

        // get field definitions

        Set<String> fieldNames = new Set<String>();

        for (Set<String> variationAxes : productModelIdVariationAxesMap.values()) {
            for (String variationAxis : variationAxes) {
                String fieldName = akeneoAttributeFieldMap.get(variationAxis);

                if (fieldName != null) {
                    fieldNames.add(fieldName.removeEnd('__c'));
                }
            }
        }

        List<FieldDefinition> variationFieldDefinitionList = [
            SELECT DurableId, DeveloperName
            FROM FieldDefinition
            WHERE EntityDefinition.QualifiedApiName = 'ProductAttribute' AND DeveloperName IN :fieldNames
        ];

        Map<String, String> variationFieldNameToDurableIdMap = new Map<String, String>();

        for (FieldDefinition variationFieldDefinition : variationFieldDefinitionList) {
            variationFieldNameToDurableIdMap.put(variationFieldDefinition.DeveloperName, variationFieldDefinition.DurableId.split('\\.')[1]);
        }

        // create the product attribute set items

        List<ProductAttributeSetItem> productAttributeSetItemList = new List<ProductAttributeSetItem>();

        for (String productModelCode : productModelCodeToIdMap.keySet()) {
            if (!existingProductAttributeSetModelCodes.contains(productModelCode)) {
                Integer sequence = 0;

                for (String variationAxis : productModelIdVariationAxesMap.get(productModelCode)) {
                    String fieldName = akeneoAttributeFieldMap.get(variationAxis);
                    Id fieldId = variationFieldNameToDurableIdMap.get(fieldName.removeEnd('__c'));

                    if (fieldName != null && fieldId != null) {
                        productAttributeSetItemList.add(
                            new ProductAttributeSetItem(
                                ProductAttributeSetId = productAttributeSetModelCodeToIdMap.get(productModelCode),
                                Field = fieldId,
                                Sequence = sequence++
                            )
                        );
                    }
                }
            }
        }

        if (!productAttributeSetItemList.isEmpty()) {
            insert productAttributeSetItemList;
        }
    }

    public void finish(Database.BatchableContext bc) {
        if (!this.productModelIds.isEmpty()) {
            Database.executeBatch(new SyncProductAttributeSetProductBatch(this.productModelIds));
        }
    }
}

 

// SyncProductAttributeSetProductBatch

public class SyncProductAttributeSetProductBatch implements Database.Batchable<sObject>, Database.Stateful {
    private Set<Id> productModelIds;

    public SyncProductAttributeSetProductBatch(Set<Id> productModelIds) {
        this.productModelIds = productModelIds;
    }

    public Iterable<Product2> start(Database.BatchableContext bc) {
        return [
            SELECT Id, akeneoSF__Akeneo_UUID__c
            FROM Product2
            WHERE Id IN :productModelIds
        ];
    }

    public void execute(Database.BatchableContext bc, List<Product2> scope) {
        // get the product model codes

        Set<String> productModelCodes = new Set<String>();

        for (Product2 product : scope) {
            productModelCodes.add(product.akeneoSF__Akeneo_UUID__c);
        }

        // get the product model attribute sets

        List<ProductAttributeSet> productAttributeSetList = [SELECT Id, MasterLabel FROM ProductAttributeSet WHERE MasterLabel IN :productModelCodes];

        Map<String, Id> productModelCodeToAttributeSetIdMap = new Map<String, Id>();

        for (ProductAttributeSet productAttributeSet : productAttributeSetList) {
            productModelCodeToAttributeSetIdMap.put(productAttributeSet.MasterLabel, productAttributeSet.Id);
        }

        // get existing product attribute set products

        Set<String> existingProductAttributeSetProductKeys = new Set<String>();

        for (ProductAttributeSetProduct productAttributeSetProduct : [
            SELECT Id, ProductId, ProductAttributeSetId
            FROM ProductAttributeSetProduct
            WHERE ProductId IN :productModelIds AND ProductAttributeSetId IN :productModelCodeToAttributeSetIdMap.values()
        ]) {
            existingProductAttributeSetProductKeys.add(productAttributeSetProduct.ProductId + '_' + productAttributeSetProduct.ProductAttributeSetId);
        }

        // assign the product attribute sets to the product models

        List<ProductAttributeSetProduct> productAttributeSetProductList = new List<ProductAttributeSetProduct>();

        for (Product2 product : scope) {
            if (productModelCodeToAttributeSetIdMap.containsKey(product.akeneoSF__Akeneo_UUID__c)) {
                Id productAttributeSetId = productModelCodeToAttributeSetIdMap.get(product.akeneoSF__Akeneo_UUID__c);

                if (!existingProductAttributeSetProductKeys.contains(product.Id + '_' + productAttributeSetId)) {
                    productAttributeSetProductList.add(new ProductAttributeSetProduct(ProductId = product.Id, ProductAttributeSetId = productAttributeSetId));
                }
            }
        }

        if (!productAttributeSetProductList.isEmpty()) {
            insert productAttributeSetProductList;
        }
    }

    public void finish(Database.BatchableContext bc) {
        if (!this.productModelIds.isEmpty()) {
            Database.executeBatch(new SyncProductAttributeBatch(this.productModelIds));
        }
    }
}

 

// SyncProductAttributeBatch

public class SyncProductAttributeBatch implements Database.Batchable<sObject> {
    private static Map<String, String> akeneoAttributeFieldMap = new Map<String, String>{ 'shirt_size' => 'Shirt_Size__c' };

    private Set<Id> productModelIds;

    public SyncProductAttributeBatch(Set<Id> productModelIds) {
        this.productModelIds = productModelIds;
    }

    public Iterable<Product2> start(Database.BatchableContext bc) {
        return [
            SELECT Id, akeneoSF__Akeneo_UUID__c
            FROM Product2
            WHERE Id IN :productModelIds
        ];
    }

    public void execute(Database.BatchableContext bc, List<Product2> scope) {
        // get product ids for the product models in the scope

        Set<Id> scopeProductModelIds = new Set<Id>();

        for (Product2 productModel : scope) {
            scopeProductModelIds.add(productModel.Id);
        }

        // get configured default locale

        akeneoSF__Akeneo_Connector_Configuration__mdt connectorConfigurationMetadata = akeneoSF__Akeneo_Connector_Configuration__mdt.getInstance('Akeneo');

        String defaultLocale = connectorConfigurationMetadata.akeneoSF__Default_Locale__c != null
            ? connectorConfigurationMetadata.akeneoSF__Default_Locale__c
            : 'en_US';

        // get variation axes

        List<akeneoSF__Akeneo_Variant_Axes_Detail__c> variationAxes = [
            SELECT
                Id,
                akeneoSF__Variant_Attribute_API__c,
                akeneoSF__Variant_Attribute_Label__c,
                akeneoSF__Variant_Attribute_Value_API__c,
                akeneoSF__Variant_Attribute_Value__c,
                akeneoSF__Variant_Attribute_Locale__c,
                akeneoSF__Product__c,
                akeneoSF__Product__r.akeneoSF__Akeneo_Variant_Model__c
            FROM akeneoSF__Akeneo_Variant_Axes_Detail__c
            WHERE akeneoSF__Product__r.akeneoSF__Akeneo_Variant_Model__c IN :scopeProductModelIds AND akeneoSF__Variant_Attribute_Locale__c = :defaultLocale
        ];

        Map<Id, List<akeneoSF__Akeneo_Variant_Axes_Detail__c>> variationAxesByProductVariantId = new Map<Id, List<akeneoSF__Akeneo_Variant_Axes_Detail__c>>();

        for (akeneoSF__Akeneo_Variant_Axes_Detail__c variationAxis : variationAxes) {
            if (!variationAxesByProductVariantId.containsKey(variationAxis.akeneoSF__Product__c)) {
                variationAxesByProductVariantId.put(variationAxis.akeneoSF__Product__c, new List<akeneoSF__Akeneo_Variant_Axes_Detail__c>());
            }

            variationAxesByProductVariantId.get(variationAxis.akeneoSF__Product__c).add(variationAxis);
        }

        // get existing product attributes

        List<ProductAttribute> existingProductAttributes = [
            SELECT Id, ProductId, VariantParentId
            FROM ProductAttribute
            WHERE VariantParentId IN :scopeProductModelIds AND ProductId IN :variationAxesByProductVariantId.keySet()
        ];

        Set<String> existingProductAttributeKeys = new Set<String>();

        for (ProductAttribute existingProductAttribute : existingProductAttributes) {
            existingProductAttributeKeys.add(existingProductAttribute.VariantParentId + '_' + existingProductAttribute.ProductId);
        }

        // create product attributes

        List<ProductAttribute> productAttributeList = new List<ProductAttribute>();

        for (Id productVariantId : variationAxesByProductVariantId.keySet()) {
            List<akeneoSF__Akeneo_Variant_Axes_Detail__c> variationAxesForProductVariant = variationAxesByProductVariantId.get(productVariantId);

            if (variationAxesForProductVariant == null || variationAxesForProductVariant.isEmpty()) {
                continue;
            }

            Id productModelId = variationAxesForProductVariant[0].akeneoSF__Product__r.akeneoSF__Akeneo_Variant_Model__c;

            if (existingProductAttributeKeys.contains(productModelId + '_' + productVariantId)) {
                continue;
            }

            ProductAttribute productAttribute = new ProductAttribute(ProductId = productVariantId, VariantParentId = productModelId);

            for (akeneoSF__Akeneo_Variant_Axes_Detail__c variationAxis : variationAxesForProductVariant) {
                String attributeField = akeneoAttributeFieldMap.get(variationAxis.akeneoSF__Variant_Attribute_API__c);

                if (attributeField != null) {
                    productAttribute.put(attributeField, variationAxis.akeneoSF__Variant_Attribute_Value_API__c);
                }
            }

            productAttributeList.add(productAttribute);
        }

        if (!productAttributeList.isEmpty()) {
            insert productAttributeList;
        }
    }

    public void finish(Database.BatchableContext bc) {
    }
}