feat(domain): Rubro entity + domain exceptions (CAT-001)

This commit is contained in:
2026-04-18 19:17:33 -03:00
parent 9f78425a93
commit dcb2e5ada6
10 changed files with 620 additions and 0 deletions

View File

@@ -169,6 +169,79 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
// CAT-001: Rubro exceptions
case RubroNotFoundException rubroNotFoundEx:
context.Result = new ObjectResult(new
{
error = "rubro_not_found",
message = rubroNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case RubroNombreDuplicadoEnPadreException rubroDupEx:
context.Result = new ObjectResult(new
{
error = "rubro_nombre_duplicado",
message = rubroDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case RubroTieneHijosActivosException rubroHijosEx:
context.Result = new ObjectResult(new
{
error = "rubro_tiene_hijos_activos",
message = rubroHijosEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case RubroPadreInactivoException rubroPadreEx:
context.Result = new ObjectResult(new
{
error = "rubro_padre_inactivo",
message = rubroPadreEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case RubroMaxDepthExceededException rubroDepthEx:
context.Result = new ObjectResult(new
{
error = "rubro_max_depth_exceeded",
message = rubroDepthEx.Message
})
{
StatusCode = StatusCodes.Status422UnprocessableEntity
};
context.ExceptionHandled = true;
break;
case RubroCycleDetectedException rubroCycleEx:
context.Result = new ObjectResult(new
{
error = "rubro_cycle_detected",
message = rubroCycleEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
// ADM-001: Medio exceptions
case MedioCodigoDuplicadoException medioCodDupEx:
context.Result = new ObjectResult(new

View File

@@ -0,0 +1,139 @@
namespace SIGCM2.Domain.Entities;
/// <summary>
/// Immutable N-ary tree node for the commercial catalog taxonomy.
/// Follows the same sealed-class + factory + with-methods pattern as Medio.cs.
/// </summary>
public sealed class Rubro
{
private const int NombreMaxLength = 200;
public int Id { get; }
public int? ParentId { get; }
public string Nombre { get; }
public int Orden { get; }
public bool Activo { get; }
public int? TarifarioBaseId { get; }
public DateTime FechaCreacion { get; }
public DateTime? FechaModificacion { get; }
/// <summary>
/// Full hydration constructor — used by the repository to reconstruct from DB rows.
/// </summary>
public Rubro(
int id,
int? parentId,
string nombre,
int orden,
bool activo,
int? tarifarioBaseId,
DateTime fechaCreacion,
DateTime? fechaModificacion)
{
Id = id;
ParentId = parentId;
Nombre = nombre;
Orden = orden;
Activo = activo;
TarifarioBaseId = tarifarioBaseId;
FechaCreacion = fechaCreacion;
FechaModificacion = fechaModificacion;
}
/// <summary>
/// Factory for creating a new Rubro.
/// Id=0 — DB assigns via IDENTITY.
/// Activo=true, FechaModificacion=null by default.
/// FechaCreacion is set from TimeProvider so it is testable.
/// </summary>
public static Rubro ForCreation(
string nombre,
int? parentId,
int orden,
int? tarifarioBaseId,
TimeProvider timeProvider)
{
ValidateNombre(nombre);
if (parentId.HasValue && parentId.Value <= 0)
throw new ArgumentException("parentId debe ser un entero positivo cuando no es nulo.", nameof(parentId));
if (tarifarioBaseId.HasValue && tarifarioBaseId.Value < 0)
throw new ArgumentException("tarifarioBaseId no puede ser negativo.", nameof(tarifarioBaseId));
return new Rubro(
id: 0,
parentId: parentId,
nombre: nombre,
orden: orden,
activo: true,
tarifarioBaseId: tarifarioBaseId,
fechaCreacion: timeProvider.GetUtcNow().UtcDateTime,
fechaModificacion: null);
}
/// <summary>
/// Returns a new Rubro instance with an updated Nombre and FechaModificacion.
/// Does NOT mutate the current instance.
/// </summary>
public Rubro WithRenamed(string nuevoNombre, TimeProvider timeProvider)
{
ValidateNombre(nuevoNombre);
return new Rubro(
id: Id,
parentId: ParentId,
nombre: nuevoNombre,
orden: Orden,
activo: Activo,
tarifarioBaseId: TarifarioBaseId,
fechaCreacion: FechaCreacion,
fechaModificacion: timeProvider.GetUtcNow().UtcDateTime);
}
/// <summary>
/// Returns a new Rubro instance with updated ParentId and Orden.
/// Does NOT mutate the current instance.
/// </summary>
public Rubro WithMoved(int? nuevoParentId, int nuevoOrden, TimeProvider timeProvider)
{
return new Rubro(
id: Id,
parentId: nuevoParentId,
nombre: Nombre,
orden: nuevoOrden,
activo: Activo,
tarifarioBaseId: TarifarioBaseId,
fechaCreacion: FechaCreacion,
fechaModificacion: timeProvider.GetUtcNow().UtcDateTime);
}
/// <summary>
/// Returns a new Rubro instance with updated Activo flag.
/// Use Deactivate (false) or Reactivate (true).
/// Does NOT mutate the current instance.
/// </summary>
public Rubro WithActivo(bool activo, TimeProvider timeProvider)
{
return new Rubro(
id: Id,
parentId: ParentId,
nombre: Nombre,
orden: Orden,
activo: activo,
tarifarioBaseId: TarifarioBaseId,
fechaCreacion: FechaCreacion,
fechaModificacion: timeProvider.GetUtcNow().UtcDateTime);
}
private static void ValidateNombre(string nombre)
{
if (string.IsNullOrWhiteSpace(nombre))
throw new ArgumentException("El nombre del rubro no puede estar vacío o ser solo espacios.", nameof(nombre));
if (nombre.Length > NombreMaxLength)
throw new ArgumentException(
$"El nombre del rubro no puede superar los {NombreMaxLength} caracteres.",
nameof(nombre));
}
}

View File

@@ -0,0 +1,17 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when moving a Rubro to one of its own descendants would create a cycle. → HTTP 400
/// </summary>
public sealed class RubroCycleDetectedException : DomainException
{
public int RubroId { get; }
public int NuevoParentId { get; }
public RubroCycleDetectedException(int rubroId, int nuevoParentId)
: base($"Mover el rubro '{rubroId}' al padre '{nuevoParentId}' crearía un ciclo en el árbol.")
{
RubroId = rubroId;
NuevoParentId = nuevoParentId;
}
}

View File

@@ -0,0 +1,17 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when creating or moving a Rubro would exceed the configured maximum tree depth. → HTTP 422
/// </summary>
public sealed class RubroMaxDepthExceededException : DomainException
{
public int Intentada { get; }
public int Max { get; }
public RubroMaxDepthExceededException(int intentada, int max)
: base($"La profundidad intentada ({intentada}) excede el máximo permitido ({max}).")
{
Intentada = intentada;
Max = max;
}
}

View File

@@ -0,0 +1,19 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a Rubro with the same Nombre (CI) already exists under the same parent. → HTTP 409
/// </summary>
public sealed class RubroNombreDuplicadoEnPadreException : DomainException
{
public string Nombre { get; }
public int? ParentId { get; }
public RubroNombreDuplicadoEnPadreException(string nombre, int? parentId)
: base(parentId.HasValue
? $"Ya existe un rubro con el nombre '{nombre}' bajo el padre con id '{parentId}'."
: $"Ya existe un rubro raíz con el nombre '{nombre}'.")
{
Nombre = nombre;
ParentId = parentId;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a requested Rubro does not exist in the system. → HTTP 404
/// </summary>
public sealed class RubroNotFoundException : DomainException
{
public int Id { get; }
public RubroNotFoundException(int id)
: base($"El rubro con id '{id}' no existe.")
{
Id = id;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to create or move a Rubro under an inactive parent. → HTTP 400
/// </summary>
public sealed class RubroPadreInactivoException : DomainException
{
public int ParentId { get; }
public RubroPadreInactivoException(int parentId)
: base($"El padre con id '{parentId}' está inactivo y no puede tener hijos.")
{
ParentId = parentId;
}
}

View File

@@ -0,0 +1,17 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to soft-delete a Rubro that still has active children. → HTTP 409
/// </summary>
public sealed class RubroTieneHijosActivosException : DomainException
{
public int Id { get; }
public int Count { get; }
public RubroTieneHijosActivosException(int id, int count)
: base($"El rubro con id '{id}' tiene {count} subrubros activos.")
{
Id = id;
Count = count;
}
}