feat(domain): ProductType entity + domain exceptions (PRD-001)
This commit is contained in:
186
src/api/SIGCM2.Domain/Entities/ProductType.cs
Normal file
186
src/api/SIGCM2.Domain/Entities/ProductType.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user