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 { 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 Variants { get; } = []; public List Attributes { get; } = []; public List Options { get; } = []; public List Categories { get; } = []; public List RuleSets { get; } = []; public List AxisValues { get; } = []; public static async Task 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 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; } }