Part 2 now available.
Intro.
Microsoft has now a full-blown web store front end. In the ever-changing and ever confusing product naming this is currently called Dynamics 365 Ecommerce, which is part of Dynamics 365 Commerce.
Dynamics 365 Commerce was formerly known as D365 Retail, which was formerly part of D365 Finance and Operations, which was formerly known as Dynamics AX. Confused yet? Join the club.
Ok, done with my usual MS product naming complaint, let’s get down to business.
In this article I’ll use:
- ‘D365F&O’ or ‘F&O’ for the ‘backend’ ERP system. Also referred to (in retail/commerce context) as ‘HQ’
- For the website/shop components I’ll use ‘Ecommerce’ or ‘Ecom’
- When referring to the retail development components around retail server, Commerce Scale Unit, POS, etc, I’ll use ‘RetailSDK’.
I’ll try to keep it consistent to keep confusion to a minimum.
Note: All the D365F&O and RetailSDK development (i.e. the CRT extension) was done with Visual Studio 2017.
The Ecom development was done with Visual Studio Code.
MS actually has some good documentation available on the architecture of the products, so I won’t get into that. However, it is not always clear on how all these pieces fit together.
In order to educate myself, and also to try out a request from a customer, I decided to see if I can complete this requirement:
“When a user adds an item to a cart on the Ecom site, the app should check if the item is marked as a warranty item. If it does, it should show a popup.”
Seems fairly straightforward, yes?
Steps and components to modify.
The following steps need to be taken to build this:
-
D365F&O backend (HQ) development
- Add ‘warranty’ field to InventTable
-
D365 Commerce Data Exchange (CDX) development
- Add field to channel DB
- Add field to CDX process that takes data from the D365 HQ to the channel DB
-
D365 Commerce / RetailSDK development
- Extend CRT: Expose new field to CRT API (Commerce Run Time)
-
D365 Ecom development
- Surface new field to Ecom site. (To keep things ‘simple’ I initially just create a new module and see if I can get to the field value)
1. D365F&O backend (HQ) development
This is super simple. Just extend the InventTable, and add the field to it. I created a new EDT that extends the YesNo Enum and added it as a new field to the extended table:
2. D365 Commerce Data Exchange (CDX) development
Here we implement the logic that is going to take the new field (and its value) and copy that to the channel DB. (This is the DB that POS/Ecom uses).
I used this doc for guidance:
https://docs.microsoft.com/en-us/dynamics365/commerce/dev-itpro/cdx-extensibility#cdx-sample---pull-new-columns-to-an-existing-table
First we need to create a resource file that is going to add our field to an existing Retail Scheduler Job.
Standard F&O has a job ‘1040’ which handles products, and has InventTable in it, so it makes sense to add it there:
Then we need to Subscribe to the ‘registerCDXSeedDataExtension’ delegate (it’s all in the link above):
We build the project, and then run ‘Initialize commerce scheduler’ (Delete configuration = ‘yes’) in F&O.
When done, check the subjob:
Extend Channel DB
Now that we have all that setup, we need to create the field in the Channel DB, so that the CDX has a spot to save the data. Details can be found here:
https://docs.microsoft.com/en-us/dynamics365/commerce/dev-itpro/channel-db-extensions
The way it works is that we need to create a table in the [ext] schema in the Channel DB with the same name as the table we are extending (InventTable), and the same primary index.
You will need to write an SQL query that does this for you. The easiest way to do this is to find the table you want to extend in the AX schema using SQL Server Management Studio. Then right-click on the table and select 'Script table as' -> 'CREATE To' -> New Query Editor window.
In the new query window you edit the query to only use the [EXT] Schema instead of [AX], then remove all fields except the key fields used in the primary index, and add your extension field(s):
/****** Object: Table [ext].[INVENTTABLE] Script Date: 3/25/2021 11:42:05 AM ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [ext].[INVENTTABLE](
[RECID] [bigint] NOT NULL,
[ITEMID] [nvarchar](100) NOT NULL,
[ITKISWARRANTY] [int] NOT NULL,
[DATAAREAID] [nvarchar](4) NOT NULL,
[ROWVERSION] [timestamp] NOT NULL,
CONSTRAINT [I_ITKINVENTTABLEEXTENSION_PRIMKEY] PRIMARY KEY CLUSTERED
(
[ITEMID] ASC,
[DATAAREAID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY])
GO
ALTER TABLE [ext].[INVENTTABLE] ADD CONSTRAINT [DF_INVENTTABLE_ITEMID] DEFAULT ('') FOR [ITEMID]
GO
GRANT INSERT ON [ext].[INVENTTABLE] TO [DataSyncUsersRole];
GO
GRANT DELETE ON [ext].[INVENTTABLE] TO [DataSyncUsersRole];
GO
GRANT UPDATE ON [ext].[INVENTTABLE] TO [DataSyncUsersRole];
GO
GRANT SELECT ON [ext].[INVENTTABLE] TO [DataSyncUsersRole];
GO
Deployment of extended table script.
I have not tested the deployment (yet) in a tier 2 environment, but the script needs to be part of your deployable package. See here:
https://docs.microsoft.com/en-us/dynamics365/commerce/dev-itpro/retail-sdk/retail-sdk-packaging#database-scripts
3. D365 Commerce / RetailSDK development
Now that we have our field in the Channel DB, we need to expose it in the CRT API.
I used this scenario as an example:
https://docs.microsoft.com/en-us/dynamics365/commerce/dev-itpro/commerce-runtime-extensibility#scenario
This is where things get complicated and I spent a LOT of time trying to figure it out.
In short: Ecom calls the retail API (hosted on the Retail server, which is part of the Scale Unit).
The Retail API basically is just a pass-through to the CRT API.
The key thing is that you need to know what CRT API is called for the process you want to modify.
For example, when product data is read from the Channel DB before it is added to your cart, that’s what we need to extend, as that is where we need to ‘add’ our new field.
I don’t think there is a direct relation or documentation that explains that Ecom Data Action ‘A’ calls which Retail API ‘B’, which calls CRT API ‘C’.
To keep things simple I just started with the Ecom ‘SimpleProduct’ action, and through trial and error (and checking the Commerce Server Event log), I figured out that it called the CRT ‘GetProductsDataRequest’ API.
So that means that I should add a post-trigger to the CRT ‘GetProductsDataRequest’ API.
The code below is by no means ‘production ready’, it is just POC code to test this out. If you have any remarks/corrections, then please comment below.
tip: In order to have your request trigger executed, you need to copy the compiled DLL to your retailservers' 'ext' folder ('K:\RetailServer\WebRoot\bin\Ext' in my case), and add the name of the DLL to the 'CommerceRuntime.Ext.config' file in the same directory. Then restart IIS to make sure the new DLL gets picked up.
What we are doing is that when the standard API has collected the product data, we implement a ‘post’ trigger that uses the retrieved ItemId to get the record from our extension table.
Once found, the result is added to the response as an ‘extension property’:
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Dynamics.Commerce.Runtime;
using Microsoft.Dynamics.Commerce.Runtime.Data;
using Microsoft.Dynamics.Commerce.Runtime.DataModel;
using Microsoft.Dynamics.Commerce.Runtime.Messages;
using Microsoft.Dynamics.Commerce.Runtime.DataServices.Messages;
namespace ITK.Commerce.Runtime.GetProductsExtended
{
/// <summary>
/// Class that implements a post trigger for the GetProductsDataRequest request type.
/// </summary>
public class ITKGetProductsDataRequestTrigger : IRequestTriggerAsync
{
/// <summary>
/// Gets the supported request for this trigger
/// </summary>
public IEnumerable<Type> SupportedRequestTypes
{
get
{
return new[] { typeof(GetProductsDataRequest) };
}
}
public async Task OnExecuted(Request request, Response response)
{
ThrowIf.Null(request, "request");
ThrowIf.Null(response, "response");
EntityDataServiceResponse<SimpleProduct> resp = response as EntityDataServiceResponse<SimpleProduct>;
SimpleProduct item = resp.PagedEntityCollection.Results[0];
var query = new SqlPagedQuery(QueryResultSettings.SingleRecord)
{
DatabaseSchema = "ext",
Select = new ColumnSet(new string[] { "ITKISWARRANTY" }),
From = "INVENTTABLE",
Where = "ITEMID = @itemId AND DATAAREAID = @dataAreaId"
};
query.Parameters["@itemId"] = item.ItemId;
query.Parameters["@dataAreaId"] = request.RequestContext.GetChannelConfiguration().InventLocationDataAreaId;
using (var dataBaseContext = new DatabaseContext(request.RequestContext))
{
var extensionResponse = await dataBaseContext.ReadEntityAsync<ExtensionsEntity>(query).ConfigureAwait(false);
ExtensionsEntity extensions = extensionResponse.FirstOrDefault();
var itkIsWarranty = extensions != null ? extensions.GetProperty("ITKISWARRANTY") : null;
if (itkIsWarranty != null)
{
item.SetProperty("ITKISWARRANTY", itkIsWarranty);
}
}
}
/// <summary>
/// Pre trigger code.
/// </summary>
/// <param name="request">The request.</param>
public async Task OnExecuting(Request request)
{
// Stub only to handle async signature.
await Task.CompletedTask;
}
}
}
I spent a lot of time trying to figure out if I now needed to extend the Retail Server API, as the documentation states that the Retail Server API calls CRT.
I’ll save you all the things I tried and looked into: it’s not needed.
The retail server API will ‘automagically’ expose the new field. (Through the extension properties framework – recall that we added the field value as a property to the response in the ‘post’ trigger above).
To make sure this all worked, and without having to worry about anything related to Ecom, I decided to create a console app that calls the retail API to make sure my new field is being returned.
See here: https://docs.microsoft.com/en-us/dynamics365/commerce/dev-itpro/consume-retail-server-api
And more specifically here (don’t skip the previous link though – you need those steps): https://docs.microsoft.com/en-us/dynamics365/commerce/dev-itpro/consume-retail-server-api#access-the-retail-server-apis-by-using-a-console-application
There was also a section in the documentation that said that we needed to (re)generate TypeScript Proxies. However, for this scenario that is not required, since we only extended an existing API.
If you create a new crt API, then you also need to create a new Retail API to call your CRT API, and you need to generate the proxies.
Code for the console app below. The parts that are commented out can be used if you extend the ‘GetProductPartsDataRequest’ API:
using Microsoft.Dynamics.Commerce.RetailProxy;
using Microsoft.Dynamics.Commerce.RetailProxy.Authentication;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Configuration;
using System.Threading.Tasks;
namespace ITKCommerceConsApp
{
class ITKCallRetailApi
{
private static string clientId;
private static string clientSecret;
private static Uri retailServerUrl;
private static string resource;
private static string operatingUnitNumber;
private static Uri authority;
private static void GetConfiguration()
{
clientId = ConfigurationManager.AppSettings["aadClientId"];
clientSecret = ConfigurationManager.AppSettings["aadClientSecret"];
authority = new Uri(ConfigurationManager.AppSettings["aadAuthority"]);
retailServerUrl = new Uri(ConfigurationManager.AppSettings["retailServerUrl"]);
operatingUnitNumber = ConfigurationManager.AppSettings["operatingUnitNumber"];
resource = ConfigurationManager.AppSettings["resource"];
}
private static async Task<ManagerFactory> CreateManagerFactory()
{
Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext authenticationContext =
new Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext(authority.ToString(), false);
AuthenticationResult authResult = null;
authResult = await authenticationContext.AcquireTokenAsync(resource,
new ClientCredential(clientId, clientSecret));
ClientCredentialsToken clientCredentialsToken = new ClientCredentialsToken(authResult.AccessToken);
RetailServerContext retailServerContext = RetailServerContext.Create(retailServerUrl,
operatingUnitNumber,
clientCredentialsToken);
ManagerFactory factory = ManagerFactory.Create(retailServerContext);
return factory;
}
private static async Task<Microsoft.Dynamics.Commerce.RetailProxy.PagedResult<SalesOrder>> ITKGetOrderHistory(string customerId)
{
QueryResultSettings querySettings = new QueryResultSettings
{
Paging = new PagingInfo() { Top = 10, Skip = 10 }
};
ManagerFactory managerFactory = await CreateManagerFactory();
ICustomerManager customerManager = managerFactory.GetManager<ICustomerManager>();
return await customerManager.GetOrderHistory(customerId, querySettings);
}
private static async Task<Microsoft.Dynamics.Commerce.RetailProxy.PagedResult<SimpleProduct>>
ITKGetProduct(long productRecId)
{
QueryResultSettings querySettings = new QueryResultSettings
{
Paging = new PagingInfo() { Top = 10, Skip = 0 }
};
ManagerFactory managerFactory = await CreateManagerFactory();
IProductManager productManager = managerFactory.GetManager<IProductManager>();
List<long> productIds = new List<long>();
productIds.Add(productRecId);
// 68719478279 is he recid of the channel I'm using for testing (Fabrikam Online Store)
return await productManager.GetByIds(68719478279, productIds, querySettings);
// Code below is working when using a CRT trigger extension for GetProductPartsDataRequest
/*
QueryResultSettings querySettings = new QueryResultSettings
{
Paging = new PagingInfo() { Top = 10, Skip = 0 }
};
ProductLookupClause productLookupClause = new ProductLookupClause();
productLookupClause.ItemId = productId;
System.Collections.ObjectModel.ObservableCollection<ProductLookupClause> lookupClauseList =
new System.Collections.ObjectModel.ObservableCollection<ProductLookupClause>();
lookupClauseList.Add(productLookupClause);
List<IObservable<ProductLookupClause>> productLookupClauseList = new List<IObservable<ProductLookupClause>>();
productLookupClauseList.Add(productLookupClause as IObservable<ProductLookupClause>);
ProductSearchCriteria productSearchCriteria = new ProductSearchCriteria();
productSearchCriteria.ItemIds = lookupClauseList;
//NOTE: ProjectionDomain/Context is required when calling productManager.Search
ProjectionDomain projectionDomain = new ProjectionDomain();
projectionDomain.ChannelId = 68719478278;
productSearchCriteria.Context = projectionDomain;
ManagerFactory managerFactory = await CreateManagerFactory();
IProductManager productManager = managerFactory.GetManager<IProductManager>();
return await productManager.Search(productSearchCriteria, querySettings);
*/
}
static void Main(string[] args)
{
GetConfiguration();
// Code that is commented out below can be used to call sales order history
/*
Microsoft.Dynamics.Commerce.RetailProxy.PagedResult<SalesOrder> orderHistory = Task.Run(async() => await ITKGetOrderHistory("2001")).Result;
IEnumerator orderEnum = orderHistory.Results.GetEnumerator();
while (orderEnum.MoveNext())
{
SalesOrder order = orderEnum.Current as SalesOrder;
Console.WriteLine(order.SalesId);
}
*/
// For the Fabrikam Online Store item 91009 is 'Round Oversized Sunglasses"
Microsoft.Dynamics.Commerce.RetailProxy.PagedResult<SimpleProduct> itkProducts =
Task.Run(async () => await ITKGetProduct(68719498121)).Result; //This is the recid of the product in the ECORESPRODUCT table
IEnumerator resultEnum = itkProducts.Results.GetEnumerator();
while (resultEnum.MoveNext())
{
SimpleProduct item = resultEnum.Current as SimpleProduct;
Console.WriteLine(item.RecordId);
Console.WriteLine(item.Description);
Console.WriteLine();
}
Console.WriteLine("Press Enter");
Console.ReadLine();
}
}
}
To recap:
The extension executes a separate query to get the referenced record from the '[ext].Inventtable' extension table.
Then the new field(s) get added to the entity that is returned:
Snippet from the CRT extension class 'ITKGetProductsDataRequestTrigger.cs':
var itkIsWarranty = extensions != null ? extensions.GetProperty("ITKISWARRANTY") : null;
if (itkIsWarranty != null)
{
item.SetProperty("ITKISWARRANTY", itkIsWarranty);
}
Then these can be accessed from the client / caller like this:
4. D365 Ecom development
Surface new field to Ecom site.
(To keep things ‘simple’ I initially just create a new module and see if I can get to the field value).
To get started with this I used Sam Jarawan’s (Microsoft) excellent Ecom development starter guide.
I setup my E-Com development environment and linked it to a ‘regular’ D365F&O Cloud Hosted Environment (CHE). This is just a regular F&O dev box.
The environment does NOT have an E-Com website linked to it, but we don’t need that for our purpose. It does have a retail server, and that’s all we need at the moment. (After all, that’s where we built our CRT extension explained above).
Follow Sam’s doc to create a new ‘product-feature’ module. For this POC I did the bare minimum, but you can follow along the doc for a good idea on how things stick together.
In our example above, we extended the ‘GetProductsDataRequest’, which is called by the Ecom ‘get-simple-product’ action.
DISCLAIMER: the part below is how I currently think it works, I could be wrong in some of my assumptions, but in the end I had a working POC, so I can’t be all that wrong 😊
Using the generated/modified ‘product-feature’ module example from the doc mentioned above:
File ‘Product-feature.tsx’:
Here we reference ‘productsDataAction’:
class ProductFeature extends React.PureComponent<IProductFeatureProps<IProductFeatureData>> {
public render(): JSX.Element | null {
const {
config,
data: { productsDataAction }
} = this.props;
This is executed when we load the module, as this references this:
File ‘Product-feature.data.tsx’:
export interface IProductFeatureData {
productsDataAction: AsyncResult<SimpleProduct>[];
}
This tells me that the return object is of type 'SimpleProduct'.
File ‘Product-feature.definition.json’:
"dataActions": {
"productsDataAction": {
"path": "@msdyn365-commerce-modules/retail-actions/dist/lib/get-simple-products",
"runOn": "server"
}
This is where my 'productDataAction' is mapped to the actual E-Com data action.
Looking at the file referenced in product-feature.definition.json ("@msdyn365-commerce-modules/retail-actions/dist/lib/get-simple-products"):
This led me to find that this called the Retail API with ‘ProductManager.GetByIds’, so to recap the full stack:
- E-Com DataAction: get-simple-products
- Retail Server API: ProductManager.GetByIds
- CRT: POST Request Trigger GetProductsDataRequest
- Response = EntityDataServiceResponse
Here are the E-Com changes I made to get this to work:
product-feature.data.ts:
Added the data action:
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { AsyncResult, SimpleProduct } from '@msdyn365-commerce/retail-proxy';
export interface IProductFeatureData {
productsDataAction: AsyncResult<SimpleProduct>[];
}
Product-feature-definition.json:
Reference the data action that I added in 'product-feature.data.ts:'
{
"$type": "contentModule",
"friendlyName": "product-feature",
"name": "product-feature",
"description": "Custom Product Feature Module",
"categories": ["product-feature"],
"tags": ["Additional tags go here"],
"dataActions": {
"productsDataAction": {
"path": "@msdyn365-commerce-modules/retail-actions/dist/lib/get-simple-products",
"runOn": "server"
}
},
"config": {
"productTitle": {
"friendlyName": "Product Title",
"description": "The Product Title",
"type": "string"
},
"productDetails": {
"friendlyName": "Product Details",
"description": "Product Details",
"type": "richText"
},
"productPrice": {
"friendlyName": "Product Price",
"description": "Product Price",
"type": "string"
}
},
"resources": {
"resourceKey": {
"comment": "resource description",
"value": "resource value from product-feature.definition.json"
}
}
}
Product-feature.tsx
Here we process the data as it comes back from the API call. The interesting stuff happens of course with the warranty field, as that is what we added as a new field. The other fields in here are fields that were already part of this API call.
You can see that the field and value are passed as extension properties, ‘key’ being the field name and ‘value’ being the actual value of the field:
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as React from 'react';
import { IProductFeatureData } from './product-feature.data';
import { IProductFeatureProps } from './product-feature.props.autogenerated';
export interface IProductFeatureViewProps extends IProductFeatureProps<IProductFeatureData> {
productName: string;
productInfo: string;
productPrice: string;
productWarranty: string;
}
/**
*
* ProductFeature component
* @extends {React.PureComponent<IProductFeatureProps<IProductFeatureData>>}
*/
class ProductFeature extends React.PureComponent<IProductFeatureProps<IProductFeatureData>> {
public render(): JSX.Element | null {
const {
config,
data: { productsDataAction }
} = this.props;
// Set default values
let ProductName = config.productTitle ? config.productTitle : 'No product title defined.';
let ProductInfo = config.productDetails ? config.productDetails : 'No product details defined.';
let ProductPrice = '130';
let ProductWarranty = 'No Value';
// If we get the product details from the retail server, then we use that
if (productsDataAction && productsDataAction.length) {
ProductName =
productsDataAction[0].result && productsDataAction[0].result.Name
? productsDataAction[0].result.Name
: 'No product data from Retail Server';
ProductInfo =
productsDataAction[0].result && productsDataAction[0].result.Description
? productsDataAction[0].result.Description
: 'No product data from Retail Server';
ProductPrice =
productsDataAction[0].result && productsDataAction[0].result.Price
? `$${productsDataAction[0].result.Price}`
: 'No product data from Retail Server';
if (productsDataAction[0].result && productsDataAction[0].result.ExtensionProperties) {
ProductWarranty = 'Result and result properties returned';
let i = 0;
while (i < productsDataAction[0].result.ExtensionProperties.length) {
if (productsDataAction[0].result.ExtensionProperties[i].Key === 'ITKISWARRANTY') {
if (productsDataAction[0].result.ExtensionProperties[i].Value) {
ProductWarranty = JSON.stringify(productsDataAction[0].result.ExtensionProperties[i].Value?.IntegerValue);
} else {
ProductWarranty = 'Warranty not specified for item';
}
i++;
}
}
}
}
const ProductFeatureViewProps = {
...this.props,
productName: ProductName,
productInfo: ProductInfo,
productPrice: ProductPrice,
productWarranty: ProductWarranty
};
return this.props.renderView(ProductFeatureViewProps);
}
}
export default ProductFeature;
product-feature-view.tsx:
And finally we modify the view to show our handy work:
/*!
* Copyright (c) Microsoft Corporation.
* All rights reserved. See LICENSE in the project root for license information.
*/
import * as React from 'react';
import { IProductFeatureViewProps } from './product-feature';
export default (props: IProductFeatureViewProps) => {
return (
<div className='row'>
<h2>Product Title: {props.productName}</h2>
<h2>Product Description: {props.productInfo}</h2>
<h2>Product Price: {props.productPrice}</h2>
<h2>Product Warranty: {props.productWarranty}</h2>
</div>
);
};
Now that we have it all setup, let’s see if it works:
The nice thing is that due to the way I wrote the code in the 'product-feature.tsx' file, is that right away I know if the data is pulled from the retail server or not.
So now, let’s change the warranty field on our item in F&O (I added it to the UI):
Now we run Retail Scheduler Job 1040 (Products) to push the update to the Channel DB, wait until the updates are applied, and then check again:
Success!
Note: initially the update did not show. I was thinking (hoping) it might be a caching issue, and indeed after I killed and restarted the yarn command, it showed the update:
This is by no means a complete how to, but hopefully it can provide some value.
Top comments (0)