From 43877bd4a1156bd48d8218145de13b5b12096f9f Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:21:45 -0300 Subject: [PATCH] feat(domain): entidad PuntoDeVenta + SecuenciaComprobante + TipoComprobante + excepciones --- .../SIGCM2.Domain/Entities/PuntoDeVenta.cs | 77 ++++++++++ .../Entities/SecuenciaComprobante.cs | 34 +++++ .../SIGCM2.Domain/Enums/TipoComprobante.cs | 16 ++ .../NumeroAFIPDuplicadoException.cs | 18 +++ .../PuntoDeVentaInactivoException.cs | 15 ++ .../PuntoDeVentaNotFoundException.cs | 15 ++ .../Domain/PuntoDeVentaTests.cs | 139 ++++++++++++++++++ .../Domain/SecuenciaComprobanteTests.cs | 57 +++++++ 8 files changed, 371 insertions(+) create mode 100644 src/api/SIGCM2.Domain/Entities/PuntoDeVenta.cs create mode 100644 src/api/SIGCM2.Domain/Entities/SecuenciaComprobante.cs create mode 100644 src/api/SIGCM2.Domain/Enums/TipoComprobante.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/NumeroAFIPDuplicadoException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/PuntoDeVentaInactivoException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/PuntoDeVentaNotFoundException.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/SecuenciaComprobanteTests.cs diff --git a/src/api/SIGCM2.Domain/Entities/PuntoDeVenta.cs b/src/api/SIGCM2.Domain/Entities/PuntoDeVenta.cs new file mode 100644 index 0000000..87ec486 --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/PuntoDeVenta.cs @@ -0,0 +1,77 @@ +namespace SIGCM2.Domain.Entities; + +/// +/// 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). +/// +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; + } + + /// + /// Factory para crear un nuevo PdV (Id=0 — BD asigna via IDENTITY; Activo=true; FechaCreacion por DF de BD). + /// + 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); + + /// + /// Retorna una nueva instancia con nombre, numeroAFIP y descripcion actualizados. + /// MedioId es inmutable (enforce en BD). + /// + 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); +} diff --git a/src/api/SIGCM2.Domain/Entities/SecuenciaComprobante.cs b/src/api/SIGCM2.Domain/Entities/SecuenciaComprobante.cs new file mode 100644 index 0000000..c936c67 --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/SecuenciaComprobante.cs @@ -0,0 +1,34 @@ +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Domain.Entities; + +/// +/// 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. +/// +public sealed class SecuenciaComprobante +{ + public int PuntoDeVentaId { get; } + public TipoComprobante TipoComprobante { get; } + public int UltimoNumero { get; } + public DateTime FechaCreacion { get; } + public DateTime? FechaModificacion { get; } + + /// El próximo número disponible (read-only, sin modificar el estado). + 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; + } +} diff --git a/src/api/SIGCM2.Domain/Enums/TipoComprobante.cs b/src/api/SIGCM2.Domain/Enums/TipoComprobante.cs new file mode 100644 index 0000000..e1be111 --- /dev/null +++ b/src/api/SIGCM2.Domain/Enums/TipoComprobante.cs @@ -0,0 +1,16 @@ +namespace SIGCM2.Domain.Enums; + +/// +/// 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. +/// +public enum TipoComprobante : byte +{ + FacturaA = 1, + FacturaB = 2, + FacturaC = 3, + NotaCreditoA = 4, + NotaCreditoB = 5, + NotaCreditoC = 6, +} diff --git a/src/api/SIGCM2.Domain/Exceptions/NumeroAFIPDuplicadoException.cs b/src/api/SIGCM2.Domain/Exceptions/NumeroAFIPDuplicadoException.cs new file mode 100644 index 0000000..01c57c2 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/NumeroAFIPDuplicadoException.cs @@ -0,0 +1,18 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a (MedioId, NumeroAFIP) combination already exists in the system. +/// Enforced by UNIQUE(MedioId, NumeroAFIP) in DB as safety net (REQ-PDV-003). +/// +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; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/PuntoDeVentaInactivoException.cs b/src/api/SIGCM2.Domain/Exceptions/PuntoDeVentaInactivoException.cs new file mode 100644 index 0000000..3405365 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/PuntoDeVentaInactivoException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a mutation (reserva) is attempted on an inactive PuntoDeVenta. +/// +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; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/PuntoDeVentaNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/PuntoDeVentaNotFoundException.cs new file mode 100644 index 0000000..c8578e1 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/PuntoDeVentaNotFoundException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a requested PuntoDeVenta does not exist in the system. +/// +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; + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs b/tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs new file mode 100644 index 0000000..639e66d --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs @@ -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); + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/SecuenciaComprobanteTests.cs b/tests/SIGCM2.Application.Tests/Domain/SecuenciaComprobanteTests.cs new file mode 100644 index 0000000..39c2fa7 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/SecuenciaComprobanteTests.cs @@ -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()) + { + var seq = Make(tipo: tipo); + Assert.Equal(tipo, seq.TipoComprobante); + } + } +}