feat(domain): Product entity + 5 domain exceptions (PRD-002)

This commit is contained in:
2026-04-19 12:59:58 -03:00
parent 0462970ea1
commit 16197cf242
8 changed files with 530 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
namespace SIGCM2.Domain.Entities;
/// <summary>
/// Immutable product entity for the commercial catalog.
/// Factory method ForCreation creates new products (Id=0).
/// Mutation methods (With*) return new instances — original is never modified.
/// Flag coherence (RequiresCategory/HasDuration) is enforced by Application handlers
/// at creation/update time against the ProductType, NOT here in the entity.
/// MedioId and ProductTypeId are immutable post-creation by design.
/// </summary>
public sealed class Product
{
private const int NombreMaxLength = 300;
public int Id { get; }
public string Nombre { get; }
public int MedioId { get; }
public int ProductTypeId { get; }
public int? RubroId { get; }
public decimal BasePrice { get; }
public int? PriceDurationDays { get; }
public bool IsActive { get; }
public DateTime FechaCreacion { get; }
public DateTime? FechaModificacion { get; }
/// <summary>Full hydration constructor — used by the repository to reconstruct from DB rows.</summary>
public Product(
int id,
string nombre,
int medioId,
int productTypeId,
int? rubroId,
decimal basePrice,
int? priceDurationDays,
bool isActive,
DateTime fechaCreacion,
DateTime? fechaModificacion)
{
Id = id;
Nombre = nombre;
MedioId = medioId;
ProductTypeId = productTypeId;
RubroId = rubroId;
BasePrice = basePrice;
PriceDurationDays = priceDurationDays;
IsActive = isActive;
FechaCreacion = fechaCreacion;
FechaModificacion = fechaModificacion;
}
/// <summary>
/// Factory for a new Product. Id=0 — DB assigns via IDENTITY.
/// IsActive=true, FechaModificacion=null.
/// </summary>
public static Product ForCreation(
string nombre,
int medioId,
int productTypeId,
int? rubroId,
decimal basePrice,
int? priceDurationDays,
TimeProvider timeProvider)
{
ValidateNombre(nombre);
ValidateMedioId(medioId);
ValidateProductTypeId(productTypeId);
ValidateRubroId(rubroId);
ValidateBasePrice(basePrice);
ValidatePriceDurationDays(priceDurationDays);
return new Product(
id: 0,
nombre: nombre.Trim(),
medioId: medioId,
productTypeId: productTypeId,
rubroId: rubroId,
basePrice: basePrice,
priceDurationDays: priceDurationDays,
isActive: true,
fechaCreacion: timeProvider.GetUtcNow().UtcDateTime,
fechaModificacion: null);
}
public Product WithRenamed(string nuevoNombre, TimeProvider timeProvider)
{
ValidateNombre(nuevoNombre);
return new Product(Id, nuevoNombre.Trim(), MedioId, ProductTypeId, RubroId,
BasePrice, PriceDurationDays, IsActive, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
}
public Product WithUpdatedPrice(decimal basePrice, int? priceDurationDays, TimeProvider timeProvider)
{
ValidateBasePrice(basePrice);
ValidatePriceDurationDays(priceDurationDays);
return new Product(Id, Nombre, MedioId, ProductTypeId, RubroId,
basePrice, priceDurationDays, IsActive, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
}
public Product WithUpdatedCategory(int? rubroId, TimeProvider timeProvider)
{
ValidateRubroId(rubroId);
return new Product(Id, Nombre, MedioId, ProductTypeId, rubroId,
BasePrice, PriceDurationDays, IsActive, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
}
/// <summary>
/// Combo mutator: renames, updates price and category in one call.
/// Used by UpdateProductCommandHandler.
/// </summary>
public Product WithUpdated(string nombre, int? rubroId, decimal basePrice, int? priceDurationDays, TimeProvider timeProvider)
{
ValidateNombre(nombre);
ValidateRubroId(rubroId);
ValidateBasePrice(basePrice);
ValidatePriceDurationDays(priceDurationDays);
return new Product(Id, nombre.Trim(), MedioId, ProductTypeId, rubroId,
basePrice, priceDurationDays, IsActive, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
}
public Product WithDeactivated(TimeProvider timeProvider)
=> new(Id, Nombre, MedioId, ProductTypeId, RubroId,
BasePrice, PriceDurationDays, isActive: false, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
// ── Private validators ────────────────────────────────────────────────────
private static void ValidateNombre(string nombre)
{
if (string.IsNullOrWhiteSpace(nombre))
throw new ArgumentException(
"El Nombre del producto no puede estar vacío o ser solo espacios.", nameof(nombre));
if (nombre.Length > NombreMaxLength)
throw new ArgumentException(
$"El Nombre del producto no puede superar los {NombreMaxLength} caracteres.", nameof(nombre));
}
private static void ValidateMedioId(int medioId)
{
if (medioId <= 0)
throw new ArgumentException("medioId debe ser un entero positivo.", nameof(medioId));
}
private static void ValidateProductTypeId(int productTypeId)
{
if (productTypeId <= 0)
throw new ArgumentException("productTypeId debe ser un entero positivo.", nameof(productTypeId));
}
private static void ValidateRubroId(int? rubroId)
{
if (rubroId.HasValue && rubroId.Value <= 0)
throw new ArgumentException(
"rubroId debe ser un entero positivo cuando no es nulo.", nameof(rubroId));
}
private static void ValidateBasePrice(decimal basePrice)
{
if (basePrice < 0m)
throw new ArgumentException("basePrice no puede ser negativo.", nameof(basePrice));
}
private static void ValidatePriceDurationDays(int? priceDurationDays)
{
if (priceDurationDays.HasValue && priceDurationDays.Value <= 0)
throw new ArgumentException(
"priceDurationDays debe ser >= 1 cuando se provee.", nameof(priceDurationDays));
}
}

View File

@@ -0,0 +1,19 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a Product with the same Nombre already exists for a given MedioId+ProductTypeId. → HTTP 409
/// </summary>
public sealed class ProductNombreDuplicadoEnMedioTipoException : DomainException
{
public int MedioId { get; }
public int ProductTypeId { get; }
public string Nombre { get; }
public ProductNombreDuplicadoEnMedioTipoException(int medioId, int productTypeId, string nombre)
: base($"Ya existe un producto activo con nombre '{nombre}' para medioId={medioId} y productTypeId={productTypeId}.")
{
MedioId = medioId;
ProductTypeId = productTypeId;
Nombre = nombre;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a requested Product does not exist. → HTTP 404
/// </summary>
public sealed class ProductNotFoundException : DomainException
{
public int ProductId { get; }
public ProductNotFoundException(int id)
: base($"El producto con id={id} no existe.")
{
ProductId = id;
}
}

View File

@@ -0,0 +1,16 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a Product's field violates the flags coherence rules of its ProductType
/// (e.g. RequiresCategory=true but RubroId is null, or HasDuration=true but PriceDurationDays is null). → HTTP 422
/// </summary>
public sealed class ProductTipoFlagsIncoherentesException : DomainException
{
public string Field { get; }
public ProductTipoFlagsIncoherentesException(string reason, string field)
: base($"Incoherencia de flags del tipo de producto: {reason}.")
{
Field = field;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to create/update a Product referencing an inactive ProductType. → HTTP 422
/// </summary>
public sealed class ProductTypeInactivoException : DomainException
{
public int ProductTypeId { get; }
public ProductTypeInactivoException(int productTypeId)
: base($"El tipo de producto con id={productTypeId} está inactivo y no puede asignarse a un producto.")
{
ProductTypeId = productTypeId;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to create/update a Product referencing an inactive Rubro. → HTTP 422
/// </summary>
public sealed class RubroInactivoException : DomainException
{
public int RubroId { get; }
public RubroInactivoException(int rubroId)
: base($"El rubro con id={rubroId} está inactivo y no puede asignarse a un producto.")
{
RubroId = rubroId;
}
}