452 lines
17 KiB
C#
452 lines
17 KiB
C#
using System.Globalization;
|
|
using Prefab.Catalog.Domain.Entities;
|
|
|
|
namespace Prefab.Catalog.Domain.Services;
|
|
|
|
/// <summary>
|
|
/// Represents a caller selection for an option definition.
|
|
/// </summary>
|
|
public sealed record OptionSelection(Guid OptionDefinitionId, Guid? OptionValueId, decimal? NumericValue);
|
|
|
|
/// <summary>
|
|
/// Result produced after applying rule evaluation to a set of selections.
|
|
/// </summary>
|
|
public sealed class RuleEvaluationResult
|
|
{
|
|
public RuleEvaluationResult(IReadOnlyCollection<OptionSelection> selections, IReadOnlyCollection<string> errors)
|
|
{
|
|
Selections = selections;
|
|
Errors = errors;
|
|
}
|
|
|
|
public IReadOnlyCollection<OptionSelection> Selections { get; }
|
|
|
|
public IReadOnlyCollection<string> Errors { get; }
|
|
|
|
public bool IsValid => Errors.Count == 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies OptionRuleSets to a model to determine visibility, enablement, and required selections.
|
|
/// </summary>
|
|
public sealed class RuleEvaluator
|
|
{
|
|
public RuleEvaluationResult Evaluate(Product model, IReadOnlyCollection<OptionSelection> selections)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(model);
|
|
ArgumentNullException.ThrowIfNull(selections);
|
|
|
|
var optionById = model.Options
|
|
.Where(o => o.DeletedOn is null)
|
|
.ToDictionary(o => o.Id);
|
|
|
|
var valueById = model.Options
|
|
.Where(o => o.DeletedOn is null)
|
|
.SelectMany(o => o.Values.Where(v => v.DeletedOn is null))
|
|
.ToDictionary(v => v.Id);
|
|
|
|
var selectionMap = selections.ToDictionary(s => s.OptionDefinitionId);
|
|
|
|
var visibleRuleSets = model.RuleSets
|
|
.Where(r => r.DeletedOn is null)
|
|
.GroupBy(r => (r.Effect, r.TargetKind, r.TargetId))
|
|
.ToList();
|
|
|
|
var hiddenDefinitions = new HashSet<Guid>();
|
|
var hiddenValues = new HashSet<Guid>();
|
|
var disabledDefinitions = new HashSet<Guid>();
|
|
var disabledValues = new HashSet<Guid>();
|
|
var requiredDefinitions = new HashSet<Guid>();
|
|
var requiredValuesByDefinition = new Dictionary<Guid, HashSet<Guid>>();
|
|
var errors = new List<string>();
|
|
|
|
EvaluateShowRules(visibleRuleSets, selectionMap, optionById, valueById, hiddenDefinitions, hiddenValues);
|
|
var prunedSelections = PruneHiddenSelections(selections, hiddenDefinitions, hiddenValues);
|
|
|
|
EvaluateEnableRules(visibleRuleSets, selectionMap, optionById, valueById, hiddenDefinitions, disabledDefinitions, disabledValues);
|
|
var enabledSelections = RejectDisabledSelections(prunedSelections, disabledDefinitions, disabledValues, optionById, valueById, errors);
|
|
|
|
EvaluateRequireRules(
|
|
visibleRuleSets,
|
|
selectionMap,
|
|
optionById,
|
|
valueById,
|
|
hiddenDefinitions,
|
|
requiredDefinitions,
|
|
requiredValuesByDefinition);
|
|
|
|
EnsureRequirementsMet(enabledSelections, optionById, requiredDefinitions, requiredValuesByDefinition, errors);
|
|
|
|
return new RuleEvaluationResult(enabledSelections, errors);
|
|
}
|
|
|
|
private static void EvaluateShowRules(
|
|
IEnumerable<IGrouping<(RuleEffect Effect, OptionRuleTargetKind TargetKind, Guid TargetId), OptionRuleSet>> groupedRuleSets,
|
|
IReadOnlyDictionary<Guid, OptionSelection> selectionMap,
|
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
|
IReadOnlyDictionary<Guid, OptionValue> valueById,
|
|
ISet<Guid> hiddenDefinitions,
|
|
ISet<Guid> hiddenValues)
|
|
{
|
|
foreach (var group in groupedRuleSets.Where(g => g.Key.Effect == RuleEffect.Show))
|
|
{
|
|
var shouldShow = group.Any(ruleSet => EvaluateRuleSet(ruleSet, selectionMap, optionById, valueById));
|
|
if (shouldShow)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (group.Key.TargetKind == OptionRuleTargetKind.OptionDefinition)
|
|
{
|
|
hiddenDefinitions.Add(group.Key.TargetId);
|
|
}
|
|
else
|
|
{
|
|
hiddenValues.Add(group.Key.TargetId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyCollection<OptionSelection> PruneHiddenSelections(
|
|
IReadOnlyCollection<OptionSelection> selections,
|
|
ISet<Guid> hiddenDefinitions,
|
|
ISet<Guid> hiddenValues)
|
|
{
|
|
var result = new List<OptionSelection>(selections.Count);
|
|
|
|
foreach (var selection in selections)
|
|
{
|
|
if (hiddenDefinitions.Contains(selection.OptionDefinitionId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (selection.OptionValueId.HasValue && hiddenValues.Contains(selection.OptionValueId.Value))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
result.Add(selection);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static void EvaluateEnableRules(
|
|
IEnumerable<IGrouping<(RuleEffect Effect, OptionRuleTargetKind TargetKind, Guid TargetId), OptionRuleSet>> groupedRuleSets,
|
|
IReadOnlyDictionary<Guid, OptionSelection> selectionMap,
|
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
|
IReadOnlyDictionary<Guid, OptionValue> valueById,
|
|
ISet<Guid> hiddenDefinitions,
|
|
ISet<Guid> disabledDefinitions,
|
|
ISet<Guid> disabledValues)
|
|
{
|
|
foreach (var group in groupedRuleSets.Where(g => g.Key.Effect == RuleEffect.Enable))
|
|
{
|
|
var targetId = group.Key.TargetId;
|
|
if (group.Key.TargetKind == OptionRuleTargetKind.OptionDefinition && hiddenDefinitions.Contains(targetId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var shouldEnable = group.Any(ruleSet => EvaluateRuleSet(ruleSet, selectionMap, optionById, valueById));
|
|
if (shouldEnable)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (group.Key.TargetKind == OptionRuleTargetKind.OptionDefinition)
|
|
{
|
|
disabledDefinitions.Add(group.Key.TargetId);
|
|
}
|
|
else
|
|
{
|
|
disabledValues.Add(group.Key.TargetId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyCollection<OptionSelection> RejectDisabledSelections(
|
|
IReadOnlyCollection<OptionSelection> selections,
|
|
ISet<Guid> disabledDefinitions,
|
|
ISet<Guid> disabledValues,
|
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
|
IReadOnlyDictionary<Guid, OptionValue> valueById,
|
|
ICollection<string> errors)
|
|
{
|
|
var result = new List<OptionSelection>(selections.Count);
|
|
|
|
foreach (var selection in selections)
|
|
{
|
|
if (disabledDefinitions.Contains(selection.OptionDefinitionId))
|
|
{
|
|
var option = optionById.GetValueOrDefault(selection.OptionDefinitionId);
|
|
var optionName = option?.Name ?? selection.OptionDefinitionId.ToString();
|
|
errors.Add($"Option '{optionName}' is disabled for the current configuration.");
|
|
continue;
|
|
}
|
|
|
|
if (selection.OptionValueId.HasValue && disabledValues.Contains(selection.OptionValueId.Value))
|
|
{
|
|
var value = valueById.GetValueOrDefault(selection.OptionValueId.Value);
|
|
var option = optionById.GetValueOrDefault(selection.OptionDefinitionId);
|
|
var optionName = option?.Name ?? selection.OptionDefinitionId.ToString();
|
|
var valueName = value?.Label ?? selection.OptionValueId.Value.ToString();
|
|
errors.Add($"Option value '{valueName}' for '{optionName}' is disabled for the current configuration.");
|
|
continue;
|
|
}
|
|
|
|
result.Add(selection);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static void EvaluateRequireRules(
|
|
IEnumerable<IGrouping<(RuleEffect Effect, OptionRuleTargetKind TargetKind, Guid TargetId), OptionRuleSet>> groupedRuleSets,
|
|
IReadOnlyDictionary<Guid, OptionSelection> selectionMap,
|
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
|
IReadOnlyDictionary<Guid, OptionValue> valueById,
|
|
ISet<Guid> hiddenDefinitions,
|
|
ISet<Guid> requiredDefinitions,
|
|
IDictionary<Guid, HashSet<Guid>> requiredValuesByDefinition)
|
|
{
|
|
foreach (var group in groupedRuleSets.Where(g => g.Key.Effect == RuleEffect.Require))
|
|
{
|
|
var targetId = group.Key.TargetId;
|
|
var isSatisfied = group.Any(ruleSet => EvaluateRuleSet(ruleSet, selectionMap, optionById, valueById));
|
|
if (!isSatisfied)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (group.Key.TargetKind == OptionRuleTargetKind.OptionDefinition)
|
|
{
|
|
if (!hiddenDefinitions.Contains(targetId))
|
|
{
|
|
requiredDefinitions.Add(targetId);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var optionValue = valueById.GetValueOrDefault(targetId);
|
|
if (optionValue is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (hiddenDefinitions.Contains(optionValue.OptionDefinitionId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!requiredValuesByDefinition.TryGetValue(optionValue.OptionDefinitionId, out var set))
|
|
{
|
|
set = new HashSet<Guid>();
|
|
requiredValuesByDefinition[optionValue.OptionDefinitionId] = set;
|
|
}
|
|
|
|
set.Add(targetId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void EnsureRequirementsMet(
|
|
IReadOnlyCollection<OptionSelection> selections,
|
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
|
ISet<Guid> requiredDefinitions,
|
|
IDictionary<Guid, HashSet<Guid>> requiredValuesByDefinition,
|
|
ICollection<string> errors)
|
|
{
|
|
var selectionMap = selections.ToDictionary(s => s.OptionDefinitionId);
|
|
|
|
foreach (var requiredDefinitionId in requiredDefinitions)
|
|
{
|
|
if (!selectionMap.TryGetValue(requiredDefinitionId, out var selection) || selection.OptionValueId is null && selection.NumericValue is null)
|
|
{
|
|
var option = optionById.GetValueOrDefault(requiredDefinitionId);
|
|
var optionName = option?.Name ?? requiredDefinitionId.ToString();
|
|
errors.Add($"Option '{optionName}' is required.");
|
|
}
|
|
}
|
|
|
|
foreach (var kvp in requiredValuesByDefinition)
|
|
{
|
|
if (!selectionMap.TryGetValue(kvp.Key, out var selection) || selection.OptionValueId is null)
|
|
{
|
|
var option = optionById.GetValueOrDefault(kvp.Key);
|
|
var optionName = option?.Name ?? kvp.Key.ToString();
|
|
errors.Add($"Option '{optionName}' requires a specific value.");
|
|
continue;
|
|
}
|
|
|
|
if (!kvp.Value.Contains(selection.OptionValueId.Value))
|
|
{
|
|
var option = optionById.GetValueOrDefault(kvp.Key);
|
|
var optionName = option?.Name ?? kvp.Key.ToString();
|
|
errors.Add($"Option '{optionName}' requires a different value for the current configuration.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool EvaluateRuleSet(
|
|
OptionRuleSet ruleSet,
|
|
IReadOnlyDictionary<Guid, OptionSelection> selectionMap,
|
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
|
IReadOnlyDictionary<Guid, OptionValue> valueById)
|
|
{
|
|
var conditions = ruleSet.Conditions.Where(c => c.DeletedOn is null).ToList();
|
|
if (conditions.Count == 0)
|
|
{
|
|
return ruleSet.Mode switch
|
|
{
|
|
RuleMode.All => true,
|
|
RuleMode.Any => false,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
var evaluations = conditions
|
|
.Select(condition => EvaluateCondition(condition, selectionMap, optionById, valueById))
|
|
.ToList();
|
|
|
|
return ruleSet.Mode switch
|
|
{
|
|
RuleMode.All => evaluations.All(result => result),
|
|
RuleMode.Any => evaluations.Any(result => result),
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
private static bool EvaluateCondition(
|
|
OptionRuleCondition condition,
|
|
IReadOnlyDictionary<Guid, OptionSelection> selectionMap,
|
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
|
IReadOnlyDictionary<Guid, OptionValue> valueById)
|
|
{
|
|
if (!optionById.TryGetValue(condition.LeftOptionDefinitionId, out var leftOption))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
selectionMap.TryGetValue(leftOption.Id, out var selection);
|
|
|
|
return leftOption.DataType switch
|
|
{
|
|
OptionDataType.Choice => EvaluateChoiceCondition(condition, leftOption, selection, valueById),
|
|
OptionDataType.Number => EvaluateNumericCondition(condition, selection),
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
private static bool EvaluateChoiceCondition(
|
|
OptionRuleCondition condition,
|
|
OptionDefinition option,
|
|
OptionSelection? selection,
|
|
IReadOnlyDictionary<Guid, OptionValue> valueById)
|
|
{
|
|
var selectedValueId = selection?.OptionValueId;
|
|
var selectedValue = selectedValueId.HasValue ? valueById.GetValueOrDefault(selectedValueId.Value) : null;
|
|
|
|
switch (condition.Operator)
|
|
{
|
|
case RuleOperator.Equal:
|
|
if (condition.RightOptionValueId.HasValue)
|
|
{
|
|
return selectedValueId.HasValue && selectedValueId.Value == condition.RightOptionValueId.Value;
|
|
}
|
|
return MatchesList(condition.RightList, selectedValue, selectedValueId);
|
|
|
|
case RuleOperator.NotEqual:
|
|
if (condition.RightOptionValueId.HasValue)
|
|
{
|
|
return !selectedValueId.HasValue || selectedValueId.Value != condition.RightOptionValueId.Value;
|
|
}
|
|
return !MatchesList(condition.RightList, selectedValue, selectedValueId);
|
|
|
|
case RuleOperator.InList:
|
|
return MatchesList(condition.RightList, selectedValue, selectedValueId);
|
|
|
|
case RuleOperator.NotInList:
|
|
return !MatchesList(condition.RightList, selectedValue, selectedValueId);
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool MatchesList(string? csv, OptionValue? selectedValue, Guid? selectedValueId)
|
|
{
|
|
if (selectedValueId is null || string.IsNullOrWhiteSpace(csv))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var tokens = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
foreach (var token in tokens)
|
|
{
|
|
if (Guid.TryParse(token, out var guidToken))
|
|
{
|
|
if (guidToken == selectedValueId.Value)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (selectedValue is not null && string.Equals(selectedValue.Code, token, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool EvaluateNumericCondition(OptionRuleCondition condition, OptionSelection? selection)
|
|
{
|
|
var selected = selection?.NumericValue;
|
|
if (selected is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return condition.Operator switch
|
|
{
|
|
RuleOperator.Equal => condition.RightNumber.HasValue && selected.Value == condition.RightNumber.Value,
|
|
RuleOperator.NotEqual => !condition.RightNumber.HasValue || selected.Value != condition.RightNumber.Value,
|
|
RuleOperator.InList => MatchesNumericList(condition.RightList, selected.Value),
|
|
RuleOperator.NotInList => !MatchesNumericList(condition.RightList, selected.Value),
|
|
RuleOperator.GreaterThan => condition.RightNumber.HasValue && selected.Value > condition.RightNumber.Value,
|
|
RuleOperator.GreaterThanOrEqual => condition.RightNumber.HasValue && selected.Value >= condition.RightNumber.Value,
|
|
RuleOperator.LessThan => condition.RightNumber.HasValue && selected.Value < condition.RightNumber.Value,
|
|
RuleOperator.LessThanOrEqual => condition.RightNumber.HasValue && selected.Value <= condition.RightNumber.Value,
|
|
RuleOperator.Between => condition.RightMin.HasValue && condition.RightMax.HasValue && selected.Value >= condition.RightMin.Value && selected.Value <= condition.RightMax.Value,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
private static bool MatchesNumericList(string? csv, decimal selected)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(csv))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var tokens = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
foreach (var token in tokens)
|
|
{
|
|
if (decimal.TryParse(token, NumberStyles.Number, CultureInfo.InvariantCulture, out var value))
|
|
{
|
|
if (value == selected)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|