feat(domain): ProductType entity + domain exceptions (PRD-001)

This commit is contained in:
2026-04-19 09:36:29 -03:00
parent de70152d3e
commit 132d17c99f
7 changed files with 616 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
namespace SIGCM2.Domain.Entities;
/// <summary>
/// Immutable product-type descriptor for the commercial catalog.
/// Flags drive form behavior (HasDuration, RequiresText, RequiresCategory, IsBundle).
/// Multimedia limits (Max*) are null = no limit.
/// Invariant: if AllowImages == false, the 4 Max* fields must be null
/// (enforced by ForCreation and WithUpdatedMultimedia).
/// </summary>
public sealed class ProductType
{
private const int NombreMaxLength = 200;
public int Id { get; }
public string Nombre { get; }
public bool HasDuration { get; }
public bool RequiresText { get; }
public bool RequiresCategory { get; }
public bool IsBundle { get; }
public bool AllowImages { get; }
public int? MaxImages { get; }
public decimal? MaxImageSizeMB { get; }
public int? MaxImageWidth { get; }
public int? MaxImageHeight { 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 ProductType(
int id,
string nombre,
bool hasDuration,
bool requiresText,
bool requiresCategory,
bool isBundle,
bool allowImages,
int? maxImages,
decimal? maxImageSizeMB,
int? maxImageWidth,
int? maxImageHeight,
bool isActive,
DateTime fechaCreacion,
DateTime? fechaModificacion)
{
Id = id;
Nombre = nombre;
HasDuration = hasDuration;
RequiresText = requiresText;
RequiresCategory = requiresCategory;
IsBundle = isBundle;
AllowImages = allowImages;
MaxImages = maxImages;
MaxImageSizeMB = maxImageSizeMB;
MaxImageWidth = maxImageWidth;
MaxImageHeight = maxImageHeight;
IsActive = isActive;
FechaCreacion = fechaCreacion;
FechaModificacion = fechaModificacion;
}
/// <summary>
/// Factory for creating a new ProductType.
/// Id=0 — DB assigns via IDENTITY.
/// IsActive=true, FechaModificacion=null by default.
/// AllowImages=false normalizes all 4 Max* fields to null.
/// </summary>
public static ProductType ForCreation(
string nombre,
bool hasDuration,
bool requiresText,
bool requiresCategory,
bool isBundle,
bool allowImages,
int? maxImages,
decimal? maxImageSizeMB,
int? maxImageWidth,
int? maxImageHeight,
TimeProvider timeProvider)
{
ValidateNombre(nombre);
var (mi, ms, mw, mh) = NormalizeMultimedia(allowImages, maxImages, maxImageSizeMB, maxImageWidth, maxImageHeight);
return new ProductType(
id: 0,
nombre: nombre,
hasDuration: hasDuration,
requiresText: requiresText,
requiresCategory: requiresCategory,
isBundle: isBundle,
allowImages: allowImages,
maxImages: mi,
maxImageSizeMB: ms,
maxImageWidth: mw,
maxImageHeight: mh,
isActive: true,
fechaCreacion: timeProvider.GetUtcNow().UtcDateTime,
fechaModificacion: null);
}
/// <summary>
/// Returns a new ProductType with an updated Nombre and FechaModificacion.
/// Does NOT mutate the current instance.
/// </summary>
public ProductType WithRenamed(string nuevoNombre, TimeProvider timeProvider)
{
ValidateNombre(nuevoNombre);
return new ProductType(
Id, nuevoNombre, HasDuration, RequiresText, RequiresCategory, IsBundle,
AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight,
IsActive, FechaCreacion, timeProvider.GetUtcNow().UtcDateTime);
}
/// <summary>
/// Returns a new ProductType with updated flags, preserving multimedia fields.
/// Does NOT mutate the current instance.
/// </summary>
public ProductType WithUpdatedFlags(
bool hasDuration,
bool requiresText,
bool requiresCategory,
bool isBundle,
TimeProvider timeProvider)
{
return new ProductType(
Id, Nombre, hasDuration, requiresText, requiresCategory, isBundle,
AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight,
IsActive, FechaCreacion, timeProvider.GetUtcNow().UtcDateTime);
}
/// <summary>
/// Returns a new ProductType with updated multimedia limits.
/// AllowImages=false normalizes all 4 Max* fields to null.
/// Does NOT mutate the current instance.
/// </summary>
public ProductType WithUpdatedMultimedia(
bool allowImages,
int? maxImages,
decimal? maxImageSizeMB,
int? maxImageWidth,
int? maxImageHeight,
TimeProvider timeProvider)
{
var (mi, ms, mw, mh) = NormalizeMultimedia(allowImages, maxImages, maxImageSizeMB, maxImageWidth, maxImageHeight);
return new ProductType(
Id, Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle,
allowImages, mi, ms, mw, mh,
IsActive, FechaCreacion, timeProvider.GetUtcNow().UtcDateTime);
}
/// <summary>
/// Returns a new ProductType with IsActive=false and FechaModificacion updated.
/// Does NOT mutate the current instance.
/// </summary>
public ProductType WithDeactivated(TimeProvider timeProvider)
{
return new ProductType(
Id, Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle,
AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight,
isActive: false, FechaCreacion, timeProvider.GetUtcNow().UtcDateTime);
}
// ── Private helpers ───────────────────────────────────────────────────────
private static (int?, decimal?, int?, int?) NormalizeMultimedia(
bool allow, int? mi, decimal? ms, int? mw, int? mh)
=> allow ? (mi, ms, mw, mh) : (null, null, null, null);
private static void ValidateNombre(string nombre)
{
if (string.IsNullOrWhiteSpace(nombre))
throw new ArgumentException(
"El nombre del tipo de producto no puede estar vacío o ser solo espacios.",
nameof(nombre));
if (nombre.Length > NombreMaxLength)
throw new ArgumentException(
$"El nombre del tipo de producto no puede superar los {NombreMaxLength} caracteres.",
nameof(nombre));
}
}

View File

@@ -0,0 +1,17 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to deactivate a ProductType that has active products. → HTTP 409
/// </summary>
public sealed class ProductTypeEnUsoException : DomainException
{
public int ProductTypeId { get; }
public int ProductsActivos { get; }
public ProductTypeEnUsoException(int id, int productsActivos)
: base($"El tipo de producto con id={id} no puede desactivarse: tiene {productsActivos} producto(s) activo(s) asociados.")
{
ProductTypeId = id;
ProductsActivos = productsActivos;
}
}

View File

@@ -0,0 +1,17 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a combination of flags/multimedia is logically incoherent. → HTTP 422
/// Defensive exception — PRD-001 normalizes instead of throwing.
/// Reserved for future rules (e.g., PRD-004 IsBundle+HasDuration constraints).
/// </summary>
public sealed class ProductTypeFlagsIncoherentesException : DomainException
{
public string Reason { get; }
public ProductTypeFlagsIncoherentesException(string reason)
: base($"Combinación de flags/multimedia inválida: {reason}")
{
Reason = reason;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a ProductType with the same active name already exists. → HTTP 409
/// </summary>
public sealed class ProductTypeNombreDuplicadoException : DomainException
{
public string Nombre { get; }
public ProductTypeNombreDuplicadoException(string nombre)
: base($"Ya existe un tipo de producto activo con el nombre '{nombre}'.")
{
Nombre = nombre;
}
}

View File

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