Files
prefab-page-detail/Prefab.Catalog/Domain/Entities/Product.cs
2025-10-27 17:39:18 -04:00

335 lines
10 KiB
C#

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