Init
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user