Init
This commit is contained in:
84
Prefab.Catalog/Domain/Entities/Attributes.cs
Normal file
84
Prefab.Catalog/Domain/Entities/Attributes.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using Prefab.Catalog.Domain.Events;
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Catalog.Domain.Entities;
|
||||
|
||||
public enum AttributeDataType
|
||||
{
|
||||
Text = 1,
|
||||
Number = 3,
|
||||
Boolean = 4,
|
||||
Enum = 5
|
||||
}
|
||||
|
||||
public class AttributeDefinition : EntityWithAuditAndStatus<Guid>
|
||||
{
|
||||
private AttributeDefinition()
|
||||
{
|
||||
}
|
||||
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
|
||||
public AttributeDataType DataType { get; private set; }
|
||||
|
||||
public string? Unit { get; private set; }
|
||||
|
||||
public static AttributeDefinition Create(string name, AttributeDataType dataType, string? unit = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
|
||||
var definition = new AttributeDefinition
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name.Trim(),
|
||||
DataType = dataType,
|
||||
Unit = string.IsNullOrWhiteSpace(unit) ? null : unit.Trim()
|
||||
};
|
||||
|
||||
definition.AddEvent(new AttributeDefinitionCreated(definition.Id, definition.Name, definition.DataType));
|
||||
return definition;
|
||||
}
|
||||
}
|
||||
|
||||
public class ProductAttributeValue : EntityWithAuditAndStatus<Guid>
|
||||
{
|
||||
private ProductAttributeValue()
|
||||
{
|
||||
}
|
||||
|
||||
public Guid ProductId { get; internal set; }
|
||||
|
||||
public Product Product { get; internal set; } = null!;
|
||||
|
||||
public Guid AttributeDefinitionId { get; internal set; }
|
||||
|
||||
public AttributeDefinition AttributeDefinition { get; internal set; } = null!;
|
||||
|
||||
public string? Value { get; internal set; }
|
||||
|
||||
public decimal? NumericValue { get; internal set; }
|
||||
|
||||
public string? UnitCode { get; internal set; }
|
||||
|
||||
public string? EnumCode { get; internal set; }
|
||||
|
||||
internal static ProductAttributeValue Create(
|
||||
Guid productId,
|
||||
Guid attributeDefinitionId,
|
||||
string? value,
|
||||
decimal? numericValue,
|
||||
string? unitCode,
|
||||
string? enumCode)
|
||||
{
|
||||
return new ProductAttributeValue
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProductId = productId,
|
||||
AttributeDefinitionId = attributeDefinitionId,
|
||||
Value = value,
|
||||
NumericValue = numericValue,
|
||||
UnitCode = unitCode,
|
||||
EnumCode = enumCode
|
||||
};
|
||||
}
|
||||
}
|
||||
113
Prefab.Catalog/Domain/Entities/Category.cs
Normal file
113
Prefab.Catalog/Domain/Entities/Category.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Prefab.Base.Catalog.Categories;
|
||||
using Prefab.Catalog.Domain.Events;
|
||||
using Prefab.Catalog.Domain.Exceptions;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Catalog.Domain.Entities;
|
||||
|
||||
public class Category : EntityWithAuditAndStatus<Guid>
|
||||
{
|
||||
private static readonly Regex SlugRegex = new("^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.Compiled);
|
||||
|
||||
private Category()
|
||||
{
|
||||
}
|
||||
|
||||
public Guid? ParentId { get; private set; }
|
||||
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
|
||||
public string? Description { get; private set; }
|
||||
|
||||
public string? Slug { get; private set; }
|
||||
|
||||
public int DisplayOrder { get; private set; }
|
||||
|
||||
public bool IsFeatured { get; private set; }
|
||||
|
||||
public string? HeroImageUrl { get; private set; }
|
||||
|
||||
public string? Icon { get; private set; }
|
||||
|
||||
public static async Task<Category> Create(string name, string? description, IUniqueChecker check, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentNullException.ThrowIfNull(check);
|
||||
|
||||
var trimmedName = name.Trim();
|
||||
var trimmedDescription = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
||||
|
||||
if (!await check.CategoryNameIsUnique(trimmedName, cancellationToken))
|
||||
{
|
||||
throw new DuplicateNameException(trimmedName, nameof(Category));
|
||||
}
|
||||
|
||||
var category = new Category
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = trimmedName,
|
||||
Description = trimmedDescription
|
||||
};
|
||||
|
||||
category.AddEvent(new CategoryCreated(category.Id, category.Name));
|
||||
return category;
|
||||
}
|
||||
|
||||
public async Task Rename(string newName, IUniqueChecker check, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(newName);
|
||||
ArgumentNullException.ThrowIfNull(check);
|
||||
|
||||
var trimmed = newName.Trim();
|
||||
if (string.Equals(trimmed, Name, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await check.CategoryNameIsUnique(trimmed, cancellationToken))
|
||||
{
|
||||
throw new DuplicateNameException(trimmed, nameof(Category));
|
||||
}
|
||||
|
||||
var oldName = Name;
|
||||
Name = trimmed;
|
||||
|
||||
AddEvent(new CategoryRenamed(Id, oldName, Name));
|
||||
}
|
||||
|
||||
public void ConfigureMetadata(string slug, int displayOrder, bool isFeatured, string? heroImageUrl, string? icon)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
|
||||
|
||||
var trimmedSlug = slug.Trim();
|
||||
if (trimmedSlug.Length > CategoryRules.SlugMaxLength)
|
||||
{
|
||||
throw new DomainValidationException($"Category slug cannot exceed {CategoryRules.SlugMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (!SlugRegex.IsMatch(trimmedSlug))
|
||||
{
|
||||
throw new DomainValidationException("Category slug must use lowercase letters, numbers, and hyphens.");
|
||||
}
|
||||
|
||||
var trimmedHeroImageUrl = string.IsNullOrWhiteSpace(heroImageUrl) ? null : heroImageUrl.Trim();
|
||||
if (trimmedHeroImageUrl is not null && trimmedHeroImageUrl.Length > CategoryRules.HeroImageUrlMaxLength)
|
||||
{
|
||||
throw new DomainValidationException($"Hero image URL cannot exceed {CategoryRules.HeroImageUrlMaxLength} characters.");
|
||||
}
|
||||
|
||||
var trimmedIcon = string.IsNullOrWhiteSpace(icon) ? null : icon.Trim();
|
||||
if (trimmedIcon is not null && trimmedIcon.Length > CategoryRules.IconMaxLength)
|
||||
{
|
||||
throw new DomainValidationException($"Icon cannot exceed {CategoryRules.IconMaxLength} characters.");
|
||||
}
|
||||
|
||||
Slug = trimmedSlug;
|
||||
DisplayOrder = displayOrder;
|
||||
IsFeatured = isFeatured;
|
||||
HeroImageUrl = trimmedHeroImageUrl;
|
||||
Icon = trimmedIcon;
|
||||
}
|
||||
}
|
||||
268
Prefab.Catalog/Domain/Entities/OptionRules.cs
Normal file
268
Prefab.Catalog/Domain/Entities/OptionRules.cs
Normal file
@@ -0,0 +1,268 @@
|
||||
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!;
|
||||
}
|
||||
543
Prefab.Catalog/Domain/Entities/Options.cs
Normal file
543
Prefab.Catalog/Domain/Entities/Options.cs
Normal file
@@ -0,0 +1,543 @@
|
||||
using Prefab.Base.Catalog.Options;
|
||||
using Prefab.Catalog.Domain.Events;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Prefab.Domain.Common;
|
||||
using DomainRuleException = Prefab.Domain.Exceptions.DomainException;
|
||||
|
||||
namespace Prefab.Catalog.Domain.Entities;
|
||||
|
||||
public enum OptionDataType
|
||||
{
|
||||
Choice = 0,
|
||||
Number = 1
|
||||
}
|
||||
|
||||
public enum PriceDeltaKind
|
||||
{
|
||||
Absolute = 0,
|
||||
Percent = 1
|
||||
}
|
||||
|
||||
public enum PercentScope
|
||||
{
|
||||
BaseOnly = 0,
|
||||
NumericOnly = 1,
|
||||
BasePlusNumeric = 2
|
||||
}
|
||||
|
||||
public class OptionDefinition : EntityWithAuditAndStatus<Guid>
|
||||
{
|
||||
private OptionDefinition()
|
||||
{
|
||||
}
|
||||
|
||||
public Guid ProductId { get; private set; }
|
||||
|
||||
public Product Product { get; private set; } = null!;
|
||||
|
||||
public string Code { get; private set; } = string.Empty;
|
||||
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
|
||||
public OptionDataType DataType { get; private set; }
|
||||
|
||||
public bool IsVariantAxis { get; private set; }
|
||||
|
||||
public string? Unit { get; private set; }
|
||||
|
||||
public decimal? Min { get; private set; }
|
||||
|
||||
public decimal? Max { get; private set; }
|
||||
|
||||
public decimal? Step { get; private set; }
|
||||
|
||||
public decimal? PricePerUnit { get; private set; }
|
||||
|
||||
public PercentScope? PercentScope { get; private set; }
|
||||
|
||||
public List<OptionValue> Values { get; } = [];
|
||||
|
||||
public List<OptionTier> Tiers { get; } = [];
|
||||
|
||||
public static async Task<OptionDefinition> CreateChoice(
|
||||
Product product,
|
||||
string code,
|
||||
string name,
|
||||
IUniqueChecker uniqueChecker,
|
||||
bool isVariantAxis = false,
|
||||
PercentScope? percentScope = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(product);
|
||||
if (product.Kind != ProductKind.Model)
|
||||
{
|
||||
throw new InvalidOperationException("Options can only be defined on product models.");
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(uniqueChecker);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(code);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
|
||||
var trimmedCode = code.Trim();
|
||||
var trimmedName = name.Trim();
|
||||
|
||||
if (trimmedCode.Length > OptionDefinitionRules.CodeMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Option code cannot exceed {OptionDefinitionRules.CodeMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (trimmedName.Length > OptionDefinitionRules.NameMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Option name cannot exceed {OptionDefinitionRules.NameMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (!await uniqueChecker.OptionCodeIsUniqueForProduct(product.Id, trimmedCode, cancellationToken))
|
||||
{
|
||||
throw new InvalidOperationException($"An option with code '{trimmedCode}' already exists for product '{product.Id}'.");
|
||||
}
|
||||
|
||||
var option = new OptionDefinition
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProductId = product.Id,
|
||||
Product = product,
|
||||
Code = trimmedCode,
|
||||
Name = trimmedName,
|
||||
DataType = OptionDataType.Choice,
|
||||
IsVariantAxis = isVariantAxis,
|
||||
PercentScope = percentScope
|
||||
};
|
||||
|
||||
product.Options.Add(option);
|
||||
|
||||
option.AddEvent(new OptionDefinitionCreated(product.Id, option.Id, option.Code, option.Name, OptionDataType.Choice.ToString(), isVariantAxis));
|
||||
return option;
|
||||
}
|
||||
|
||||
public static Task<OptionDefinition> CreateNumber(
|
||||
Product product,
|
||||
string code,
|
||||
string name,
|
||||
string unit,
|
||||
decimal? min,
|
||||
decimal? max,
|
||||
decimal? step,
|
||||
decimal? pricePerUnit,
|
||||
IUniqueChecker uniqueChecker,
|
||||
bool isVariantAxis = false,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CreateNumberInternal(product, code, name, unit, min, max, step, pricePerUnit, isVariantAxis, uniqueChecker, cancellationToken);
|
||||
|
||||
private static async Task<OptionDefinition> CreateNumberInternal(
|
||||
Product product,
|
||||
string code,
|
||||
string name,
|
||||
string unit,
|
||||
decimal? min,
|
||||
decimal? max,
|
||||
decimal? step,
|
||||
decimal? pricePerUnit,
|
||||
bool isVariantAxis,
|
||||
IUniqueChecker uniqueChecker,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(product);
|
||||
if (product.Kind != ProductKind.Model)
|
||||
{
|
||||
throw new InvalidOperationException("Options can only be defined on product models.");
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(uniqueChecker);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(code);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(unit);
|
||||
|
||||
var trimmedCode = code.Trim();
|
||||
var trimmedName = name.Trim();
|
||||
var trimmedUnit = unit.Trim();
|
||||
|
||||
if (trimmedCode.Length > OptionDefinitionRules.CodeMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Option code cannot exceed {OptionDefinitionRules.CodeMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (trimmedName.Length > OptionDefinitionRules.NameMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Option name cannot exceed {OptionDefinitionRules.NameMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (trimmedUnit.Length > OptionDefinitionRules.UnitMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Option unit cannot exceed {OptionDefinitionRules.UnitMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (min is not null && max is not null && min > max)
|
||||
{
|
||||
throw new ArgumentException("Minimum value cannot be greater than maximum.", nameof(min));
|
||||
}
|
||||
|
||||
if (step is not null && step <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(step), step, "Step must be greater than zero.");
|
||||
}
|
||||
|
||||
if (pricePerUnit is not null && pricePerUnit < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pricePerUnit), pricePerUnit, "Price per unit cannot be negative.");
|
||||
}
|
||||
|
||||
if (!await uniqueChecker.OptionCodeIsUniqueForProduct(product.Id, trimmedCode, cancellationToken))
|
||||
{
|
||||
throw new InvalidOperationException($"An option with code '{trimmedCode}' already exists for product '{product.Id}'.");
|
||||
}
|
||||
|
||||
var option = new OptionDefinition
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProductId = product.Id,
|
||||
Product = product,
|
||||
Code = trimmedCode,
|
||||
Name = trimmedName,
|
||||
DataType = OptionDataType.Number,
|
||||
IsVariantAxis = isVariantAxis,
|
||||
Unit = trimmedUnit,
|
||||
Min = min,
|
||||
Max = max,
|
||||
Step = step,
|
||||
PricePerUnit = pricePerUnit
|
||||
};
|
||||
|
||||
product.Options.Add(option);
|
||||
|
||||
option.AddEvent(new OptionDefinitionCreated(product.Id, option.Id, option.Code, option.Name, OptionDataType.Number.ToString(), isVariantAxis));
|
||||
return option;
|
||||
}
|
||||
|
||||
public OptionValue AddValue(
|
||||
string code,
|
||||
string label,
|
||||
decimal? priceDelta,
|
||||
PriceDeltaKind priceDeltaKind = PriceDeltaKind.Absolute)
|
||||
{
|
||||
if (DataType != OptionDataType.Choice)
|
||||
{
|
||||
throw new InvalidOperationException("Values can only be added to choice options.");
|
||||
}
|
||||
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(code);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(label);
|
||||
|
||||
var trimmedCode = code.Trim();
|
||||
var trimmedLabel = label.Trim();
|
||||
|
||||
if (trimmedCode.Length > OptionValueRules.CodeMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Option value code cannot exceed {OptionValueRules.CodeMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (trimmedLabel.Length > OptionValueRules.LabelMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Option value label cannot exceed {OptionValueRules.LabelMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (priceDelta is not null && priceDeltaKind == PriceDeltaKind.Percent &&
|
||||
(priceDelta < -100m || priceDelta > 100m))
|
||||
{
|
||||
throw new DomainRuleException("Percent-based price deltas must be between -100 and 100.");
|
||||
}
|
||||
|
||||
if (Values.Any(v => string.Equals(v.Code, trimmedCode, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new InvalidOperationException($"An option value with code '{trimmedCode}' already exists.");
|
||||
}
|
||||
|
||||
var value = OptionValue.Create(Id, this, trimmedCode, trimmedLabel, priceDelta, priceDeltaKind);
|
||||
|
||||
Values.Add(value);
|
||||
AddEvent(new OptionValueAdded(Id, value.Id, value.Code, value.Label, value.PriceDelta, value.PriceDeltaKind));
|
||||
return value;
|
||||
}
|
||||
|
||||
public void ChangeValue(Guid valueId, string? label, decimal? priceDelta, PriceDeltaKind? priceDeltaKind)
|
||||
{
|
||||
if (DataType != OptionDataType.Choice)
|
||||
{
|
||||
throw new InvalidOperationException("Values can only be changed for choice options.");
|
||||
}
|
||||
|
||||
var value = Values.FirstOrDefault(v => v.Id == valueId)
|
||||
?? throw new InvalidOperationException($"Option value '{valueId}' was not found.");
|
||||
|
||||
var changed = false;
|
||||
|
||||
if (label is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
{
|
||||
throw new ArgumentException("Label cannot be empty.", nameof(label));
|
||||
}
|
||||
|
||||
var trimmedLabel = label.Trim();
|
||||
|
||||
if (trimmedLabel.Length > OptionValueRules.LabelMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Option value label cannot exceed {OptionValueRules.LabelMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (!string.Equals(value.Label, trimmedLabel, StringComparison.Ordinal))
|
||||
{
|
||||
value.Label = trimmedLabel;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (priceDelta != value.PriceDelta)
|
||||
{
|
||||
var deltaKind = priceDeltaKind ?? value.PriceDeltaKind;
|
||||
if (priceDelta is not null && deltaKind == PriceDeltaKind.Percent &&
|
||||
(priceDelta < -100m || priceDelta > 100m))
|
||||
{
|
||||
throw new DomainRuleException("Percent-based price deltas must be between -100 and 100.");
|
||||
}
|
||||
|
||||
value.PriceDelta = priceDelta;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (priceDeltaKind.HasValue && priceDeltaKind.Value != value.PriceDeltaKind)
|
||||
{
|
||||
value.PriceDeltaKind = priceDeltaKind.Value;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
AddEvent(new OptionValueChanged(Id, value.Id));
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveValue(Guid valueId)
|
||||
{
|
||||
if (DataType != OptionDataType.Choice)
|
||||
{
|
||||
throw new InvalidOperationException("Values can only be removed from choice options.");
|
||||
}
|
||||
|
||||
var value = Values.FirstOrDefault(v => v.Id == valueId)
|
||||
?? throw new InvalidOperationException($"Option value '{valueId}' was not found.");
|
||||
|
||||
Values.Remove(value);
|
||||
AddEvent(new OptionValueRemoved(Id, value.Id));
|
||||
}
|
||||
|
||||
public OptionTier AddTier(
|
||||
decimal fromInclusive,
|
||||
decimal? toInclusive,
|
||||
decimal unitRate,
|
||||
decimal? flatDelta)
|
||||
{
|
||||
if (DataType != OptionDataType.Number)
|
||||
{
|
||||
throw new InvalidOperationException("Tiers can only be added to number options.");
|
||||
}
|
||||
|
||||
if (fromInclusive < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(fromInclusive), fromInclusive, "FromInclusive cannot be negative.");
|
||||
}
|
||||
|
||||
if (toInclusive is not null && toInclusive < fromInclusive)
|
||||
{
|
||||
throw new ArgumentException("ToInclusive must be greater than or equal to FromInclusive.", nameof(toInclusive));
|
||||
}
|
||||
|
||||
if (unitRate < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(unitRate), unitRate, "Unit rate cannot be negative.");
|
||||
}
|
||||
|
||||
if (flatDelta is not null && flatDelta < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(flatDelta), flatDelta, "Flat delta cannot be negative.");
|
||||
}
|
||||
|
||||
var tier = OptionTier.Create(Id, this, fromInclusive, toInclusive, unitRate, flatDelta);
|
||||
Tiers.Add(tier);
|
||||
AddEvent(new OptionTierAdded(Id, tier.Id, tier.FromInclusive, tier.ToInclusive));
|
||||
return tier;
|
||||
}
|
||||
|
||||
public void ChangeTier(Guid tierId, decimal? fromInclusive, decimal? toInclusive, decimal? unitRate, decimal? flatDelta)
|
||||
{
|
||||
if (DataType != OptionDataType.Number)
|
||||
{
|
||||
throw new InvalidOperationException("Tiers can only be changed for number options.");
|
||||
}
|
||||
|
||||
var tier = Tiers.FirstOrDefault(t => t.Id == tierId)
|
||||
?? throw new InvalidOperationException($"Option tier '{tierId}' was not found.");
|
||||
|
||||
var newFrom = fromInclusive ?? tier.FromInclusive;
|
||||
var newTo = toInclusive ?? tier.ToInclusive;
|
||||
var newRate = unitRate ?? tier.UnitRate;
|
||||
var newFlat = flatDelta ?? tier.FlatDelta;
|
||||
|
||||
if (newFrom < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(fromInclusive), newFrom, "FromInclusive cannot be negative.");
|
||||
}
|
||||
|
||||
if (newTo is not null && newTo < newFrom)
|
||||
{
|
||||
throw new ArgumentException("ToInclusive must be greater than or equal to FromInclusive.", nameof(toInclusive));
|
||||
}
|
||||
|
||||
if (newRate < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(unitRate), newRate, "Unit rate cannot be negative.");
|
||||
}
|
||||
|
||||
if (newFlat is not null && newFlat < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(flatDelta), newFlat, "Flat delta cannot be negative.");
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
|
||||
if (tier.FromInclusive != newFrom)
|
||||
{
|
||||
tier.FromInclusive = newFrom;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (tier.ToInclusive != newTo)
|
||||
{
|
||||
tier.ToInclusive = newTo;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (tier.UnitRate != newRate)
|
||||
{
|
||||
tier.UnitRate = newRate;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (tier.FlatDelta != newFlat)
|
||||
{
|
||||
tier.FlatDelta = newFlat;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
AddEvent(new OptionTierChanged(Id, tier.Id));
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveTier(Guid tierId)
|
||||
{
|
||||
if (DataType != OptionDataType.Number)
|
||||
{
|
||||
throw new InvalidOperationException("Tiers can only be removed from number options.");
|
||||
}
|
||||
|
||||
var tier = Tiers.FirstOrDefault(t => t.Id == tierId)
|
||||
?? throw new InvalidOperationException($"Option tier '{tierId}' was not found.");
|
||||
|
||||
Tiers.Remove(tier);
|
||||
AddEvent(new OptionTierRemoved(Id, tier.Id));
|
||||
}
|
||||
|
||||
public void SetPercentScope(PercentScope scope)
|
||||
{
|
||||
if (DataType != OptionDataType.Choice)
|
||||
{
|
||||
throw new InvalidOperationException("Percent scope applies only to choice options.");
|
||||
}
|
||||
|
||||
if (PercentScope == scope)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PercentScope = scope;
|
||||
}
|
||||
}
|
||||
|
||||
public class OptionValue : EntityWithAuditAndStatus<Guid>
|
||||
{
|
||||
private OptionValue()
|
||||
{
|
||||
}
|
||||
|
||||
public Guid OptionDefinitionId { get; private set; }
|
||||
|
||||
public OptionDefinition OptionDefinition { get; private set; } = null!;
|
||||
|
||||
public string Code { get; private set; } = string.Empty;
|
||||
|
||||
public string Label { get; internal set; } = string.Empty;
|
||||
|
||||
public decimal? PriceDelta { get; internal set; }
|
||||
|
||||
public PriceDeltaKind PriceDeltaKind { get; internal set; }
|
||||
|
||||
internal static OptionValue Create(
|
||||
Guid optionDefinitionId,
|
||||
OptionDefinition optionDefinition,
|
||||
string code,
|
||||
string label,
|
||||
decimal? priceDelta,
|
||||
PriceDeltaKind priceDeltaKind)
|
||||
{
|
||||
return new OptionValue
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OptionDefinitionId = optionDefinitionId,
|
||||
OptionDefinition = optionDefinition,
|
||||
Code = code,
|
||||
Label = label,
|
||||
PriceDelta = priceDelta,
|
||||
PriceDeltaKind = priceDeltaKind
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class OptionTier : EntityWithAuditAndStatus<Guid>
|
||||
{
|
||||
private OptionTier()
|
||||
{
|
||||
}
|
||||
|
||||
public Guid OptionDefinitionId { get; private set; }
|
||||
|
||||
public OptionDefinition OptionDefinition { get; private set; } = null!;
|
||||
|
||||
public decimal FromInclusive { get; internal set; }
|
||||
|
||||
public decimal? ToInclusive { get; internal set; }
|
||||
|
||||
public decimal UnitRate { get; internal set; }
|
||||
|
||||
public decimal? FlatDelta { get; internal set; }
|
||||
|
||||
internal static OptionTier Create(
|
||||
Guid optionDefinitionId,
|
||||
OptionDefinition optionDefinition,
|
||||
decimal fromInclusive,
|
||||
decimal? toInclusive,
|
||||
decimal unitRate,
|
||||
decimal? flatDelta)
|
||||
{
|
||||
return new OptionTier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OptionDefinitionId = optionDefinitionId,
|
||||
OptionDefinition = optionDefinition,
|
||||
FromInclusive = fromInclusive,
|
||||
ToInclusive = toInclusive,
|
||||
UnitRate = unitRate,
|
||||
FlatDelta = flatDelta
|
||||
};
|
||||
}
|
||||
}
|
||||
334
Prefab.Catalog/Domain/Entities/Product.cs
Normal file
334
Prefab.Catalog/Domain/Entities/Product.cs
Normal file
@@ -0,0 +1,334 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Prefab.Base.Catalog.Products;
|
||||
using Prefab.Catalog.Domain.Events;
|
||||
using Prefab.Catalog.Domain.Exceptions;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Prefab.Domain.Common;
|
||||
using DomainRuleException = Prefab.Domain.Exceptions.DomainException;
|
||||
|
||||
namespace Prefab.Catalog.Domain.Entities;
|
||||
|
||||
public enum ProductKind
|
||||
{
|
||||
Model = 0,
|
||||
Variant = 1
|
||||
}
|
||||
|
||||
public class Product : EntityWithAuditAndStatus<Guid>
|
||||
{
|
||||
private static readonly Regex SlugRegex = new(ProductRules.SlugPattern, RegexOptions.Compiled);
|
||||
|
||||
private Product()
|
||||
{
|
||||
}
|
||||
|
||||
public ProductKind Kind { get; private set; }
|
||||
|
||||
public string? Sku { get; private set; }
|
||||
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
|
||||
public string? Slug { get; private set; }
|
||||
|
||||
public string? Description { get; private set; }
|
||||
|
||||
public decimal? Price { get; private set; }
|
||||
|
||||
public Guid? ParentProductId { get; private set; }
|
||||
|
||||
public Product? ParentProduct { get; private set; }
|
||||
|
||||
public List<Product> Variants { get; } = [];
|
||||
|
||||
public List<ProductAttributeValue> Attributes { get; } = [];
|
||||
|
||||
public List<OptionDefinition> Options { get; } = [];
|
||||
|
||||
public List<ProductCategory> Categories { get; } = [];
|
||||
|
||||
public List<OptionRuleSet> RuleSets { get; } = [];
|
||||
|
||||
public List<VariantAxisValue> AxisValues { get; } = [];
|
||||
|
||||
public static async Task<Product> CreateModel(
|
||||
string name,
|
||||
string? slug,
|
||||
string? description,
|
||||
IUniqueChecker uniqueChecker,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentNullException.ThrowIfNull(uniqueChecker);
|
||||
|
||||
var trimmedName = name.Trim();
|
||||
var trimmedSlug = string.IsNullOrWhiteSpace(slug) ? null : slug.Trim();
|
||||
var trimmedDescription = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
||||
|
||||
if (trimmedName.Length > ProductRules.NameMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Product name cannot exceed {ProductRules.NameMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (trimmedDescription is not null && trimmedDescription.Length > ProductRules.DescriptionMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Product description cannot exceed {ProductRules.DescriptionMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (trimmedSlug is not null)
|
||||
{
|
||||
if (trimmedSlug.Length > ProductRules.SlugMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Product slug cannot exceed {ProductRules.SlugMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (!SlugRegex.IsMatch(trimmedSlug))
|
||||
{
|
||||
throw new DomainRuleException("Product slug may only contain lowercase letters, numbers, and single hyphens between segments.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!await uniqueChecker.ProductModelNameIsUnique(trimmedName, cancellationToken))
|
||||
{
|
||||
throw new DuplicateNameException(trimmedName, nameof(Product));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(trimmedSlug) &&
|
||||
!await uniqueChecker.ProductSlugIsUnique(trimmedSlug, cancellationToken))
|
||||
{
|
||||
throw new DomainValidationException($"A product with slug '{trimmedSlug}' already exists.");
|
||||
}
|
||||
|
||||
var product = new Product
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Kind = ProductKind.Model,
|
||||
Name = trimmedName,
|
||||
Slug = trimmedSlug,
|
||||
Description = trimmedDescription
|
||||
};
|
||||
|
||||
product.AddEvent(new ProductCreated(product.Id, "Model", product.Name, product.Slug));
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
public static async Task<Product> CreateVariant(
|
||||
Guid parentId,
|
||||
string sku,
|
||||
string name,
|
||||
decimal price,
|
||||
IUniqueChecker uniqueChecker,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(uniqueChecker);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sku);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
|
||||
if (price < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(price), price, "Price cannot be negative.");
|
||||
}
|
||||
|
||||
var trimmedSku = sku.Trim();
|
||||
var trimmedName = name.Trim();
|
||||
|
||||
if (trimmedSku.Length > ProductRules.SkuMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Product SKU cannot exceed {ProductRules.SkuMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (trimmedName.Length > ProductRules.NameMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Product name cannot exceed {ProductRules.NameMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (!await uniqueChecker.ProductSkuIsUnique(trimmedSku, cancellationToken))
|
||||
{
|
||||
throw new DomainValidationException($"A product variant with SKU '{trimmedSku}' already exists.");
|
||||
}
|
||||
|
||||
var product = new Product
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Kind = ProductKind.Variant,
|
||||
ParentProductId = parentId,
|
||||
Sku = trimmedSku,
|
||||
Name = trimmedName,
|
||||
Price = price
|
||||
};
|
||||
|
||||
product.AddEvent(new ProductVariantCreated(product.Id, parentId, trimmedSku, trimmedName, price));
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
public void SetBasePrice(decimal? price)
|
||||
{
|
||||
if (price is < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(price), price, "Price cannot be negative.");
|
||||
}
|
||||
|
||||
if (Price == price)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var old = Price;
|
||||
Price = price;
|
||||
AddEvent(new ProductPriceChanged(Id, old, price));
|
||||
}
|
||||
|
||||
public async Task Rename(string name, IUniqueChecker uniqueChecker, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentNullException.ThrowIfNull(uniqueChecker);
|
||||
|
||||
var trimmed = name.Trim();
|
||||
|
||||
if (trimmed.Length > ProductRules.NameMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Product name cannot exceed {ProductRules.NameMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (string.Equals(trimmed, Name, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Kind == ProductKind.Model)
|
||||
{
|
||||
var isUnique = await uniqueChecker.ProductModelNameIsUnique(trimmed, cancellationToken);
|
||||
if (!isUnique)
|
||||
{
|
||||
throw new DuplicateNameException(trimmed, nameof(Product));
|
||||
}
|
||||
}
|
||||
|
||||
var oldName = Name;
|
||||
Name = trimmed;
|
||||
AddEvent(new ProductRenamed(Id, oldName, Name));
|
||||
}
|
||||
|
||||
public void ChangeDescription(string? description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
Description = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var trimmed = description.Trim();
|
||||
if (trimmed.Length > ProductRules.DescriptionMaxLength)
|
||||
{
|
||||
throw new DomainRuleException($"Product description cannot exceed {ProductRules.DescriptionMaxLength} characters.");
|
||||
}
|
||||
|
||||
Description = trimmed;
|
||||
}
|
||||
|
||||
public void AttachVariant(Product variant)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(variant);
|
||||
|
||||
if (variant.Kind != ProductKind.Variant)
|
||||
{
|
||||
throw new InvalidOperationException("Only variant products can be attached as variants.");
|
||||
}
|
||||
|
||||
variant.ParentProductId = Id;
|
||||
variant.ParentProduct = this;
|
||||
|
||||
if (Variants.Any(v => v.Id == variant.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Variants.Add(variant);
|
||||
AddEvent(new ProductVariantAttached(Id, variant.Id));
|
||||
}
|
||||
|
||||
public ProductAttributeValue UpsertSpec(
|
||||
Guid attributeDefinitionId,
|
||||
string? value,
|
||||
decimal? numericValue,
|
||||
string? unitCode,
|
||||
string? enumCode)
|
||||
{
|
||||
if (attributeDefinitionId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Attribute definition identifier is required.", nameof(attributeDefinitionId));
|
||||
}
|
||||
|
||||
var existing = Attributes.FirstOrDefault(x => x.AttributeDefinitionId == attributeDefinitionId);
|
||||
var trimmedValue = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
var trimmedUnit = string.IsNullOrWhiteSpace(unitCode) ? null : unitCode.Trim();
|
||||
var trimmedEnum = string.IsNullOrWhiteSpace(enumCode) ? null : enumCode.Trim();
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
existing = ProductAttributeValue.Create(
|
||||
Id,
|
||||
attributeDefinitionId,
|
||||
trimmedValue,
|
||||
numericValue,
|
||||
trimmedUnit,
|
||||
trimmedEnum);
|
||||
|
||||
existing.Product = this;
|
||||
Attributes.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Product = this;
|
||||
existing.Value = trimmedValue;
|
||||
existing.NumericValue = numericValue;
|
||||
existing.UnitCode = trimmedUnit;
|
||||
existing.EnumCode = trimmedEnum;
|
||||
}
|
||||
|
||||
AddEvent(new ProductAttributeValueUpserted(Id, attributeDefinitionId));
|
||||
return existing;
|
||||
}
|
||||
|
||||
public ProductCategory AssignToCategory(Guid categoryId, bool isPrimary)
|
||||
{
|
||||
if (categoryId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Category identifier is required.", nameof(categoryId));
|
||||
}
|
||||
|
||||
var existing = Categories.FirstOrDefault(x => x.CategoryId == categoryId);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new ProductCategory
|
||||
{
|
||||
ProductId = Id,
|
||||
Product = this,
|
||||
CategoryId = categoryId,
|
||||
IsPrimary = isPrimary
|
||||
};
|
||||
Categories.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.IsPrimary = isPrimary;
|
||||
}
|
||||
|
||||
AddEvent(new ProductCategoryAssigned(Id, categoryId, isPrimary));
|
||||
return existing;
|
||||
}
|
||||
|
||||
public bool UnassignFromCategory(Guid categoryId)
|
||||
{
|
||||
var existing = Categories.FirstOrDefault(x => x.CategoryId == categoryId);
|
||||
if (existing is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Categories.Remove(existing);
|
||||
AddEvent(new ProductCategoryUnassigned(Id, categoryId));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
14
Prefab.Catalog/Domain/Entities/ProductCategory.cs
Normal file
14
Prefab.Catalog/Domain/Entities/ProductCategory.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Prefab.Catalog.Domain.Entities;
|
||||
|
||||
public class ProductCategory
|
||||
{
|
||||
public Guid ProductId { get; set; }
|
||||
|
||||
public Product Product { get; set; } = null!;
|
||||
|
||||
public Guid CategoryId { get; set; }
|
||||
|
||||
public Category Category { get; set; } = null!;
|
||||
|
||||
public bool IsPrimary { get; set; }
|
||||
}
|
||||
6
Prefab.Catalog/Domain/Events/AttributeEvents.cs
Normal file
6
Prefab.Catalog/Domain/Events/AttributeEvents.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Catalog.Domain.Events;
|
||||
|
||||
public sealed record AttributeDefinitionCreated(Guid AttributeDefinitionId, string Name, AttributeDataType DataType) : Event;
|
||||
7
Prefab.Catalog/Domain/Events/CategoryEvents.cs
Normal file
7
Prefab.Catalog/Domain/Events/CategoryEvents.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Catalog.Domain.Events;
|
||||
|
||||
public sealed record CategoryCreated(Guid CategoryId, string Name) : Event;
|
||||
|
||||
public sealed record CategoryRenamed(Guid CategoryId, string OldName, string NewName) : Event;
|
||||
34
Prefab.Catalog/Domain/Events/OptionEvents.cs
Normal file
34
Prefab.Catalog/Domain/Events/OptionEvents.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Catalog.Domain.Events;
|
||||
|
||||
public sealed record OptionDefinitionCreated(
|
||||
Guid ProductId,
|
||||
Guid OptionDefinitionId,
|
||||
string Code,
|
||||
string Name,
|
||||
string DataType,
|
||||
bool IsVariantAxis) : Event;
|
||||
|
||||
public sealed record OptionValueAdded(
|
||||
Guid OptionDefinitionId,
|
||||
Guid OptionValueId,
|
||||
string Code,
|
||||
string Label,
|
||||
decimal? PriceDelta,
|
||||
PriceDeltaKind Kind) : Event;
|
||||
|
||||
public sealed record OptionValueChanged(Guid OptionDefinitionId, Guid OptionValueId) : Event;
|
||||
|
||||
public sealed record OptionValueRemoved(Guid OptionDefinitionId, Guid OptionValueId) : Event;
|
||||
|
||||
public sealed record OptionTierAdded(
|
||||
Guid OptionDefinitionId,
|
||||
Guid OptionTierId,
|
||||
decimal FromInclusive,
|
||||
decimal? ToInclusive) : Event;
|
||||
|
||||
public sealed record OptionTierChanged(Guid OptionDefinitionId, Guid OptionTierId) : Event;
|
||||
|
||||
public sealed record OptionTierRemoved(Guid OptionDefinitionId, Guid OptionTierId) : Event;
|
||||
19
Prefab.Catalog/Domain/Events/ProductEvents.cs
Normal file
19
Prefab.Catalog/Domain/Events/ProductEvents.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Catalog.Domain.Events;
|
||||
|
||||
public sealed record ProductCreated(Guid ProductId, string Kind, string Name, string? Slug) : Event;
|
||||
|
||||
public sealed record ProductVariantCreated(Guid ProductId, Guid ParentProductId, string Sku, string Name, decimal Price) : Event;
|
||||
|
||||
public sealed record ProductPriceChanged(Guid ProductId, decimal? OldPrice, decimal? NewPrice) : Event;
|
||||
|
||||
public sealed record ProductRenamed(Guid ProductId, string OldName, string NewName) : Event;
|
||||
|
||||
public sealed record ProductVariantAttached(Guid ParentProductId, Guid VariantProductId) : Event;
|
||||
|
||||
public sealed record ProductAttributeValueUpserted(Guid ProductId, Guid AttributeDefinitionId) : Event;
|
||||
|
||||
public sealed record ProductCategoryAssigned(Guid ProductId, Guid CategoryId, bool IsPrimary) : Event;
|
||||
|
||||
public sealed record ProductCategoryUnassigned(Guid ProductId, Guid CategoryId) : Event;
|
||||
19
Prefab.Catalog/Domain/Exceptions/DomainException.cs
Normal file
19
Prefab.Catalog/Domain/Exceptions/DomainException.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace Prefab.Catalog.Domain.Exceptions;
|
||||
|
||||
public abstract class DomainException(string message) : Exception(message);
|
||||
|
||||
public sealed class CatalogNotFoundException(string resource, string identifier)
|
||||
: DomainException($"{resource} with identifier '{identifier}' was not found.")
|
||||
{
|
||||
public string Resource { get; } = resource;
|
||||
|
||||
public string Identifier { get; } = identifier;
|
||||
}
|
||||
|
||||
public sealed class CatalogConflictException(string resource, string identifier, string detail)
|
||||
: DomainException(detail)
|
||||
{
|
||||
public string Resource { get; } = resource;
|
||||
|
||||
public string Identifier { get; } = identifier;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Prefab.Catalog.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a domain rule violation caused by invalid user input.
|
||||
/// </summary>
|
||||
/// <param name="message">Details about the validation failure.</param>
|
||||
public sealed class DomainValidationException(string message) : DomainException(message);
|
||||
|
||||
14
Prefab.Catalog/Domain/Exceptions/DuplicateNameException.cs
Normal file
14
Prefab.Catalog/Domain/Exceptions/DuplicateNameException.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Prefab.Catalog.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an exception that is thrown when an attempt is made to create or add an entity with a name that already
|
||||
/// exists for the specified type.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the entity that caused the duplication error. Cannot be null.</param>
|
||||
/// <param name="type">The type of the entity for which the duplicate name was detected. Cannot be null.</param>
|
||||
internal class DuplicateNameException(string name, string type) : DomainException($"A {type} with the name {name} already exists.")
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
|
||||
public string Type { get; } = type;
|
||||
}
|
||||
14
Prefab.Catalog/Domain/Services/IUniqueChecker.cs
Normal file
14
Prefab.Catalog/Domain/Services/IUniqueChecker.cs
Normal 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);
|
||||
}
|
||||
253
Prefab.Catalog/Domain/Services/PricingService.cs
Normal file
253
Prefab.Catalog/Domain/Services/PricingService.cs
Normal 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 shopper’s 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);
|
||||
}
|
||||
}
|
||||
451
Prefab.Catalog/Domain/Services/RuleEvaluator.cs
Normal file
451
Prefab.Catalog/Domain/Services/RuleEvaluator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
137
Prefab.Catalog/Domain/Services/VariantResolver.cs
Normal file
137
Prefab.Catalog/Domain/Services/VariantResolver.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user