feat(domain): Product entity + 5 domain exceptions (PRD-002)
This commit is contained in:
172
src/api/SIGCM2.Domain/Entities/Product.cs
Normal file
172
src/api/SIGCM2.Domain/Entities/Product.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
15
src/api/SIGCM2.Domain/Exceptions/ProductNotFoundException.cs
Normal file
15
src/api/SIGCM2.Domain/Exceptions/ProductNotFoundException.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
15
src/api/SIGCM2.Domain/Exceptions/RubroInactivoException.cs
Normal file
15
src/api/SIGCM2.Domain/Exceptions/RubroInactivoException.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user