269 lines
8.2 KiB
C#
269 lines
8.2 KiB
C#
using Prefab.Domain.Common;
|
|
|
|
namespace Prefab.Catalog.Domain.Entities;
|
|
|
|
/// <summary>
|
|
/// Identifies what entity a rule set controls (option definition vs individual value).
|
|
/// </summary>
|
|
public enum OptionRuleTargetKind : byte
|
|
{
|
|
OptionDefinition = 0,
|
|
OptionValue = 1
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result to apply when rule conditions evaluate to true.
|
|
/// </summary>
|
|
public enum RuleEffect : byte
|
|
{
|
|
Show = 0,
|
|
Enable = 1,
|
|
Require = 2
|
|
}
|
|
|
|
/// <summary>
|
|
/// Defines whether all or any conditions must be satisfied.
|
|
/// </summary>
|
|
public enum RuleMode : byte
|
|
{
|
|
All = 0,
|
|
Any = 1
|
|
}
|
|
|
|
/// <summary>
|
|
/// Operators supported when evaluating rule conditions against shopper selections.
|
|
/// </summary>
|
|
public enum RuleOperator : byte
|
|
{
|
|
Equal = 0,
|
|
NotEqual = 1,
|
|
InList = 2,
|
|
NotInList = 3,
|
|
GreaterThan = 4,
|
|
GreaterThanOrEqual = 5,
|
|
LessThan = 6,
|
|
LessThanOrEqual = 7,
|
|
Between = 8
|
|
}
|
|
|
|
/// <summary>
|
|
/// Groups conditions that control visibility, enablement, or requirement for an option or value.
|
|
/// </summary>
|
|
public class OptionRuleSet : EntityWithAuditAndStatus<Guid>
|
|
{
|
|
private OptionRuleSet()
|
|
{
|
|
}
|
|
|
|
public Guid ProductId { get; internal set; }
|
|
|
|
public Product Product { get; internal set; } = null!;
|
|
|
|
public OptionRuleTargetKind TargetKind { get; internal set; }
|
|
|
|
public Guid TargetId { get; internal set; }
|
|
|
|
public RuleEffect Effect { get; internal set; }
|
|
|
|
public RuleMode Mode { get; internal set; }
|
|
|
|
public List<OptionRuleCondition> Conditions { get; } = [];
|
|
|
|
public static OptionRuleSet Create(
|
|
Product product,
|
|
OptionRuleTargetKind targetKind,
|
|
Guid targetId,
|
|
RuleEffect effect,
|
|
RuleMode mode)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(product);
|
|
|
|
if (targetKind == OptionRuleTargetKind.OptionDefinition)
|
|
{
|
|
_ = product.Options.FirstOrDefault(o => o.Id == targetId)
|
|
?? throw new ArgumentException($"Option definition '{targetId}' does not belong to product '{product.Id}'.", nameof(targetId));
|
|
}
|
|
else
|
|
{
|
|
var ownsValue = product.Options
|
|
.SelectMany(o => o.Values)
|
|
.Any(v => v.Id == targetId);
|
|
if (!ownsValue)
|
|
{
|
|
throw new ArgumentException($"Option value '{targetId}' does not belong to product '{product.Id}'.", nameof(targetId));
|
|
}
|
|
}
|
|
|
|
var ruleSet = new OptionRuleSet
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Product = product,
|
|
ProductId = product.Id,
|
|
TargetKind = targetKind,
|
|
TargetId = targetId,
|
|
Effect = effect,
|
|
Mode = mode
|
|
};
|
|
|
|
product.RuleSets.Add(ruleSet);
|
|
return ruleSet;
|
|
}
|
|
|
|
public OptionRuleCondition AddCondition(
|
|
OptionDefinition leftOptionDefinition,
|
|
RuleOperator @operator,
|
|
Guid? rightOptionValueId = null,
|
|
string? rightList = null,
|
|
decimal? rightNumber = null,
|
|
decimal? rightMin = null,
|
|
decimal? rightMax = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(leftOptionDefinition);
|
|
if (leftOptionDefinition.ProductId != ProductId)
|
|
{
|
|
throw new ArgumentException($"Option definition '{leftOptionDefinition.Id}' does not belong to product '{ProductId}'.", nameof(leftOptionDefinition));
|
|
}
|
|
|
|
ValidateRightHandSide(leftOptionDefinition, @operator, rightOptionValueId, rightList, rightNumber, rightMin, rightMax);
|
|
|
|
OptionValue? rightOptionValue = null;
|
|
if (rightOptionValueId.HasValue)
|
|
{
|
|
rightOptionValue = leftOptionDefinition.Values.FirstOrDefault(v => v.Id == rightOptionValueId.Value);
|
|
if (rightOptionValue is null)
|
|
{
|
|
rightOptionValue = Product.Options
|
|
.SelectMany(o => o.Values)
|
|
.FirstOrDefault(v => v.Id == rightOptionValueId.Value)
|
|
?? throw new ArgumentException($"Option value '{rightOptionValueId.Value}' was not found for product '{ProductId}'.", nameof(rightOptionValueId));
|
|
}
|
|
}
|
|
|
|
var condition = new OptionRuleCondition
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
RuleSet = this,
|
|
RuleSetId = Id,
|
|
LeftOptionDefinition = leftOptionDefinition,
|
|
LeftOptionDefinitionId = leftOptionDefinition.Id,
|
|
Operator = @operator,
|
|
RightOptionValue = rightOptionValue,
|
|
RightOptionValueId = rightOptionValueId,
|
|
RightList = string.IsNullOrWhiteSpace(rightList) ? null : rightList,
|
|
RightNumber = rightNumber,
|
|
RightMin = rightMin,
|
|
RightMax = rightMax
|
|
};
|
|
|
|
Conditions.Add(condition);
|
|
return condition;
|
|
}
|
|
|
|
private static void ValidateRightHandSide(
|
|
OptionDefinition leftOptionDefinition,
|
|
RuleOperator @operator,
|
|
Guid? rightOptionValueId,
|
|
string? rightList,
|
|
decimal? rightNumber,
|
|
decimal? rightMin,
|
|
decimal? rightMax)
|
|
{
|
|
var isChoice = leftOptionDefinition.DataType == OptionDataType.Choice;
|
|
|
|
switch (@operator)
|
|
{
|
|
case RuleOperator.Equal:
|
|
case RuleOperator.NotEqual:
|
|
if (isChoice)
|
|
{
|
|
if (rightOptionValueId is null && string.IsNullOrWhiteSpace(rightList))
|
|
{
|
|
throw new ArgumentException("Choice comparisons require a right option value identifier or list.", nameof(rightOptionValueId));
|
|
}
|
|
}
|
|
else if (rightNumber is null)
|
|
{
|
|
throw new ArgumentException("Numeric comparisons require a numeric value.", nameof(rightNumber));
|
|
}
|
|
break;
|
|
|
|
case RuleOperator.InList:
|
|
case RuleOperator.NotInList:
|
|
if (string.IsNullOrWhiteSpace(rightList))
|
|
{
|
|
throw new ArgumentException("List-based comparisons require a comma delimited list.", nameof(rightList));
|
|
}
|
|
break;
|
|
|
|
case RuleOperator.GreaterThan:
|
|
case RuleOperator.GreaterThanOrEqual:
|
|
case RuleOperator.LessThan:
|
|
case RuleOperator.LessThanOrEqual:
|
|
if (rightNumber is null)
|
|
{
|
|
throw new ArgumentException("Range comparisons require a numeric value.", nameof(rightNumber));
|
|
}
|
|
break;
|
|
|
|
case RuleOperator.Between:
|
|
if (rightMin is null || rightMax is null)
|
|
{
|
|
throw new ArgumentException("Between comparisons require both minimum and maximum values.", nameof(rightMin));
|
|
}
|
|
break;
|
|
default:
|
|
throw new ArgumentOutOfRangeException(nameof(@operator), @operator, "Unsupported rule operator.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A single conditional expression applied to option selections.
|
|
/// </summary>
|
|
public class OptionRuleCondition : EntityWithAuditAndStatus<Guid>
|
|
{
|
|
internal OptionRuleCondition()
|
|
{
|
|
}
|
|
|
|
public Guid RuleSetId { get; internal set; }
|
|
|
|
public OptionRuleSet RuleSet { get; internal set; } = null!;
|
|
|
|
public Guid LeftOptionDefinitionId { get; internal set; }
|
|
|
|
public OptionDefinition LeftOptionDefinition { get; internal set; } = null!;
|
|
|
|
public RuleOperator Operator { get; internal set; }
|
|
|
|
public Guid? RightOptionValueId { get; internal set; }
|
|
|
|
public OptionValue? RightOptionValue { get; internal set; }
|
|
|
|
public string? RightList { get; internal set; }
|
|
|
|
public decimal? RightNumber { get; internal set; }
|
|
|
|
public decimal? RightMin { get; internal set; }
|
|
|
|
public decimal? RightMax { get; internal set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records which option value combination maps to an individual variant SKU.
|
|
/// </summary>
|
|
public class VariantAxisValue
|
|
{
|
|
public Guid ProductVariantId { get; set; }
|
|
|
|
public Product ProductVariant { get; set; } = null!;
|
|
|
|
public Guid OptionDefinitionId { get; set; }
|
|
|
|
public OptionDefinition OptionDefinition { get; set; } = null!;
|
|
|
|
public Guid OptionValueId { get; set; }
|
|
|
|
public OptionValue OptionValue { get; set; } = null!;
|
|
}
|