Promotion criteria in Ucommerce are used to determine when to trigger a discount on an order.
Ucommerce has built-in criteria for handling most use cases. However, if the need arises, it's quite easy to create a custom criterion.
Create a Custom Criterion
Creating a Definition
To create a definition, follow these steps:
Create a definition and add it to your database. The definition must be of the criterion definition type.
Add appropriate definition fields to it. Built-in Ucommerce data types will work out of the box.
If you wish to be able to reuse the criterion for future projects we recommend you create a background service to automate this process.
Example: buy-less-than Criterion
Below is an example of setting up a criterion that triggers only when a customer is buying less than a given quantity.
publicclassSetupCustomCriterion:BackgroundService{privatereadonlyIServiceProvider _serviceProvider; /// <inheritdoc />publicSetupCustomCriterion(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } /// <inheritdoc />protectedoverrideasyncTaskExecuteAsync(CancellationToken stoppingToken) {awaitusingvar asyncScope =_serviceProvider.CreateAsyncScope();var dbContext =asyncScope.ServiceProvider.GetRequiredService<UcommerceDbContext>();if (dbContext.Set<DefinitionEntity>().Any(x =>x.Name=="Buy At Most")) {return; } // Set up data using dbContext // Find the TargetOrderLine data typevar dataType =dbContext.Set<DataTypeEntity>() .FirstOrDefault(x =>x.Guid.ToString() =="b5295545-5583-41e1-8c93-7bc921c09e27");var defFields =newList<DefinitionFieldEntity>();defFields.Add(newDefinitionFieldEntity { Name ="Max Amount", DataTypeId =3, DisplayOnSite =true, DefaultValue ="0", RenderInEditor =true, Guid =Guid.Parse("42085d44-3e81-4d94-aad8-8699ae7d35b0") });defFields.Add(newDefinitionFieldEntity { Name ="Target Orderline", DefaultValue ="0", DisplayOnSite =true, RenderInEditor =true, DataType = dataType, Guid =Guid.Parse("aa793b3f-2baf-45fb-a162-cb675e8d9b64") });dbContext.Set<DefinitionEntity>() .Add(newDefinitionEntity { BuiltIn =false, Description ="buy at Most criterion definition", Name ="Buy At Most", DefinitionTypeId =5434834,//Id of the Criterion Definition Type DefinitionFields = defFields, Guid =Guid.Parse("99ab73d5-22ee-425c-97f8-f9794ed01944") });awaitdbContext.SaveChangesAsync(stoppingToken); }}
Using explicit GUIDs makes subsequent steps easier.
Background services are run on every startup, so it is important to have a check to prevent multiple additions of the same values.
Implementing a Pipeline Task for Satisfaction Check
To trigger a criterion we need to extend the SatisfiedCriteria pipeline with a pipeline task that checks whether the criterion is satisfied. There are four unique concepts to be aware of regarding this task:
CriterionDTO is a DTO connecting a criterion with its properties.
context.Output.CriterionDtos is a dictionary of CriterionDTO grouped by their definition.
Context.Output.SatisfiedOrderLineCriteria is a dictionary of lists containing criteria grouped by the order line that satisfied them. Use this for order line-level criteria.
context.Output.SatisfiedOrderCriteria is a list of criteria that are satisfied by the order. Use this for order-level criteria.
Remember to register your pipeline task through the pipeline builder.
Example
Here's an example of a pipeline task for checking if the buy-at-most criterion is triggered by an order. Since it can target both line- and order-level this task can handle both cases. This will not be necessary in most cases.
/// <summary> /// Task for checking buyAtMost criteria. /// </summary>publicclassBuyAtMostCriteriaPipelineTask:AbstractPipelineTask<SatisfiedCriteriaInput,SatisfiedCriteriaOutput>{ /// <inheritdoc /> public override Task Execute(PipelineContext<SatisfiedCriteriaInput, SatisfiedCriteriaOutput> context, CancellationToken cancellationToken)
{if (!context.Output.CriterionDtos.TryGetValue( Guid.Parse("99ab73d5-22ee-425c-97f8-f9794ed01944"), outvarbuyAtMostCriterionDtos)) {returnTask.CompletedTask; }var orderLineBuyAtMostCriterionDtos =buyAtMostCriterionDtos.Where(x =>x.Properties["aa793b3f-2baf-45fb-a162-cb675e8d9b64"] .Value==BuyAtMostCriteriaConstants.ORDER_LINE_ENUM) .ToImmutableList();var orderLevelBuyAtMostCriterionDtos =buyAtMostCriterionDtos.Where(x =>x.Properties["aa793b3f-2baf-45fb-a162-cb675e8d9b64"] .Value==BuyAtMostCriteriaConstants.ORDER_ENUM) .ToImmutableList();var satisfiedCriteriaOrderLevel =GetBuyAtMostCriteriaOrderLevel( orderLevelBuyAtMostCriterionDtos,context.Output.Cart.OrderLines.ToImmutableList()) .ToImmutableList(); context.Output.SatisfiedOrderCriteria =context.Output.SatisfiedOrderCriteria .Concat(satisfiedCriteriaOrderLevel) .ToImmutableList();var satisfiedCriteriaOrderLineLevel =GetBuyAtMostCriteriaOrderLineLevel( orderLineBuyAtMostCriterionDtos,context.Output.Cart.OrderLines.ToImmutableList()) .ToImmutableList();foreach (var satisfiedCriterion in satisfiedCriteriaOrderLineLevel) {if (context.Output.SatisfiedOrderLineCriteria.TryAdd(satisfiedCriterion.OrderLine,ImmutableList.Create(satisfiedCriterion.Criterion))) {continue; }context.Output.SatisfiedOrderLineCriteria[satisfiedCriterion.OrderLine] =context.Output.SatisfiedOrderLineCriteria[satisfiedCriterion.OrderLine] .Add(satisfiedCriterion.Criterion); } return Task.CompletedTask; } /// <summary> /// Gets the buyAtMost criteria that are satisfied by the given order lines. /// </summary> /// <returns>IEnumerable of buyAtMost criteria where the buyAtMost of the order /// lines given is above the threshold if the TargetOrderLine is true. /// If the TargetOrderline is false it instead checks if the order lines in total /// have a higher buyAtMost than the threshold.</returns>protectedvirtualIEnumerable<CriterionEntity> GetBuyAtMostCriteriaOrderLevel(ImmutableList<CriterionDTO> criterionDtos,IReadOnlyCollection<OrderLineEntity> orderLines) {foreach (var criterionDto in criterionDtos) {var generated =orderLines.Where(o =>o.Properties .Any(p =>p.Key==Constants.OrderProperties.GENERATED)) .ToList();var any =orderLines.Where(o =>!generated.Contains(o)) .Sum(x =>x.Quantity) <=int.Parse(criterionDto.Properties["42085d44-3e81-4d94-aad8-8699ae7d35b0"] .Value!);if (any) {yieldreturncriterionDto.Criterion; } } } /// <summary> /// Gets the buyAtMost criteria that are satisfied by the given order lines. /// </summary> /// <returns>IEnumerable of buyAtMost criteria where the buyAtMost of the order /// lines given is above the threshold if the TargetOrderLine is true. /// If the TargetOrderline is false it instead checks if the order lines in total /// have a higher buyAtMost that the threshold.</returns>protectedvirtualIEnumerable<SatisfiedOrderlineDTO> GetBuyAtMostCriteriaOrderLineLevel(ImmutableList<CriterionDTO> criterionDtos,IReadOnlyCollection<OrderLineEntity> orderLines) {var generated =orderLines.Where(o =>o.Properties .Any(p =>p.Key==Constants.OrderProperties.GENERATED)) .ToList();var viableOrderLines =orderLines.Where(o =>!generated.Contains(o));foreach (var criterionDto in criterionDtos) {foreach (var orderLine in viableOrderLines) {if (orderLine.Quantity<=int.Parse(criterionDto.Properties["42085d44-3e81-4d94-aad8-8699ae7d35b0"] .Value!)) {yieldreturnnewSatisfiedOrderlineDTO(orderLine,criterionDto.Criterion); } } } }}
Validation (Optional)
It is possible to set validation rules for a criterion using FluentValidation. To make this easier, inherit from UpdateCriteriaInputValidatorBase in namespace Ucommerce.Web.BackOffice.Validators.Promotions.Criteria.UpdateCriteria to set rules for your custom criteria.
UpdateCriteriaInputValidatorBase contains the method RuleForUpdatePropertyValue(string propertyName) where propertyName is the definition field´s name.
After creating a validator it needs to be registered as a service in the program.cs file.
Example
Here's an example of custom rules for the Buy-At-Most criterion that was created earlier.
publicclassUpdateCriteriaBuyAtMostCriteriaValidator:UpdateCriteriaInputValidatorBase{ /// <summary> /// Initializes a new instance of the <seecref="UpdateCriteriaQuantityCriteriaValidator"/> class. /// </summary>publicUpdateCriteriaBuyAtMostCriteriaValidator(): base("99ab73d5-22ee-425c-97f8-f9794ed01944") {RuleForUpdatePropertyValue("Max Amount") .Required() .GreaterThan(2) .WithMessage("Must be a positive number higher than 2");RuleForUpdatePropertyEnum("Target Orderline") .Required() .HasEnumValue(CriteriaTypeConstants.QuantityCriterion.ORDER_ENUM, CriteriaTypeConstants.QuantityCriterion.ORDER_LINE_ENUM)
.WithMessage("Must be either 'Order' or 'Order Line'"); }}
RuleForUpdatePropertyValue is case sensitive so the string must exactly match the definition field name in the database.
Tips for Reusability
To facilitate reuse of a custom criterion across projects, it is recommended to create an extension method that registers the needed services.
This is an example of a method for registering the different parts of a custom criterion: