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

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

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

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

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

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