using System.Globalization; using Prefab.Catalog.Domain.Entities; namespace Prefab.Catalog.Domain.Services; /// /// Represents a caller selection for an option definition. /// public sealed record OptionSelection(Guid OptionDefinitionId, Guid? OptionValueId, decimal? NumericValue); /// /// Result produced after applying rule evaluation to a set of selections. /// public sealed class RuleEvaluationResult { public RuleEvaluationResult(IReadOnlyCollection selections, IReadOnlyCollection errors) { Selections = selections; Errors = errors; } public IReadOnlyCollection Selections { get; } public IReadOnlyCollection Errors { get; } public bool IsValid => Errors.Count == 0; } /// /// Applies OptionRuleSets to a model to determine visibility, enablement, and required selections. /// public sealed class RuleEvaluator { public RuleEvaluationResult Evaluate(Product model, IReadOnlyCollection 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(); var hiddenValues = new HashSet(); var disabledDefinitions = new HashSet(); var disabledValues = new HashSet(); var requiredDefinitions = new HashSet(); var requiredValuesByDefinition = new Dictionary>(); var errors = new List(); 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> groupedRuleSets, IReadOnlyDictionary selectionMap, IReadOnlyDictionary optionById, IReadOnlyDictionary valueById, ISet hiddenDefinitions, ISet 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 PruneHiddenSelections( IReadOnlyCollection selections, ISet hiddenDefinitions, ISet hiddenValues) { var result = new List(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> groupedRuleSets, IReadOnlyDictionary selectionMap, IReadOnlyDictionary optionById, IReadOnlyDictionary valueById, ISet hiddenDefinitions, ISet disabledDefinitions, ISet 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 RejectDisabledSelections( IReadOnlyCollection selections, ISet disabledDefinitions, ISet disabledValues, IReadOnlyDictionary optionById, IReadOnlyDictionary valueById, ICollection errors) { var result = new List(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> groupedRuleSets, IReadOnlyDictionary selectionMap, IReadOnlyDictionary optionById, IReadOnlyDictionary valueById, ISet hiddenDefinitions, ISet requiredDefinitions, IDictionary> 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(); requiredValuesByDefinition[optionValue.OptionDefinitionId] = set; } set.Add(targetId); } } } private static void EnsureRequirementsMet( IReadOnlyCollection selections, IReadOnlyDictionary optionById, ISet requiredDefinitions, IDictionary> requiredValuesByDefinition, ICollection 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 selectionMap, IReadOnlyDictionary optionById, IReadOnlyDictionary 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 selectionMap, IReadOnlyDictionary optionById, IReadOnlyDictionary 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 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; } }