This commit is contained in:
2025-10-27 17:39:18 -04:00
commit 31f723bea4
1579 changed files with 642409 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
namespace Prefab.Catalog.Domain.Services;
public interface IUniqueChecker
{
Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default);
Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default);
Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default);
Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default);
Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,253 @@
using Microsoft.EntityFrameworkCore;
using Prefab.Catalog.Data;
using Prefab.Catalog.Domain.Entities;
using Prefab.Shared.Catalog.Products;
namespace Prefab.Catalog.Domain.Services;
/// <summary>
/// Calculates price quotes for configured catalog products by combining base price, numeric adjustments, and percent deltas.
/// </summary>
public interface IPricingService
{
/// <summary>
/// Produces a unit price and line-item breakdown for a shoppers selections.
/// </summary>
Task<QuotePrice.Response> QuoteAsync(QuotePrice.Request request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Default Catalog pricing implementation that reads product/option data on demand and applies the PercentScope rules.
/// </summary>
public sealed class PricingService(IModuleDbReadOnly db) : IPricingService
{
public async Task<QuotePrice.Response> QuoteAsync(QuotePrice.Request request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (request.ProductId is null && string.IsNullOrWhiteSpace(request.Sku))
{
throw new ArgumentException("Either ProductId or Sku must be supplied.", nameof(request));
}
var product = await FindProductAsync(request, cancellationToken)
?? throw new InvalidOperationException("Product could not be located for pricing.");
var optionsSource = product.Kind == ProductKind.Model
? product
: product.ParentProduct ?? throw new InvalidOperationException("Variant product is missing its model definition.");
var optionLookup = optionsSource.Options
.Where(o => o.DeletedOn is null)
.ToDictionary(o => o.Id);
var basePrice = product.Price ?? 0m;
var numericTotal = 0m;
var choiceAbsTotal = 0m;
var breakdownEntries = new List<SelectionBreakdown>();
foreach (var selection in request.Selections)
{
if (!optionLookup.TryGetValue(selection.OptionDefinitionId, out var option))
{
throw new InvalidOperationException($"Option definition '{selection.OptionDefinitionId}' was not found for product '{optionsSource.Id}'.");
}
switch (option.DataType)
{
case OptionDataType.Number:
{
var (delta, chosenDescription) = HandleNumber(option, selection);
numericTotal += delta;
breakdownEntries.Add(SelectionBreakdown.Fixed(option.Name, chosenDescription, delta));
break;
}
case OptionDataType.Choice:
{
if (selection.OptionValueId is null)
{
throw new InvalidOperationException($"Selection for option '{option.Name}' must include an option value identifier.");
}
var value = option.Values.FirstOrDefault(v => v.DeletedOn == null && v.Id == selection.OptionValueId)
?? throw new InvalidOperationException($"Option value '{selection.OptionValueId}' was not found for option '{option.Name}'.");
var chosenDescription = value.Label;
var delta = value.PriceDelta ?? 0m;
switch (value.PriceDeltaKind)
{
case PriceDeltaKind.Absolute:
choiceAbsTotal += delta;
breakdownEntries.Add(SelectionBreakdown.Fixed(option.Name, chosenDescription, delta));
break;
case PriceDeltaKind.Percent:
breakdownEntries.Add(SelectionBreakdown.Percent(option, value, chosenDescription, delta));
break;
default:
throw new NotSupportedException($"Price delta kind '{value.PriceDeltaKind}' is not supported.");
}
break;
}
default:
throw new NotSupportedException($"Option data type '{option.DataType}' is not supported.");
}
}
var percentSelections = breakdownEntries.Where(e => e.Kind == BreakdownKind.Percent).ToList();
var percentTotal = 0m;
PercentScope? selectedScope = null;
foreach (var entry in percentSelections)
{
var scope = entry.OptionDefinition.PercentScope ?? PercentScope.BaseOnly;
if (selectedScope is null)
{
selectedScope = scope;
}
else if (selectedScope.Value != scope)
{
throw new InvalidOperationException("Percent options for a product must share the same percent scope.");
}
var scopeBase = scope switch
{
PercentScope.BaseOnly => basePrice,
PercentScope.NumericOnly => numericTotal,
PercentScope.BasePlusNumeric => basePrice + numericTotal,
_ => basePrice
};
var delta = scopeBase * (entry.PercentValue / 100m);
entry.Value = delta;
percentTotal += delta;
}
var final = Math.Max(
0m,
Math.Round(basePrice + numericTotal + choiceAbsTotal + percentTotal, 2, MidpointRounding.AwayFromZero));
var breakdown = breakdownEntries
.Select(e => new QuotePrice.QuoteBreakdown(
e.Option,
e.Chosen,
Math.Round(e.Value, 2, MidpointRounding.AwayFromZero)))
.ToList();
return new QuotePrice.Response(final, breakdown);
}
/// <summary>
/// Resolves a model or variant to price, including the parent model so option metadata is always available.
/// </summary>
private async Task<Product?> FindProductAsync(QuotePrice.Request request, CancellationToken cancellationToken)
{
var products = db.Products
.Where(p => p.DeletedOn == null)
.AsNoTracking()
.Include(p => p.Options).ThenInclude(o => o.Values)
.Include(p => p.Options).ThenInclude(o => o.Tiers)
.Include(p => p.ParentProduct).ThenInclude(pp => pp!.Options).ThenInclude(o => o.Values)
.Include(p => p.ParentProduct).ThenInclude(pp => pp!.Options).ThenInclude(o => o.Tiers);
if (!string.IsNullOrWhiteSpace(request.Sku))
{
var sku = request.Sku!.Trim().ToUpperInvariant();
return await products.FirstOrDefaultAsync(p => p.Sku != null && p.Sku.ToUpper() == sku, cancellationToken);
}
return await products.FirstOrDefaultAsync(p => p.Id == request.ProductId, cancellationToken);
}
/// <summary>
/// Computes the price delta for numeric options with clamping, stepping, and tier evaluation.
/// </summary>
private static (decimal Delta, string ChosenDescription) HandleNumber(OptionDefinition option, QuotePrice.Selection selection)
{
if (selection.NumericValue is null)
{
throw new InvalidOperationException($"Selection for option '{option.Name}' must include a numeric value.");
}
var requested = selection.NumericValue.Value;
var clamped = option.Min.HasValue ? Math.Max(requested, option.Min.Value) : requested;
clamped = option.Max.HasValue ? Math.Min(clamped, option.Max.Value) : clamped;
if (option.Step is > 0)
{
var step = option.Step.Value;
clamped = Math.Round(clamped / step, MidpointRounding.AwayFromZero) * step;
if (option.Min.HasValue)
{
clamped = Math.Max(clamped, option.Min.Value);
}
if (option.Max.HasValue)
{
clamped = Math.Min(clamped, option.Max.Value);
}
}
var applicableTier = option.Tiers
.Where(t => t.DeletedOn == null && clamped >= t.FromInclusive && (t.ToInclusive == null || clamped <= t.ToInclusive.Value))
.OrderByDescending(t => t.FromInclusive)
.FirstOrDefault();
var rate = applicableTier?.UnitRate ?? option.PricePerUnit ?? 0m;
var flat = applicableTier?.FlatDelta ?? 0m;
var chosenDescription = option.Unit is { Length: > 0 }
? $"{clamped:0.##} {option.Unit}"
: clamped.ToString("0.##");
return ((rate * clamped) + flat, chosenDescription);
}
private enum BreakdownKind
{
Fixed,
Percent
}
private sealed class SelectionBreakdown
{
private SelectionBreakdown(
string option,
string chosen,
BreakdownKind kind,
OptionDefinition optionDefinition,
OptionValue? optionValue,
decimal value,
decimal percentValue)
{
Option = option;
Chosen = chosen;
Kind = kind;
OptionDefinition = optionDefinition;
OptionValue = optionValue;
Value = value;
PercentValue = percentValue;
}
public string Option { get; }
public string Chosen { get; }
public BreakdownKind Kind { get; }
public OptionDefinition OptionDefinition { get; }
public OptionValue? OptionValue { get; }
public decimal Value { get; set; }
public decimal PercentValue { get; }
public static SelectionBreakdown Fixed(string option, string chosen, decimal value) =>
new(option, chosen, BreakdownKind.Fixed, null!, null, value, 0m);
public static SelectionBreakdown Percent(OptionDefinition option, OptionValue value, string chosen, decimal percent) =>
new(option.Name, chosen, BreakdownKind.Percent, option, value, 0m, percent);
}
}

View File

@@ -0,0 +1,451 @@
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;
}
}

View File

@@ -0,0 +1,137 @@
using Prefab.Catalog.Domain.Entities;
namespace Prefab.Catalog.Domain.Services;
/// <summary>
/// Result of variant lookup based on the shopper's selections.
/// </summary>
public sealed class VariantResolutionResult
{
public VariantResolutionResult(Product? variant, IReadOnlyCollection<string> errors)
{
Variant = variant;
Errors = errors;
}
public Product? Variant { get; }
public IReadOnlyCollection<string> Errors { get; }
public bool IsSuccess => Errors.Count == 0;
}
/// <summary>
/// Resolves a model variant using axis selections or SKU.
/// </summary>
public sealed class VariantResolver
{
public VariantResolutionResult Resolve(Product model, IReadOnlyCollection<OptionSelection> selections, string? sku = null)
{
ArgumentNullException.ThrowIfNull(model);
ArgumentNullException.ThrowIfNull(selections);
var errors = new List<string>();
if (!string.IsNullOrWhiteSpace(sku))
{
var variant = model.Variants
.Where(v => v.DeletedOn is null)
.FirstOrDefault(v => string.Equals(v.Sku, sku, StringComparison.OrdinalIgnoreCase));
if (variant is null)
{
errors.Add($"Variant with SKU '{sku}' was not found for product '{model.Name}'.");
}
return new VariantResolutionResult(variant, errors);
}
var axisDefinitions = model.Options
.Where(o => o.DeletedOn is null && o.IsVariantAxis)
.ToList();
if (axisDefinitions.Count == 0)
{
// No variant axes means the base model should be used.
return new VariantResolutionResult(null, errors);
}
var selectionMap = selections
.Where(s => s.OptionValueId.HasValue)
.ToDictionary(s => s.OptionDefinitionId, s => s.OptionValueId!.Value);
foreach (var axis in axisDefinitions)
{
if (!selectionMap.ContainsKey(axis.Id))
{
errors.Add($"Selection for variant axis '{axis.Name}' is required.");
}
}
if (errors.Count > 0)
{
return new VariantResolutionResult(null, errors);
}
var matches = new List<Product>();
foreach (var variant in model.Variants.Where(v => v.DeletedOn is null))
{
var axisValues = GetAxisValuesForVariant(model, variant);
if (axisValues.Count == 0)
{
continue;
}
var isMatch = true;
foreach (var axis in axisDefinitions)
{
var expectedValue = selectionMap[axis.Id];
if (!axisValues.TryGetValue(axis.Id, out var actualValue) || actualValue != expectedValue)
{
isMatch = false;
break;
}
}
if (isMatch)
{
matches.Add(variant);
}
}
if (matches.Count == 1)
{
return new VariantResolutionResult(matches[0], errors);
}
if (matches.Count == 0)
{
errors.Add("No variant matches the provided selections.");
}
else
{
errors.Add("Multiple variants match the provided selections.");
}
return new VariantResolutionResult(null, errors);
}
private static IDictionary<Guid, Guid> GetAxisValuesForVariant(Product model, Product variant)
{
var axisValues = variant.AxisValues?
.ToDictionary(av => av.OptionDefinitionId, av => av.OptionValueId);
if (axisValues is not null && axisValues.Count > 0)
{
return axisValues;
}
axisValues = model.AxisValues?
.Where(av => av.ProductVariantId == variant.Id)
.ToDictionary(av => av.OptionDefinitionId, av => av.OptionValueId);
return axisValues ?? new Dictionary<Guid, Guid>();
}
}