ADM-008: Puntos de Venta (CRUD fundacional) #19

Merged
dmolinari merged 18 commits from feature/ADM-008 into main 2026-04-17 17:31:21 +00:00
8 changed files with 371 additions and 0 deletions
Showing only changes of commit 43877bd4a1 - Show all commits

View File

@@ -0,0 +1,77 @@
namespace SIGCM2.Domain.Entities;
/// <summary>
/// Punto de Venta AFIP vinculado a un Medio. Gestiona comprobantes fiscales.
/// Identidad por Id (IDENTITY). NumeroAFIP único por Medio (enforced por UNIQUE(MedioId,NumeroAFIP) en BD).
/// </summary>
public sealed class PuntoDeVenta
{
public int Id { get; }
public int MedioId { get; }
public short NumeroAFIP { get; }
public string Nombre { get; }
public string? Descripcion { get; }
public bool Activo { get; }
public DateTime FechaCreacion { get; }
public DateTime? FechaModificacion { get; }
public PuntoDeVenta(
int id,
int medioId,
short numeroAFIP,
string nombre,
string? descripcion,
bool activo,
DateTime fechaCreacion,
DateTime? fechaModificacion)
{
Id = id;
MedioId = medioId;
NumeroAFIP = numeroAFIP;
Nombre = nombre;
Descripcion = descripcion;
Activo = activo;
FechaCreacion = fechaCreacion;
FechaModificacion = fechaModificacion;
}
/// <summary>
/// Factory para crear un nuevo PdV (Id=0 — BD asigna via IDENTITY; Activo=true; FechaCreacion por DF de BD).
/// </summary>
public static PuntoDeVenta ForCreation(int medioId, short numeroAFIP, string nombre, string? descripcion)
=> new(
id: 0,
medioId: medioId,
numeroAFIP: numeroAFIP,
nombre: nombre,
descripcion: descripcion,
activo: true,
fechaCreacion: default,
fechaModificacion: null);
/// <summary>
/// Retorna una nueva instancia con nombre, numeroAFIP y descripcion actualizados.
/// MedioId es inmutable (enforce en BD).
/// </summary>
public PuntoDeVenta WithUpdatedProfile(string nombre, short numeroAFIP, string? descripcion)
=> new(
id: Id,
medioId: MedioId,
numeroAFIP: numeroAFIP,
nombre: nombre,
descripcion: descripcion,
activo: Activo,
fechaCreacion: FechaCreacion,
fechaModificacion: DateTime.UtcNow);
public PuntoDeVenta WithActivo(bool activo)
=> new(
id: Id,
medioId: MedioId,
numeroAFIP: NumeroAFIP,
nombre: Nombre,
descripcion: Descripcion,
activo: activo,
fechaCreacion: FechaCreacion,
fechaModificacion: DateTime.UtcNow);
}

View File

@@ -0,0 +1,34 @@
using SIGCM2.Domain.Enums;
namespace SIGCM2.Domain.Entities;
/// <summary>
/// Lleva el correlativo de números de comprobante por (PuntoDeVentaId × TipoComprobante).
/// La reserva atómica la ejecuta usp_ReservarNumeroComprobante directamente en BD.
/// Este objeto es un helper de lectura/proyección.
/// </summary>
public sealed class SecuenciaComprobante
{
public int PuntoDeVentaId { get; }
public TipoComprobante TipoComprobante { get; }
public int UltimoNumero { get; }
public DateTime FechaCreacion { get; }
public DateTime? FechaModificacion { get; }
/// <summary>El próximo número disponible (read-only, sin modificar el estado).</summary>
public int ProximoNumero => UltimoNumero + 1;
public SecuenciaComprobante(
int puntoDeVentaId,
TipoComprobante tipoComprobante,
int ultimoNumero,
DateTime fechaCreacion,
DateTime? fechaModificacion)
{
PuntoDeVentaId = puntoDeVentaId;
TipoComprobante = tipoComprobante;
UltimoNumero = ultimoNumero;
FechaCreacion = fechaCreacion;
FechaModificacion = fechaModificacion;
}
}

View File

@@ -0,0 +1,16 @@
namespace SIGCM2.Domain.Enums;
/// <summary>
/// Tipos de comprobante AFIP soportados por ADM-008.
/// Valor TINYINT persistido en BD (CHECK TipoComprobante BETWEEN 1 AND 6).
/// Migración a tabla maestra diferida a FAC-001.
/// </summary>
public enum TipoComprobante : byte
{
FacturaA = 1,
FacturaB = 2,
FacturaC = 3,
NotaCreditoA = 4,
NotaCreditoB = 5,
NotaCreditoC = 6,
}

View File

@@ -0,0 +1,18 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a (MedioId, NumeroAFIP) combination already exists in the system.
/// Enforced by UNIQUE(MedioId, NumeroAFIP) in DB as safety net (REQ-PDV-003).
/// </summary>
public sealed class NumeroAFIPDuplicadoException : DomainException
{
public int MedioId { get; }
public short NumeroAFIP { get; }
public NumeroAFIPDuplicadoException(int medioId, short numeroAFIP)
: base($"El número AFIP '{numeroAFIP}' ya existe para el medio {medioId}.")
{
MedioId = medioId;
NumeroAFIP = numeroAFIP;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a mutation (reserva) is attempted on an inactive PuntoDeVenta.
/// </summary>
public sealed class PuntoDeVentaInactivoException : DomainException
{
public int PuntoDeVentaId { get; }
public PuntoDeVentaInactivoException(int puntoDeVentaId)
: base($"El punto de venta {puntoDeVentaId} está inactivo. No se pueden realizar operaciones hasta reactivarlo.")
{
PuntoDeVentaId = puntoDeVentaId;
}
}

View File

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

View File

@@ -0,0 +1,139 @@
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.Tests.Domain;
public class PuntoDeVentaTests
{
private static PuntoDeVenta MakePdv(
int id = 1,
int medioId = 5,
short numeroAFIP = 1,
string nombre = "PdV Central",
string? descripcion = null,
bool activo = true)
=> new(id, medioId, numeroAFIP, nombre, descripcion, activo, DateTime.UtcNow, null);
// ── ForCreation ───────────────────────────────────────────────────────────
[Fact]
public void ForCreation_SetsCorrectValues()
{
var pdv = PuntoDeVenta.ForCreation(medioId: 5, numeroAFIP: 3, nombre: "PdV Sur", descripcion: null);
Assert.Equal(0, pdv.Id);
Assert.Equal(5, pdv.MedioId);
Assert.Equal(3, pdv.NumeroAFIP);
Assert.Equal("PdV Sur", pdv.Nombre);
Assert.Null(pdv.Descripcion);
Assert.True(pdv.Activo);
}
[Fact]
public void ForCreation_WithDescripcion_SetsDescripcion()
{
var pdv = PuntoDeVenta.ForCreation(5, 1, "PdV Norte", "Descripcion larga");
Assert.Equal("Descripcion larga", pdv.Descripcion);
}
// ── WithUpdatedProfile ────────────────────────────────────────────────────
[Fact]
public void WithUpdatedProfile_ReturnsNewInstanceWithUpdatedFields()
{
var original = MakePdv(id: 10, nombre: "Original");
var updated = original.WithUpdatedProfile(nombre: "Actualizado", numeroAFIP: 7, descripcion: "Desc");
Assert.NotSame(original, updated);
Assert.Equal("Actualizado", updated.Nombre);
Assert.Equal(7, updated.NumeroAFIP);
Assert.Equal("Desc", updated.Descripcion);
}
[Fact]
public void WithUpdatedProfile_ImmutableFields_Preserved()
{
var original = MakePdv(id: 10, medioId: 5);
var updated = original.WithUpdatedProfile("Nuevo", 2, null);
Assert.Equal(10, updated.Id);
Assert.Equal(5, updated.MedioId);
Assert.Equal(original.Activo, updated.Activo);
Assert.Equal(original.FechaCreacion, updated.FechaCreacion);
}
[Fact]
public void WithUpdatedProfile_SetsFechaModificacion()
{
var original = MakePdv();
var updated = original.WithUpdatedProfile("Nuevo", 2, null);
Assert.NotNull(updated.FechaModificacion);
}
// ── WithActivo ────────────────────────────────────────────────────────────
[Fact]
public void WithActivo_False_ReturnsDomainObjectWithActivoFalse()
{
var pdv = MakePdv(activo: true);
var deactivated = pdv.WithActivo(false);
Assert.False(deactivated.Activo);
Assert.NotSame(pdv, deactivated);
}
[Fact]
public void WithActivo_True_ReturnsDomainObjectWithActivoTrue()
{
var pdv = MakePdv(activo: false);
var reactivated = pdv.WithActivo(true);
Assert.True(reactivated.Activo);
}
[Fact]
public void WithActivo_ImmutableFields_Preserved()
{
var pdv = MakePdv(id: 99, medioId: 3);
var toggled = pdv.WithActivo(false);
Assert.Equal(99, toggled.Id);
Assert.Equal(3, toggled.MedioId);
Assert.Equal(pdv.NumeroAFIP, toggled.NumeroAFIP);
Assert.Equal(pdv.Nombre, toggled.Nombre);
}
// ── Constructor sets all properties ───────────────────────────────────────
[Fact]
public void Constructor_SetsAllProperties()
{
var now = DateTime.UtcNow;
var pdv = new PuntoDeVenta(
id: 7,
medioId: 3,
numeroAFIP: 4,
nombre: "PdV Test",
descripcion: "Desc Test",
activo: true,
fechaCreacion: now,
fechaModificacion: null);
Assert.Equal(7, pdv.Id);
Assert.Equal(3, pdv.MedioId);
Assert.Equal(4, pdv.NumeroAFIP);
Assert.Equal("PdV Test", pdv.Nombre);
Assert.Equal("Desc Test", pdv.Descripcion);
Assert.True(pdv.Activo);
Assert.Equal(now, pdv.FechaCreacion);
Assert.Null(pdv.FechaModificacion);
}
}

View File

@@ -0,0 +1,57 @@
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.Tests.Domain;
public class SecuenciaComprobanteTests
{
private static SecuenciaComprobante Make(
int puntoDeVentaId = 1,
TipoComprobante tipo = TipoComprobante.FacturaA,
int ultimoNumero = 0)
=> new(puntoDeVentaId, tipo, ultimoNumero, DateTime.UtcNow, null);
[Fact]
public void Constructor_SetsAllProperties()
{
var now = DateTime.UtcNow;
var seq = new SecuenciaComprobante(
puntoDeVentaId: 3,
tipoComprobante: TipoComprobante.FacturaB,
ultimoNumero: 42,
fechaCreacion: now,
fechaModificacion: null);
Assert.Equal(3, seq.PuntoDeVentaId);
Assert.Equal(TipoComprobante.FacturaB, seq.TipoComprobante);
Assert.Equal(42, seq.UltimoNumero);
Assert.Equal(now, seq.FechaCreacion);
Assert.Null(seq.FechaModificacion);
}
[Fact]
public void ProximoNumero_WhenUltimoNumeroZero_ReturnsOne()
{
var seq = Make(ultimoNumero: 0);
Assert.Equal(1, seq.ProximoNumero);
}
[Fact]
public void ProximoNumero_WhenUltimoNumeroN_ReturnsNPlusOne()
{
var seq = Make(ultimoNumero: 7);
Assert.Equal(8, seq.ProximoNumero);
}
[Fact]
public void AllTipoComprobanteValues_CanBeUsedInConstructor()
{
foreach (TipoComprobante tipo in Enum.GetValues<TipoComprobante>())
{
var seq = Make(tipo: tipo);
Assert.Equal(tipo, seq.TipoComprobante);
}
}
}