Customization (programmatically)

Summary

Customization, what for?

Well, you have your Akeneo Connector for Salesforce already up and running.
Now you think about how you can customize things, because you have additional requirements?

Congratulations, now all your product data is transferred to Salesforce or just heading there. 
You have your Categories, Products, and Assets in sync - but you need more.

Maybe you plan to run a commerce shop, but it requires you to have your Assets in product media objects as well as it also would require to synchronize your catalog records into the Product Catalog.

The Akeneo Connector is designed to be multi-cloud compatible. Because of this, no objects are used that will require extra licenses like commerce or digital experience as the above-mentioned Salesforce objects would do.

Customization helps to achieve things the Connector is not capable of, out of the box, now.

There are different possibilities like doing things programmatically i.e. by using Apex, Triggers, and so on, but on the other hand, you can use declarative tools like flows to achieve this and apply customizations.

Now we focus on how to do things as a coder, which means programmatically.
 

How to use existing connections?

Yes, there is already an existing connection available in Salesforce you can use as well. It's there because the connector already requires it to be set up and configured.

Since this, of course, is about security and some requisites have to be made upfront to make both systems run together, it would be great if there is an easy way to directly reuse what's there – and that's what in Salesforce is called Named Credentials.

Named Credentials

Named Credentials are exactly what the name promises. Credentials that are already set up and securely stored within Salesforce which can then simply be used by their name.

The Named Credential uses the already configured Salesforce Auth. Provider and provides you access to the Akeno APIs by providing you with an endpoint alias: callout:Akeneo

This means you just need to know the specific endpoint context path structure to do your own custom callout – no matter if you use Catalog API or REST API.

Example:

HttpRequest request = new HttpRequest();
request.setEndpoint('callout:Akeneo/api/rest/v1/catalogs');

As you can see, you do not need to take care of authentication, base URL, and the like, simply attach your endpoint and use it.
 

Reference Implementation

Following you find some specific reference implementations you can use to achieve your custom goals.

Localization

For reference an example custom implementation could be the following:

  • Create a custom field on the Product2 object for each language you want to use.
  • Populate the custom fields with the translated values for each language.
  • Use a formula field to display the appropriate translated value based on the user’s language preference.

Here’s an example of how we can use a formula field to display a localized product name in Sales/Service Cloud:

  • Create custom fields for each language you want to use. For example, you could create custom fields called “Product Name (French)” and “Product Name (Spanish)”.
  • Populate the custom fields with the translated values for each language. For example, you could enter “Chaussures” in the “Product Name (French)” field for a product called “Shoes”.
  • Create a formula field on the Product2 object to display the appropriate translated value based on the user’s language preference. Here’s an example formula:
IF($User.LanguageLocaleKey = "fr_CA", Product_Name_French__c,
IF($User.LanguageLocaleKey = "es", Product_Name_Spanish__c, Name))

In this example, the formula checks the user’s language preference using the $User global variable.

If the user’s language is French (France), the formula displays the value of the “Product Name (French)” custom field. If the user’s language is Spanish, the formula displays the value of the “Product Name (Spanish)” custom field.

Otherwise, the formula displays the standard product name stored in the “Name” field.
 

Reindexing

As Akeneo PIM Connector is a multi-cloud solution, B2B Commerce is not required in the target org, but due to a huge load of products and the possibility to schedule product transfers automatically, we thought it would make sense to provide an automatic reindexing.

Due to these limitations, it was not possible to implement a custom solution to automatically trigger the product search index, however, we provide this reference implementation to easily help customers with B2B Commerce to implement an automatic reindexing every time a scheduled transfer is done.

For this reference implementation, we choose to use a flow and an apex invocable action.

Invocable action - ProductIndexing class

This class will implement the invocable method to be used in the flow. This method will be responsible to trigger the product search index, and generating a partial index for each provided webstore id.

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;
    }
}

Flow

The proposed flow is a record-triggered flow, that is triggered when an Akeneo Transfer Log record is updated with a successful status and categories or products were loaded. The customer should assign the target webstore name that the index should be built for so that then, the previously described apex action can be executed and trigger the product search partial index.

 

Metadata file of the described flow:

<?xml version="1.0" encoding="UTF-8"?>
<Flow xmlns="http://soap.sforce.com/2006/04/metadata">
    <actionCalls>
        <description>triggers product search index automatically</description>
        <name>Product_Reindex</name>
        <label>Product Reindex</label>
        <locationX>536</locationX>
        <locationY>587</locationY>
        <actionName>ProductIndexing</actionName>
        <actionType>apex</actionType>
        <flowTransactionModel>CurrentTransaction</flowTransactionModel>
        <inputParameters>
            <name>webstoreIds</name>
            <value>
                <elementReference>Get_Store_ID.Id</elementReference>
            </value>
        </inputParameters>
        <storeOutputAutomatically>true</storeOutputAutomatically>
    </actionCalls>
    <apiVersion>57.0</apiVersion>
    <assignments>
        <name>Assign_Webstore</name>
        <label>Assign Webstore</label>
        <locationX>536</locationX>
        <locationY>371</locationY>
        <assignmentItems>
            <assignToReference>varWebStoreName</assignToReference>
            <operator>Assign</operator>
            <value>
                <stringValue>Commerce Demo Store</stringValue>
            </value>
        </assignmentItems>
        <connector>
            <targetReference>Get_Store_ID</targetReference>
        </connector>
    </assignments>
    <constants>
        <name>webStoreName</name>
        <dataType>String</dataType>
    </constants>
    <description>Triggers the Product search index after transfer if it successful</description>
    <environments>Default</environments>
    <interviewLabel>Product Search Automatic Reindex {!$Flow.CurrentDateTime}</interviewLabel>
    <isTemplate>true</isTemplate>
    <label>Product Search Automatic Reindex</label>
    <processMetadataValues>
        <name>BuilderType</name>
        <value>
            <stringValue>LightningFlowBuilder</stringValue>
        </value>
    </processMetadataValues>
    <processMetadataValues>
        <name>CanvasMode</name>
        <value>
            <stringValue>FREE_FORM_CANVAS</stringValue>
        </value>
    </processMetadataValues>
    <processMetadataValues>
        <name>OriginBuilderType</name>
        <value>
            <stringValue>LightningFlowBuilder</stringValue>
        </value>
    </processMetadataValues>
    <processType>AutoLaunchedFlow</processType>
    <recordLookups>
        <name>Get_Store_ID</name>
        <label>Get Store ID</label>
        <locationX>536</locationX>
        <locationY>479</locationY>
        <assignNullValuesIfNoRecordsFound>false</assignNullValuesIfNoRecordsFound>
        <connector>
            <targetReference>Product_Reindex</targetReference>
        </connector>
        <filterLogic>and</filterLogic>
        <filters>
            <field>Name</field>
            <operator>EqualTo</operator>
            <value>
                <elementReference>varWebStoreName</elementReference>
            </value>
        </filters>
        <getFirstRecordOnly>true</getFirstRecordOnly>
        <object>WebStore</object>
        <queriedFields>Id</queriedFields>
        <storeOutputAutomatically>true</storeOutputAutomatically>
    </recordLookups>
    <start>
        <locationX>410</locationX>
        <locationY>48</locationY>
        <doesRequireRecordChangedToMeetCriteria>true</doesRequireRecordChangedToMeetCriteria>
        <filterLogic>1 AND ( 2 OR 3)</filterLogic>
        <filters>
            <field>akeneo__Status__c</field>
            <operator>EqualTo</operator>
            <value>
                <stringValue>SUCCESS</stringValue>
            </value>
        </filters>
        <filters>
            <field>akeneo__ProductsTotal__c</field>
            <operator>GreaterThan</operator>
            <value>
                <numberValue>0.0</numberValue>
            </value>
        </filters>
        <filters>
            <field>akeneo__CategoriesTotal__c</field>
            <operator>GreaterThan</operator>
            <value>
                <numberValue>0.0</numberValue>
            </value>
        </filters>
        <object>akeneo__Akeneo_Transfer_Log__c</object>
        <recordTriggerType>Update</recordTriggerType>
        <scheduledPaths>
            <connector>
                <targetReference>Assign_Webstore</targetReference>
            </connector>
            <pathType>AsyncAfterCommit</pathType>
        </scheduledPaths>
        <triggerType>RecordAfterSave</triggerType>
    </start>
    <status>Active</status>
    <variables>
        <name>varWebStoreName</name>
        <dataType>String</dataType>
        <isCollection>false</isCollection>
        <isInput>false</isInput>
        <isOutput>false</isOutput>
    </variables>
</Flow>

 

Product/Category Assignment

Data Model

The custom category data model directly matches the Salesforce standard data model. In each data model, there is an object to store the root-level Catalog, an object to store the multi-level Categories, and an object to store the link between the Product and the Category.


Synchronization

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.

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

Example:

Akeneo Product Category

Field Name Value
Id APC-1
Name E-Commerce
Code e_commerce

Product Category

Field Name Value
Id PC-1
Name E-Commerce
Code APC-1

 

The Code field doesn’t need to be copied to the standard object’s record. But if for some reason that’s required, a new field can be created and it can easily be copied as well.

 

 

Reference Implementation

Multiple Salesforce platform features can be leveraged to do the actual record synchronization. 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.
 

SyncProductCatalog

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

trigger SyncProductCatalog on akeneo__Akeneo_Product_Catalog__c (after insert, after update, after delete)
{
    if (Trigger.isInsert || Trigger.isUpdate)#
    {
        List<ProductCatalog> productCatalogs = new List<ProductCatalog>();
    
        for (akeneo__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 (akeneo__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

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

trigger SyncProductCategory on akeneo__Akeneo_Product_Category__c (after insert, after update, after delete)
{
    if (Trigger.isInsert || Trigger.isUpdate)
    {
        List<ProductCategory> productCategories = new List<ProductCategory>();
    
        for (akeneo__Akeneo_Product_Category__c akeneoProductCategory : Trigger.new)
        {
            ProductCategory productCategoryRecord = new ProductCategory();
            productCategoryRecord.Name = akeneoProductCategory.Name;
            productCategoryRecord.SortOrder = Integer.valueOf(
                akeneoProductCategory.akeneo__Sort_Order__c
            );
            productCategoryRecord.IsNavigational = true;
            productCategoryRecord.External_ID__c = akeneoProductCategory.Id;
    
            productCategoryRecord.Catalog = new ProductCatalog(
                External_ID__c = akeneoProductCategory.akeneo__Product_Catalog__c
            );
            
            if (akeneoProductCategory.akeneo__Parent_Category__c != null)
            {
                productCategoryRecord.ParentCategory = new ProductCategory(
                    External_ID__c = akeneoProductCategory.akeneo__Parent_Category__c
                );
            }
            productCategories.add(productCategoryRecord);
        }
    
        if (!productCategories.isEmpty())
        {
            upsert productCategories External_ID__c;
        }
    }

    if (Trigger.isDelete)
    {
        Set<String> productCategoryExternalIds = new Set<String>();
    
        for (akeneo__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

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

trigger SyncProductCategoryProduct on akeneo__Akeneo_Product_Category_Product__c (after insert, after update, after delete)
{
    if (Trigger.isInsert || Trigger.isUpdate)
    {
        List<ProductCategoryProduct> productCategories =
            new List<ProductCategoryProduct>();
    
        for (akeneo__Akeneo_Product_Category_Product__c akeneoProductCategoryProduct :
            Trigger.new)
        {
            productCategories.add(new ProductCategoryProduct(
                ProductId = akeneoProductCategoryProduct.akeneo__Product__c,
                ProductCategory = new ProductCategory(
                    External_ID__c = akeneoProductCategoryProduct.akeneo__Product_Category__c
                ),
                External_ID__c = akeneoProductCategoryProduct.Id
            ));
        }
    
        if (!productCategories.isEmpty())
        {
            upsert productCategories External_ID__c;
        }
    }

    if (Trigger.isDelete)
    {
        Set<String> productCategoryProductExternalIds = new Set<String>();
    
        for (akeneo__Akeneo_Product_Category_Product__c akeneoProductCategoryProduct :
            Trigger.old)
        {
    
            productCategoryProductExternalIds.add(akeneoProductCategoryProduct.Id);
        }

        List<ProductCategoryProduct> productCategoryProducts = [SELECT Id FROM
            ProductCategoryProduct WHERE External_ID__c IN
            :productCategoryProductExternalIds];
        if (!productCategoryProducts.isEmpty())
        {
            delete productCategoryProducts;
        }
    }
}

 

How to import content into Salesforce CMS?

You may first ask “Why import content into Salesforce CMS?” and this is a valid question.
Salesforce CMS is maintaining the content which can be used OOTB (out of the box) in Commerce solutions like B2B Commerce. If you manage to import your assets into CMS and you plan to drive a (B2B) store, you need to have those data at the proper place to have it available in PLP (product list page) and PDP (product detail page) pages.

Since the Salesforce Connector is by design an app that imports PIM data into Salesforce to be available for multi-cloud purposes, meaning for any kind of Salesforce cloud (not just commerce), we did not import into license-dependent objects like ProductMedia – the one you would need for a commerce store and OOTB stuff. 

 

Import using CMS features

Salesforce CMS provides an import/export solution whose intention is primarily dedicated to translation.

It can be used to import content like product media, but since it is not consistent between CMS 1.0 and the new CMS 2.0 we better focus on another, API-based approach.

For those who are interested anyways, there is a online documentation available by Salesforce:
Import Content into Salesforce CMS

The related JSON File Format is documented in the same place in a parallel documentation.

 

Importing using Connect REST API

With the following API, you are able to upload binary content directly into Salesforce CMS:
CMS Contents

Unfortunately, the API is only capable of importing on binary at once, so you would need multiple requests if you want to import multiple contents.

Additionally, you have to publish the imported records:
CMS Contents Publish

This time it is possible to publish a batch of records at once. 

There are also other API endpoints e.g. to unpublish records again.

Enhanced CMS (CMS 2.0) documentation can be found here:
View, Create, and Manage Content in Enhanced CMS Workspaces

 

Importing using CSV and Connect REST API

Salesforce provides an API that allows you to mass-import using CSV format.
It requires a call Connect REST API to start the import.
There is a store-related version: Commerce Product Import Resource
And a product-related one: Commerce Import Product Job, Create
The last one requires a later store association of products youz import that way for the case you want to use them in a commerce store.

The CSV import allows to import of up to 10k records per file, but you have to programmatically generate the CSV file and upload the content itself into Salesforce before you start the import.

Uploading of content can be made using metadata API: Metadata API Developer Guide

After this is done you can call the Connect REST API to start the import.

Pro: associations and publishing are done already via CSV; both CMS versions are supported
Limits: takes long, multiple hours; enhanced CMS reduces this by 50%

 

Importing using Apex

From within Apex programming language, you can use Enhanced CMS Workspaces Resources APIs.

Building a custom Apex solution can potentially have the option of parallel processing (to be tested) and it provides more flexibility. It can take over products, content, and associations at once.

To implement such a solution you have to provide our own Apex REST endpoint which then has to be called externally per media record (parallelizable) which creates managed content records using Apex and Connect API (Connext in Apex).
In particular, this means it requires a loop-back to call our own service.

Limits: Connect in Apex as well as we have for Connect REST API
Can be extended using 5 free API integration users provided by Salesforce

200 * 5 = 1000 potentially per hour

 

For more details and to get access to some example implementations, please contact Akeneo.