Files
2025-10-27 17:39:18 -04:00

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