Promotions: Sitecore Commerce promotions are based on Qualifications and Actions. In this article, I will mainly talk about Qualifications.
A qualification is a set of conditions that has to be true for a promotion to be applied to a cart. While each condition represents a Rule in Sitecore world. If you have worked with Rules in Sitecore, it’s the same concept. An example of such a condition would be “Customer is purchasing more than 5 items”.
When we define multiple conditions in qualification, we have to add logical condition operators between these conditions. e.g. “Customer is purchasing more than 5 items” And “One of the items in cart is Nike Product“. OOTB Sitecore Commerce supports two logical operators; And, Or. So the previous example can also be “Customer is purchasing more than 5 items” OR “One of the items in cart is Nike Shirt“
Recently working on a project we had to implement the following examples of qualifications.
- “Customer is purchasing more than 5 items” And “One of the items is Nike Product“
- “Customer is purchasing more than 5 items” And “None of the items is Nike Product“
- “Customer is purchasing more than 5 items” Or “One of the items is Nike Product“
Please consider Nike Product as a Category here. The above examples are just for reference. We had to implement a lot of those. Since most of the conditions were not available out of the box, we had to implement all the conditions. If you look at the examples above, there are following conditions that we need to have. In our case, most of the conditions we had to create because they were not available OOTB.
- Customer is purchasing more than 5 items
- One of the items is Nike Product
- None of the items is Nike Product
Condition 2 is inverse of 3. If we go down this path, we will be creating an inverse condition of each condition we wanted to have. So rather than doing that, I decided to add an AndNot operator in qualifications. This will give the flexibility to use any condition(OOTB or Custom) to be used as the inverse of itself.
The condition #2 in the example above became
“Customer is purchasing more than 5 items” AndNot “One of the items is Nike Product”
If you think of different examples of conditions we get OOTB, e.g. Customer has registered, if you want to inverse it, you will have to create a contrary condition for each. But if you add AndNot it will eliminate the need for it.
How I did: First you have to add AndNot option to the dropdown list of condition operators. For that, I had to override the block which adds And and Or operators to SelectList.
Important: Only custom code is under //Custom Code Started. Rest is copied from Sitecore assebmly
public class DoActionSelectQualificationBlockWithAndNot : PipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext> { private readonly IGetConditionsPipeline _getConditionsPipeline; private readonly IGetOperatorsPipeline _getOperatorsPipeline; /// <summary> /// Initializes a new instance of the <see cref="DoActionSelectQualificationBlock"/> class. /// </summary> /// <param name="getConditionsPipeline">The get conditions pipeline.</param> /// <param name="getOperatorsPipeline">The get operators pipeline.</param> public DoActionSelectQualificationBlockWithAndNot( IGetConditionsPipeline getConditionsPipeline, IGetOperatorsPipeline getOperatorsPipeline) { _getConditionsPipeline = getConditionsPipeline; _getOperatorsPipeline = getOperatorsPipeline; } /// <summary> /// The run. /// </summary> /// <param name="arg"> /// The argument. /// </param> /// <param name="context"> /// The context. /// </param> /// <returns> /// The <see cref="EntityView"/>. /// </returns> public override async Task<EntityView> Run(EntityView arg, CommercePipelineExecutionContext context) { if (string.IsNullOrEmpty(arg?.Action) || !arg.Action.Equals(context.GetPolicy<KnownPromotionsActionsPolicy>().SelectQualification, StringComparison.OrdinalIgnoreCase)) { return arg; } var promotion = context.CommerceContext.GetObjects<Promotion>().FirstOrDefault(p => p.Id.Equals(arg.EntityId, StringComparison.OrdinalIgnoreCase)); if (promotion == null) { return arg; } var selectedCondition = arg.Properties.FirstOrDefault(p => p.Name.Equals("Condition", StringComparison.OrdinalIgnoreCase)); if (string.IsNullOrEmpty(selectedCondition?.Value)) { var propertyDisplayName = selectedCondition == null ? "Condition" : selectedCondition.DisplayName; await context.CommerceContext.AddMessage( context.GetPolicy<KnownResultCodes>().ValidationError, "InvalidOrMissingPropertyValue", new object[] { propertyDisplayName }, "Invalid or missing value for property 'Condition'.").ConfigureAwait(false); return arg; } var availableConditions = (await _getConditionsPipeline.Run(typeof(ICondition), context.CommerceContext.PipelineContextOptions).ConfigureAwait(false))?.ToList(); var condition = availableConditions?.FirstOrDefault(c => c.LibraryId.Equals(selectedCondition.Value, StringComparison.OrdinalIgnoreCase)); if (condition == null) { await context.CommerceContext.AddMessage( context.GetPolicy<KnownResultCodes>().ValidationError, "InvalidOrMissingPropertyValue", new object[] { selectedCondition.DisplayName }, "Invalid or missing value for property 'Condition'.").ConfigureAwait(false); return arg; } selectedCondition.RawValue = condition.LibraryId; selectedCondition.IsReadOnly = true; var selectionPolicy = new AvailableSelectionsPolicy( availableConditions .Where(s => s.LibraryId.Equals(condition.LibraryId, StringComparison.OrdinalIgnoreCase)) .Select(c => new Selection { DisplayName = c.Name, Name = c.LibraryId }).ToList() ); selectedCondition.Policies.Clear(); selectedCondition.Policies = new List<Policy> { selectionPolicy }; selectedCondition.IsHidden = !condition.Properties.Any(); var propertyIndex = arg.Properties.FindIndex(p => p.Name.Equals("Condition", StringComparison.OrdinalIgnoreCase)); //Custom Code Started var viewProp = new ViewProperty(new List<Policy> { new AvailableSelectionsPolicy(new List<Selection> { new Selection { DisplayName = "And", Name = "And" }, new Selection { DisplayName = "Or", Name = "Or" }, new Selection { DisplayName = "AndNot", Name = "AndNot" } }) }) { Name = "ConditionOperator", RawValue = condition.ConditionOperator ?? string.Empty, IsHidden = !promotion.HasPolicy<PromotionQualificationsPolicy>(), IsRequired = promotion.HasPolicy<PromotionQualificationsPolicy>() }; //Custome Code Ended arg.Properties.Insert( propertyIndex, viewProp); foreach (var p in condition.Properties.Where(p => p.IsOperator)) { var viewProperty = new ViewProperty { Name = p.Name, RawValue = p.Value ?? string.Empty, OriginalType = p.DisplayType }; var type = string.IsNullOrEmpty(p.DisplayType) ? null : Type.GetType(p.DisplayType); var availableOperators = (await _getOperatorsPipeline.Run(type, context.CommerceContext.PipelineContextOptions).ConfigureAwait(false))?.ToList(); viewProperty.GetPolicy<AvailableSelectionsPolicy>().List.Clear(); viewProperty.GetPolicy<AvailableSelectionsPolicy>().List.AddRange(availableOperators.Any() ? availableOperators.Select(c => new Selection { DisplayName = c.Name, Name = c.Type }).ToList() : new List<Selection>()); arg.Properties.Add(viewProperty); } condition.Properties.Where(p => !p.IsOperator).ForEach( p => { var viewProperty = new ViewProperty { Name = p.Name, RawValue = p.Value ?? string.Empty, OriginalType = p.DisplayType }; arg.Properties.Add(viewProperty); }); context.CommerceContext.AddModel(new MultiStepActionModel(context.GetPolicy<KnownPromotionsActionsPolicy>().AddQualification)); return arg; } }
Then I registered this block using ConfigureSitecore
public void ConfigureServices(IServiceCollection services) { var assembly = Assembly.GetExecutingAssembly(); services.RegisterAllPipelineBlocks(assembly); services.Sitecore().Pipelines(config => config.ConfigurePipeline<IDoActionPipeline>(p => { p.Replace<DoActionSelectQualificationBlock, DoActionSelectQualificationBlockWithAndNot>(); }) ); services.RegisterAllCommands(assembly); }
After this, I started seeing AndNot in the list of condition operators
Not we need to handle the AndNot when it is selected. Sitecore commerce uses Sitecore Rules engine which has the option for AndNot. So I only had to map this new option to Sitecore rules engine operator.
I had to override a block again which gets executed when we create a promotion. This block translates these promotion conditions into Sitecore engine rules. I had to look for AndNot and map it to its counterpart in Sitecoe rules engine.
public class BuildRuleSetBlockWithAndNot: BuildRuleSetBlock { private readonly IEntityRegistry _entityRegistry; private IRuleBuilderInit _ruleBuilder = null; private readonly IServiceProvider _services; public BuildRuleSetBlockWithAndNot(IEntityRegistry entityRegistry, IServiceProvider services): base(entityRegistry, services) { this._entityRegistry = entityRegistry; this._services = services; this._ruleBuilder = _services.GetService<IRuleBuilderInit>(); } protected override IRule BuildRule(RuleModel model) { var firstConditionModel = model.Conditions.First<ConditionModel>(); //The firt conditions with no ConditionalOperator var firstConditionMetaData = _entityRegistry.GetMetadata(firstConditionModel.LibraryId); IRuleBuilder ruleBuilder = _ruleBuilder.When(firstConditionModel.ConvertToCondition(firstConditionMetaData, this._entityRegistry, this._services)); for (int index = 1; index < model.Conditions.Count; ++index) { var conditionModel = model.Conditions[index]; var conditionMetaData = this._entityRegistry.GetMetadata(conditionModel.LibraryId); var condition = conditionModel.ConvertToCondition(conditionMetaData, this._entityRegistry, this._services); if (!string.IsNullOrEmpty(conditionModel.ConditionOperator)) { if (conditionModel.ConditionOperator.ToUpperInvariant() == "OR") ruleBuilder.Or(condition); else if (conditionModel.ConditionOperator.ToUpperInvariant() == "ANDNOT") ruleBuilder.AndNot(condition); else ruleBuilder.And(condition); } } BuildElseAndThen(model, ruleBuilder); return ruleBuilder.ToRule(); } private void BuildElseAndThen(RuleModel model, IRuleBuilder ruleBuilder) { foreach (var thenAction in model.ThenActions) { var action = thenAction.ConvertToAction(_entityRegistry.GetMetadata(thenAction.LibraryId), this._entityRegistry, this._services); ruleBuilder.Then(action); } foreach (var elseAction in model.ElseActions) { var action = elseAction.ConvertToAction(_entityRegistry.GetMetadata(elseAction.LibraryId), this._entityRegistry, this._services); ruleBuilder.Else(action); } } }
And then register this block in ConfigureSitecore
public void ConfigureServices(IServiceCollection services) { var assembly = Assembly.GetExecutingAssembly(); services.RegisterAllPipelineBlocks(assembly); services.Sitecore().Pipelines(config => config.ConfigurePipeline<IBuildRuleSetPipeline>(p => { p.Replace<BuildRuleSetBlock, BuildRuleSetBlockWithAndNot>(); }) ); services.RegisterAllCommands(assembly); }
Happy coding!!