335 lines
10 KiB
C#
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;
|
|
}
|
|
}
|