From bef8977c5c7522d79a36d5006f9d6737d0e00d97 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:16:56 -0300 Subject: [PATCH 01/18] feat(db): migration V013 + SP usp_ReservarNumeroComprobante para ADM-008 - Tabla PuntoDeVenta con Temporal Tables + UNIQUE(MedioId, NumeroAFIP) - Tabla SecuenciaComprobante con Temporal Tables + UNIQUE(PdvId, TipoComprobante) - Permiso administracion:puntos_de_venta:gestionar (guion_bajo: CK_Permiso_Codigo_Format) - SP usp_ReservarNumeroComprobante con SERIALIZABLE + THROW 50001/50002/50003 - V013_ROLLBACK.sql incluido Smoke tests SIGCM2_Test: - TEST 1: primera reserva devuelve 1 (lazy init) OK - TEST 2: segunda reserva devuelve 2 OK - TEST 3: PdV inactivo -> SqlException 50001 'punto_de_venta_inactivo' OK - TEST 4: Medio inactivo -> SqlException 50002 'medio_inactivo' OK Covers: REQ-PDV-001/003/009, REQ-SEC-CMB-001/002/003/004 --- database/migrations/V013_ROLLBACK.sql | 131 ++++++++ .../V013__create_puntos_de_venta.sql | 294 ++++++++++++++++++ 2 files changed, 425 insertions(+) create mode 100644 database/migrations/V013_ROLLBACK.sql create mode 100644 database/migrations/V013__create_puntos_de_venta.sql diff --git a/database/migrations/V013_ROLLBACK.sql b/database/migrations/V013_ROLLBACK.sql new file mode 100644 index 0000000..8226e72 --- /dev/null +++ b/database/migrations/V013_ROLLBACK.sql @@ -0,0 +1,131 @@ +-- V013_ROLLBACK.sql +-- Reversa de V013__create_puntos_de_venta.sql. +-- +-- ADVERTENCIA: ejecutar ELIMINA PuntoDeVenta, SecuenciaComprobante, su historia temporal, +-- el permiso 'administracion:puntos_de_venta:gestionar', sus asignaciones y el SP. +-- +-- Uso intended: ROLLBACK en entornos NO-productivos. +-- Prerequisito: no deben existir FKs vivas apuntando a PuntoDeVenta (p.ej., comprobantes FAC-001). +-- Si FAC-001 ya está aplicado, este rollback fallará — usar backup. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. Drop SP +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ReservarNumeroComprobante; + PRINT 'SP dbo.usp_ReservarNumeroComprobante dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 2. Apagar SYSTEM_VERSIONING + remover PERIOD — SecuenciaComprobante primero (FK a PdV) +-- ═══════════════════════════════════════════════════════════════════════ + +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF); + PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING OFF.'; +END +GO + +IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante')) +BEGIN + ALTER TABLE dbo.SecuenciaComprobante DROP PERIOD FOR SYSTEM_TIME; + PRINT 'SecuenciaComprobante: PERIOD FOR SYSTEM_TIME dropped.'; +END +GO + +IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NOT NULL +BEGIN + ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT IF EXISTS DF_SecuenciaComprobante_ValidFrom; + ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT IF EXISTS DF_SecuenciaComprobante_ValidTo; + ALTER TABLE dbo.SecuenciaComprobante DROP COLUMN ValidFrom, ValidTo; + PRINT 'SecuenciaComprobante: ValidFrom/ValidTo dropped.'; +END +GO + +IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.SecuenciaComprobante_History; + PRINT 'SecuenciaComprobante_History dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 3. Apagar SYSTEM_VERSIONING + remover PERIOD — PuntoDeVenta +-- ═══════════════════════════════════════════════════════════════════════ + +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.PuntoDeVenta SET (SYSTEM_VERSIONING = OFF); + PRINT 'PuntoDeVenta: SYSTEM_VERSIONING OFF.'; +END +GO + +IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta')) +BEGIN + ALTER TABLE dbo.PuntoDeVenta DROP PERIOD FOR SYSTEM_TIME; + PRINT 'PuntoDeVenta: PERIOD FOR SYSTEM_TIME dropped.'; +END +GO + +IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NOT NULL +BEGIN + ALTER TABLE dbo.PuntoDeVenta DROP CONSTRAINT IF EXISTS DF_PuntoDeVenta_ValidFrom; + ALTER TABLE dbo.PuntoDeVenta DROP CONSTRAINT IF EXISTS DF_PuntoDeVenta_ValidTo; + ALTER TABLE dbo.PuntoDeVenta DROP COLUMN ValidFrom, ValidTo; + PRINT 'PuntoDeVenta: ValidFrom/ValidTo dropped.'; +END +GO + +IF OBJECT_ID(N'dbo.PuntoDeVenta_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.PuntoDeVenta_History; + PRINT 'PuntoDeVenta_History dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 4. Drop tablas (SecuenciaComprobante primero por FK) +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.SecuenciaComprobante; + PRINT 'Table dbo.SecuenciaComprobante dropped.'; +END +GO + +IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.PuntoDeVenta; + PRINT 'Table dbo.PuntoDeVenta dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 5. Remover permiso 'administracion:puntos_de_venta:gestionar' + RolPermiso +-- ═══════════════════════════════════════════════════════════════════════ + +DELETE rp +FROM dbo.RolPermiso rp +JOIN dbo.Permiso p ON p.Id = rp.PermisoId +WHERE p.Codigo = 'administracion:puntos_de_venta:gestionar'; +GO + +DELETE FROM dbo.Permiso +WHERE Codigo = 'administracion:puntos_de_venta:gestionar'; +GO + +PRINT ''; +PRINT 'V013 rolled back. dbo.PuntoDeVenta, dbo.SecuenciaComprobante and their history removed.'; +PRINT 'SP dbo.usp_ReservarNumeroComprobante removed.'; +PRINT 'Permiso administracion:puntos_de_venta:gestionar removed.'; +GO diff --git a/database/migrations/V013__create_puntos_de_venta.sql b/database/migrations/V013__create_puntos_de_venta.sql new file mode 100644 index 0000000..0f0669a --- /dev/null +++ b/database/migrations/V013__create_puntos_de_venta.sql @@ -0,0 +1,294 @@ +-- V013__create_puntos_de_venta.sql +-- ADM-008 Puntos de Venta: DDL + SP de reserva atómica de número de comprobante. +-- +-- Cambios: +-- 1. dbo.PuntoDeVenta (FK→Medio, UNIQUE(MedioId,NumeroAFIP), SYSTEM_VERSIONING ON, retention 10Y). +-- 2. dbo.SecuenciaComprobante (FK→PuntoDeVenta, PK(PuntoDeVentaId,TipoComprobante), SYSTEM_VERSIONING ON, retention 10Y). +-- 3. Permiso 'administracion:puntos-de-venta:gestionar' + asignación a rol 'admin'. +-- 4. SP dbo.usp_ReservarNumeroComprobante (SERIALIZABLE, UPDATE+OUTPUT, THROW 50001/50002/50003). +-- +-- Patrón: V011 (Temporal Tables + Permiso MERGE + PAGE compression en history). +-- Idempotente: seguro para re-ejecutar. +-- Reversa: V013_ROLLBACK.sql. +-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests). +-- +-- NOTA: el código de permiso usa guion_bajo (_) según CK_Permiso_Codigo_Format del proyecto. +-- Código efectivo: 'administracion:puntos_de_venta:gestionar' +-- El spec menciona 'administracion:puntos-de-venta:gestionar' (guion) pero el CHECK constraint +-- solo permite [a-z0-9_:] — se usa guion_bajo para cumplir la constraint existente. +-- El backend y frontend deben usar el código con guion_bajo. +-- +-- Covers: REQ-PDV-001, -003, -009, REQ-SEC-CMB-001, -002, -003, -004 +-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.10 ADM-008 +-- +-- NOTA T1.3 — Seeds: NO se seedean PuntoDeVenta. +-- Cada instalación configura sus propios PdVs con el NumeroAFIP real asignado por AFIP/ARCA. +-- Seedear con valores ficticios generaría confusión operativa. El admin los crea manualmente. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. dbo.PuntoDeVenta +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NULL +BEGIN + CREATE TABLE dbo.PuntoDeVenta ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_PuntoDeVenta PRIMARY KEY, + MedioId INT NOT NULL, + NumeroAFIP SMALLINT NOT NULL, + Nombre NVARCHAR(100) NOT NULL, + Descripcion NVARCHAR(255) NULL, + Activo BIT NOT NULL CONSTRAINT DF_PuntoDeVenta_Activo DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_PuntoDeVenta_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT FK_PuntoDeVenta_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, + CONSTRAINT UQ_PuntoDeVenta_Medio_AFIP UNIQUE (MedioId, NumeroAFIP), + CONSTRAINT CK_PuntoDeVenta_NumeroAFIP CHECK (NumeroAFIP >= 1) + ); + PRINT 'Table dbo.PuntoDeVenta created.'; +END +ELSE + PRINT 'Table dbo.PuntoDeVenta already exists — skip.'; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_PuntoDeVenta_MedioId_Activo' AND object_id = OBJECT_ID('dbo.PuntoDeVenta')) +BEGIN + CREATE INDEX IX_PuntoDeVenta_MedioId_Activo + ON dbo.PuntoDeVenta(MedioId, Activo) + INCLUDE (NumeroAFIP, Nombre); + PRINT 'Index IX_PuntoDeVenta_MedioId_Activo created.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 2. dbo.SecuenciaComprobante +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NULL +BEGIN + CREATE TABLE dbo.SecuenciaComprobante ( + PuntoDeVentaId INT NOT NULL, + TipoComprobante TINYINT NOT NULL, + UltimoNumero INT NOT NULL CONSTRAINT DF_SecuenciaComprobante_UltimoNumero DEFAULT(0), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_SecuenciaComprobante_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT PK_SecuenciaComprobante PRIMARY KEY (PuntoDeVentaId, TipoComprobante), + CONSTRAINT FK_SecuenciaComprobante_PuntoDeVenta FOREIGN KEY (PuntoDeVentaId) REFERENCES dbo.PuntoDeVenta(Id) ON DELETE NO ACTION, + CONSTRAINT CK_SecuenciaComprobante_TipoComprobante CHECK (TipoComprobante BETWEEN 1 AND 6), + CONSTRAINT CK_SecuenciaComprobante_UltimoNumero CHECK (UltimoNumero >= 0) + ); + PRINT 'Table dbo.SecuenciaComprobante created.'; +END +ELSE + PRINT 'Table dbo.SecuenciaComprobante already exists — skip.'; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 3. SYSTEM_VERSIONING — PuntoDeVenta +-- ═══════════════════════════════════════════════════════════════════════ + +IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NULL +BEGIN + ALTER TABLE dbo.PuntoDeVenta + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_PuntoDeVenta_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_PuntoDeVenta_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + PRINT 'PuntoDeVenta: PERIOD FOR SYSTEM_TIME added.'; +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.PuntoDeVenta + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.PuntoDeVenta_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'PuntoDeVenta: SYSTEM_VERSIONING = ON (history: dbo.PuntoDeVenta_History, retention: 10 years).'; +END +ELSE + PRINT 'PuntoDeVenta: SYSTEM_VERSIONING already ON — skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'PuntoDeVenta_History' AND schema_id = SCHEMA_ID('dbo')) + AND NOT EXISTS ( + SELECT 1 FROM sys.partitions p + JOIN sys.tables t ON t.object_id = p.object_id + WHERE t.name = 'PuntoDeVenta_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.PuntoDeVenta_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'PuntoDeVenta_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 4. SYSTEM_VERSIONING — SecuenciaComprobante +-- ═══════════════════════════════════════════════════════════════════════ + +IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NULL +BEGIN + ALTER TABLE dbo.SecuenciaComprobante + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_SecuenciaComprobante_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_SecuenciaComprobante_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + PRINT 'SecuenciaComprobante: PERIOD FOR SYSTEM_TIME added.'; +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.SecuenciaComprobante + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.SecuenciaComprobante_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = ON (history: dbo.SecuenciaComprobante_History, retention: 10 years).'; +END +ELSE + PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING already ON — skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'SecuenciaComprobante_History' AND schema_id = SCHEMA_ID('dbo')) + AND NOT EXISTS ( + SELECT 1 FROM sys.partitions p + JOIN sys.tables t ON t.object_id = p.object_id + WHERE t.name = 'SecuenciaComprobante_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.SecuenciaComprobante_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'SecuenciaComprobante_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 5. Permiso: administracion:puntos-de-venta:gestionar +-- ═══════════════════════════════════════════════════════════════════════ + +MERGE dbo.Permiso AS t +USING (VALUES + ('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta AFIP', 'administracion') +) AS s (Codigo, Nombre, Descripcion, Modulo) +ON t.Codigo = s.Codigo +WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Modulo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo); +GO + +MERGE dbo.RolPermiso AS t +USING ( + SELECT r.Id AS RolId, p.Id AS PermisoId + FROM (VALUES + ('admin', 'administracion:puntos_de_venta:gestionar') + ) AS x (RolCodigo, PermisoCodigo) + JOIN dbo.Rol r ON r.Codigo = x.RolCodigo + JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo +) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId +WHEN NOT MATCHED BY TARGET THEN + INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId); +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 6. SP: dbo.usp_ReservarNumeroComprobante +-- ═══════════════════════════════════════════════════════════════════════ +-- Decisión AD1: SP con UPDATE+OUTPUT bajo SERIALIZABLE (lógica crítica en SP; +-- permite validar PdV/Medio activos en DB; atómico sin race). +-- Decisión AD2: patrón "UPDATE si existe, INSERT si @@ROWCOUNT=0" (lazy init; +-- sin trigger; simple; no genera filas huérfanas). +-- El retry ante deadlock (SqlException 1205) vive en el Application handler +-- (ReservarNumeroCommandHandler), NO en este SP. Máx 3 intentos, 50/150/450ms. +-- NO envolver la llamada en TransactionScope externo: el SP ya es atómico. +-- Un TransactionScope externo con otra conexión escalaría a MSDTC innecesariamente. + +IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL + DROP PROCEDURE dbo.usp_ReservarNumeroComprobante; +GO + +CREATE PROCEDURE dbo.usp_ReservarNumeroComprobante + @PuntoDeVentaId INT, + @TipoComprobante TINYINT, + @NumeroReservado INT OUTPUT +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRAN; + + -- Validar existencia y estado de PdV + Medio padre en una sola lectura. + -- Con SERIALIZABLE, el lock de rango protege contra inserciones concurrentes + -- de la misma fila de SecuenciaComprobante entre el SELECT y el UPDATE/INSERT. + DECLARE @PdvActivo BIT; + DECLARE @MedioActivo BIT; + + SELECT + @PdvActivo = p.Activo, + @MedioActivo = m.Activo + FROM dbo.PuntoDeVenta p + JOIN dbo.Medio m ON m.Id = p.MedioId + WHERE p.Id = @PuntoDeVentaId; + + IF @PdvActivo IS NULL + BEGIN + ROLLBACK; + THROW 50003, 'punto_de_venta_not_found', 1; + END + + IF @PdvActivo = 0 + BEGIN + ROLLBACK; + THROW 50001, 'punto_de_venta_inactivo', 1; + END + + IF @MedioActivo = 0 + BEGIN + ROLLBACK; + THROW 50002, 'medio_inactivo', 1; + END + + -- Intentar actualizar la fila existente y capturar el nuevo número. + DECLARE @_out TABLE (n INT NOT NULL); + + UPDATE dbo.SecuenciaComprobante + SET + UltimoNumero = UltimoNumero + 1, + FechaModificacion = SYSUTCDATETIME() + OUTPUT inserted.UltimoNumero INTO @_out(n) + WHERE PuntoDeVentaId = @PuntoDeVentaId + AND TipoComprobante = @TipoComprobante; + + IF @@ROWCOUNT = 0 + BEGIN + -- Primera reserva para este par (PdvId, TipoComprobante): inicialización lazy. + INSERT INTO dbo.SecuenciaComprobante (PuntoDeVentaId, TipoComprobante, UltimoNumero) + VALUES (@PuntoDeVentaId, @TipoComprobante, 1); + SET @NumeroReservado = 1; + END + ELSE + BEGIN + SELECT @NumeroReservado = n FROM @_out; + END + + COMMIT; +END +GO + +PRINT ''; +PRINT 'V013 applied successfully.'; +PRINT ' - dbo.PuntoDeVenta (temporal, retention 10y, PAGE compression)'; +PRINT ' - dbo.SecuenciaComprobante (temporal, retention 10y, PAGE compression)'; +PRINT ' - Permiso administracion:puntos-de-venta:gestionar (asignado a admin)'; +PRINT ' - SP dbo.usp_ReservarNumeroComprobante'; +PRINT 'Next: Batch 2 — Domain (PuntoDeVenta entity + exceptions + TipoComprobante enum).'; +GO From 43877bd4a1156bd48d8218145de13b5b12096f9f Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:21:45 -0300 Subject: [PATCH 02/18] 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); + } + } +} From 50f6f2b67a78fa5c9cb139dddf3f51c6f58b7e5b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:28:11 -0300 Subject: [PATCH 03/18] =?UTF-8?q?feat(application):=20repository=20abstrac?= =?UTF-8?q?tion=20+=20DTOs=20+=20validators=20+=20handlers=20CRUD=20Puntos?= =?UTF-8?q?DeVenta=20con=20auditor=C3=ADa=20+=20retry=20deadlock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Persistence/IPuntoDeVentaRepository.cs | 16 +++ .../Common/PuntosDeVentaQuery.cs | 9 ++ .../Create/CreatePuntoDeVentaCommand.cs | 7 + .../CreatePuntoDeVentaCommandHandler.cs | 75 +++++++++++ .../CreatePuntoDeVentaCommandValidator.cs | 29 ++++ .../Create/PuntoDeVentaCreatedDto.cs | 9 ++ .../DeactivatePuntoDeVentaCommand.cs | 3 + .../DeactivatePuntoDeVentaCommandHandler.cs | 53 ++++++++ .../Deactivate/PuntoDeVentaStatusDto.cs | 3 + .../GetById/GetPuntoDeVentaByIdQuery.cs | 3 + .../GetPuntoDeVentaByIdQueryHandler.cs | 31 +++++ .../GetById/PuntoDeVentaDetailDto.cs | 11 ++ .../List/ListPuntosDeVentaQuery.cs | 7 + .../List/ListPuntosDeVentaQueryHandler.cs | 30 +++++ .../List/PuntoDeVentaListItemDto.cs | 8 ++ .../ProximoNumero/GetProximoNumeroQuery.cs | 5 + .../GetProximoNumeroQueryHandler.cs | 27 ++++ .../ProximoNumero/ProximoNumeroDto.cs | 5 + .../ReactivatePuntoDeVentaCommand.cs | 3 + .../ReactivatePuntoDeVentaCommandHandler.cs | 60 +++++++++ .../Reservar/ReservaNumeroDto.cs | 5 + .../Reservar/ReservarNumeroCommand.cs | 5 + .../Reservar/ReservarNumeroCommandHandler.cs | 53 ++++++++ .../Update/PuntoDeVentaUpdatedDto.cs | 9 ++ .../Update/UpdatePuntoDeVentaCommand.cs | 7 + .../UpdatePuntoDeVentaCommandHandler.cs | 71 ++++++++++ .../UpdatePuntoDeVentaCommandValidator.cs | 29 ++++ .../Exceptions/DeadlockTransientException.cs | 14 ++ .../CreatePuntoDeVentaCommandHandlerTests.cs | 122 +++++++++++++++++ ...activatePuntoDeVentaCommandHandlerTests.cs | 86 ++++++++++++ .../GetPuntoDeVentaByIdQueryHandlerTests.cs | 46 +++++++ .../ListPuntosDeVentaQueryHandlerTests.cs | 76 +++++++++++ .../GetProximoNumeroQueryHandlerTests.cs | 52 ++++++++ ...activatePuntoDeVentaCommandHandlerTests.cs | 98 ++++++++++++++ .../ReservarNumeroCommandHandlerTests.cs | 126 ++++++++++++++++++ .../UpdatePuntoDeVentaCommandHandlerTests.cs | 103 ++++++++++++++ 36 files changed, 1296 insertions(+) create mode 100644 src/api/SIGCM2.Application/Abstractions/Persistence/IPuntoDeVentaRepository.cs create mode 100644 src/api/SIGCM2.Application/Common/PuntosDeVentaQuery.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommand.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Create/PuntoDeVentaCreatedDto.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommand.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/PuntoDeVentaStatusDto.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQuery.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/GetById/PuntoDeVentaDetailDto.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQuery.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/List/PuntoDeVentaListItemDto.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQuery.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/ProximoNumeroDto.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommand.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservaNumeroDto.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommand.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Update/PuntoDeVentaUpdatedDto.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommand.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandValidator.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/DeadlockTransientException.cs create mode 100644 tests/SIGCM2.Application.Tests/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/PuntosDeVenta/List/ListPuntosDeVentaQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/PuntosDeVenta/Reservar/ReservarNumeroCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandlerTests.cs diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IPuntoDeVentaRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IPuntoDeVentaRepository.cs new file mode 100644 index 0000000..5d5cabd --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IPuntoDeVentaRepository.cs @@ -0,0 +1,16 @@ +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface IPuntoDeVentaRepository +{ + Task AddAsync(PuntoDeVenta pdv, CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task ExistsByNumeroAFIPInMedioAsync(int medioId, short numeroAFIP, int? excludeId = null, CancellationToken ct = default); + Task UpdateAsync(PuntoDeVenta pdv, CancellationToken ct = default); + Task> GetPagedAsync(PuntosDeVentaQuery q, CancellationToken ct = default); + Task ReservarNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default); + Task GetUltimoNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Common/PuntosDeVentaQuery.cs b/src/api/SIGCM2.Application/Common/PuntosDeVentaQuery.cs new file mode 100644 index 0000000..668242f --- /dev/null +++ b/src/api/SIGCM2.Application/Common/PuntosDeVentaQuery.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Common; + +/// Query parameters for listing puntos de venta with optional filters and paging. +public sealed record PuntosDeVentaQuery( + int Page, + int PageSize, + int? MedioId, + bool? Activo +); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommand.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommand.cs new file mode 100644 index 0000000..8cb9d87 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.PuntosDeVenta.Create; + +public sealed record CreatePuntoDeVentaCommand( + int MedioId, + short NumeroAFIP, + string Nombre, + string? Descripcion); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandler.cs new file mode 100644 index 0000000..10cfc19 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandler.cs @@ -0,0 +1,75 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.PuntosDeVenta.Create; + +public sealed class CreatePuntoDeVentaCommandHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + private readonly IMedioRepository _medioRepo; + private readonly IAuditLogger _audit; + + public CreatePuntoDeVentaCommandHandler( + IPuntoDeVentaRepository repo, + IMedioRepository medioRepo, + IAuditLogger audit) + { + _repo = repo; + _medioRepo = medioRepo; + _audit = audit; + } + + public async Task Handle(CreatePuntoDeVentaCommand command) + { + // Validate medio exists and is active (REQ-PDV-001, -002) + var medio = await _medioRepo.GetByIdAsync(command.MedioId) + ?? throw new MedioNotFoundException(command.MedioId); + + if (!medio.Activo) + throw new MedioInactivoException(medio.Id); + + // Check uniqueness NumeroAFIP within Medio (REQ-PDV-003) + var exists = await _repo.ExistsByNumeroAFIPInMedioAsync(command.MedioId, command.NumeroAFIP, excludeId: null); + if (exists) + throw new NumeroAFIPDuplicadoException(command.MedioId, command.NumeroAFIP); + + var pdv = PuntoDeVenta.ForCreation(command.MedioId, command.NumeroAFIP, command.Nombre, command.Descripcion); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + var newId = await _repo.AddAsync(pdv); + + // fail-closed: if LogAsync throws, tx rolls back (REQ-PDV-010) + await _audit.LogAsync( + action: "punto_de_venta.create", + targetType: "PuntoDeVenta", + targetId: newId.ToString(), + metadata: new + { + after = new + { + pdv.MedioId, + pdv.NumeroAFIP, + pdv.Nombre, + pdv.Descripcion, + }, + }); + + tx.Complete(); + + return new PuntoDeVentaCreatedDto( + Id: newId, + MedioId: pdv.MedioId, + NumeroAFIP: pdv.NumeroAFIP, + Nombre: pdv.Nombre, + Descripcion: pdv.Descripcion, + Activo: pdv.Activo); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandValidator.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandValidator.cs new file mode 100644 index 0000000..d6db8cc --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; + +namespace SIGCM2.Application.PuntosDeVenta.Create; + +public sealed class CreatePuntoDeVentaCommandValidator : AbstractValidator +{ + private const int NombreMaxLength = 100; + private const int DescripcionMaxLength = 255; + private const short NumeroAFIPMin = 1; + private const short NumeroAFIPMax = 9999; + + public CreatePuntoDeVentaCommandValidator() + { + RuleFor(x => x.MedioId) + .GreaterThan(0).WithMessage("El medioId debe ser mayor a 0."); + + RuleFor(x => x.NumeroAFIP) + .InclusiveBetween(NumeroAFIPMin, NumeroAFIPMax) + .WithMessage($"El número AFIP debe estar entre {NumeroAFIPMin} y {NumeroAFIPMax}."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Descripcion) + .MaximumLength(DescripcionMaxLength).WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.") + .When(x => x.Descripcion is not null); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Create/PuntoDeVentaCreatedDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Create/PuntoDeVentaCreatedDto.cs new file mode 100644 index 0000000..787ef1c --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Create/PuntoDeVentaCreatedDto.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.PuntosDeVenta.Create; + +public sealed record PuntoDeVentaCreatedDto( + int Id, + int MedioId, + short NumeroAFIP, + string Nombre, + string? Descripcion, + bool Activo); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommand.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommand.cs new file mode 100644 index 0000000..6218c4e --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.PuntosDeVenta.Deactivate; + +public sealed record DeactivatePuntoDeVentaCommand(int Id); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandler.cs new file mode 100644 index 0000000..87fc908 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandler.cs @@ -0,0 +1,53 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.PuntosDeVenta.Deactivate; + +public sealed class DeactivatePuntoDeVentaCommandHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + private readonly IMedioRepository _medioRepo; + private readonly IAuditLogger _audit; + + public DeactivatePuntoDeVentaCommandHandler( + IPuntoDeVentaRepository repo, + IMedioRepository medioRepo, + IAuditLogger audit) + { + _repo = repo; + _medioRepo = medioRepo; + _audit = audit; + } + + public async Task Handle(DeactivatePuntoDeVentaCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new PuntoDeVentaNotFoundException(command.Id); + + // Idempotent: already inactive → return as-is without writing an audit event + if (!target.Activo) + return new PuntoDeVentaStatusDto(target.Id, target.NumeroAFIP, target.Activo); + + var updated = target.WithActivo(false); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "punto_de_venta.deactivate", + targetType: "PuntoDeVenta", + targetId: command.Id.ToString()); + + tx.Complete(); + + return new PuntoDeVentaStatusDto(updated.Id, updated.NumeroAFIP, updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/PuntoDeVentaStatusDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/PuntoDeVentaStatusDto.cs new file mode 100644 index 0000000..2209877 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/PuntoDeVentaStatusDto.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.PuntosDeVenta.Deactivate; + +public sealed record PuntoDeVentaStatusDto(int Id, short NumeroAFIP, bool Activo); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQuery.cs b/src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQuery.cs new file mode 100644 index 0000000..dbf8085 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.PuntosDeVenta.GetById; + +public sealed record GetPuntoDeVentaByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandler.cs new file mode 100644 index 0000000..ca0e24b --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandler.cs @@ -0,0 +1,31 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.PuntosDeVenta.GetById; + +public sealed class GetPuntoDeVentaByIdQueryHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + + public GetPuntoDeVentaByIdQueryHandler(IPuntoDeVentaRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetPuntoDeVentaByIdQuery query) + { + var pdv = await _repo.GetByIdAsync(query.Id) + ?? throw new PuntoDeVentaNotFoundException(query.Id); + + return new PuntoDeVentaDetailDto( + Id: pdv.Id, + MedioId: pdv.MedioId, + NumeroAFIP: pdv.NumeroAFIP, + Nombre: pdv.Nombre, + Descripcion: pdv.Descripcion, + Activo: pdv.Activo, + FechaCreacion: pdv.FechaCreacion, + FechaModificacion: pdv.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/GetById/PuntoDeVentaDetailDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/GetById/PuntoDeVentaDetailDto.cs new file mode 100644 index 0000000..b1136ca --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/GetById/PuntoDeVentaDetailDto.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Application.PuntosDeVenta.GetById; + +public sealed record PuntoDeVentaDetailDto( + int Id, + int MedioId, + short NumeroAFIP, + string Nombre, + string? Descripcion, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQuery.cs b/src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQuery.cs new file mode 100644 index 0000000..c93239f --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQuery.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.PuntosDeVenta.List; + +public sealed record ListPuntosDeVentaQuery( + int Page, + int PageSize, + int? MedioId, + bool? Activo); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQueryHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQueryHandler.cs new file mode 100644 index 0000000..a4653cb --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQueryHandler.cs @@ -0,0 +1,30 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.PuntosDeVenta.List; + +public sealed class ListPuntosDeVentaQueryHandler : ICommandHandler> +{ + private readonly IPuntoDeVentaRepository _repo; + + public ListPuntosDeVentaQueryHandler(IPuntoDeVentaRepository repo) + { + _repo = repo; + } + + public async Task> Handle(ListPuntosDeVentaQuery query) + { + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + + var repoQuery = new PuntosDeVentaQuery(page, pageSize, query.MedioId, query.Activo); + var paged = await _repo.GetPagedAsync(repoQuery); + + var items = paged.Items + .Select(p => new PuntoDeVentaListItemDto(p.Id, p.MedioId, p.NumeroAFIP, p.Nombre, p.Activo)) + .ToList(); + + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/List/PuntoDeVentaListItemDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/List/PuntoDeVentaListItemDto.cs new file mode 100644 index 0000000..c10cd67 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/List/PuntoDeVentaListItemDto.cs @@ -0,0 +1,8 @@ +namespace SIGCM2.Application.PuntosDeVenta.List; + +public sealed record PuntoDeVentaListItemDto( + int Id, + int MedioId, + short NumeroAFIP, + string Nombre, + bool Activo); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQuery.cs b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQuery.cs new file mode 100644 index 0000000..7fb0ada --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQuery.cs @@ -0,0 +1,5 @@ +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero; + +public sealed record GetProximoNumeroQuery(int PuntoDeVentaId, TipoComprobante TipoComprobante); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandler.cs new file mode 100644 index 0000000..e0c930c --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandler.cs @@ -0,0 +1,27 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero; + +/// +/// Consulta el próximo número disponible sin reservarlo (read-only, REQ-SEC-CMB-005). +/// Retorna UltimoNumero+1; si no existe fila devuelve 1. +/// +public sealed class GetProximoNumeroQueryHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + + public GetProximoNumeroQueryHandler(IPuntoDeVentaRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetProximoNumeroQuery query) + { + var ultimoNumero = await _repo.GetUltimoNumeroAsync(query.PuntoDeVentaId, query.TipoComprobante); + + var proximo = ultimoNumero.HasValue ? ultimoNumero.Value + 1 : 1; + + return new ProximoNumeroDto(query.TipoComprobante, proximo); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/ProximoNumeroDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/ProximoNumeroDto.cs new file mode 100644 index 0000000..3b2fb8f --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/ProximoNumeroDto.cs @@ -0,0 +1,5 @@ +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero; + +public sealed record ProximoNumeroDto(TipoComprobante TipoComprobante, int ProximoNumero); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommand.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommand.cs new file mode 100644 index 0000000..e60a889 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.PuntosDeVenta.Reactivate; + +public sealed record ReactivatePuntoDeVentaCommand(int Id); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandler.cs new file mode 100644 index 0000000..6ea1e69 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandler.cs @@ -0,0 +1,60 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.PuntosDeVenta.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.PuntosDeVenta.Reactivate; + +public sealed class ReactivatePuntoDeVentaCommandHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + private readonly IMedioRepository _medioRepo; + private readonly IAuditLogger _audit; + + public ReactivatePuntoDeVentaCommandHandler( + IPuntoDeVentaRepository repo, + IMedioRepository medioRepo, + IAuditLogger audit) + { + _repo = repo; + _medioRepo = medioRepo; + _audit = audit; + } + + public async Task Handle(ReactivatePuntoDeVentaCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new PuntoDeVentaNotFoundException(command.Id); + + var medio = await _medioRepo.GetByIdAsync(target.MedioId) + ?? throw new MedioNotFoundException(target.MedioId); + + if (!medio.Activo) + throw new MedioInactivoException(medio.Id); + + // Idempotent: already active → return as-is without writing an audit event + if (target.Activo) + return new PuntoDeVentaStatusDto(target.Id, target.NumeroAFIP, target.Activo); + + var updated = target.WithActivo(true); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "punto_de_venta.reactivate", + targetType: "PuntoDeVenta", + targetId: command.Id.ToString()); + + tx.Complete(); + + return new PuntoDeVentaStatusDto(updated.Id, updated.NumeroAFIP, updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservaNumeroDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservaNumeroDto.cs new file mode 100644 index 0000000..1544c7e --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservaNumeroDto.cs @@ -0,0 +1,5 @@ +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Application.PuntosDeVenta.Reservar; + +public sealed record ReservaNumeroDto(TipoComprobante TipoComprobante, int NumeroReservado); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommand.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommand.cs new file mode 100644 index 0000000..44d4918 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommand.cs @@ -0,0 +1,5 @@ +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Application.PuntosDeVenta.Reservar; + +public sealed record ReservarNumeroCommand(int PuntoDeVentaId, TipoComprobante TipoComprobante); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs new file mode 100644 index 0000000..ce19ee1 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs @@ -0,0 +1,53 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.PuntosDeVenta.Reservar; + +/// +/// Reserva el próximo número correlativo para (PdvId × TipoComprobante) ejecutando +/// usp_ReservarNumeroComprobante vía el repositorio. +/// +/// NOTAS DE DISEÑO (AD4, AD9): +/// - NO se envuelve en TransactionScope: el SP ya es atómico bajo SERIALIZABLE. +/// Un TransactionScope ambiente aquí escalaría a DTC → innecesario. +/// - NO usa Polly: no está en el proyecto. Retry deadlock con bucle simple. +/// - Infrastructure traduce SqlException 1205 → DeadlockTransientException. +/// - Backoff en ms: [50, 150, 450] — 3 intentos máximo. +/// - La auditoría de reservas corre solo vía Temporal Tables (AD8). +/// +public sealed class ReservarNumeroCommandHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + private readonly int[] _deadlockBackoffMs; + + private static readonly int[] DefaultBackoffMs = [50, 150, 450]; + + public ReservarNumeroCommandHandler(IPuntoDeVentaRepository repo) + : this(repo, DefaultBackoffMs) { } + + /// Constructor with custom backoff for testing (e.g., [0,0,0] for fast tests). + public ReservarNumeroCommandHandler(IPuntoDeVentaRepository repo, int[] deadlockBackoffMs) + { + _repo = repo; + _deadlockBackoffMs = deadlockBackoffMs; + } + + public async Task Handle(ReservarNumeroCommand command) + { + for (var i = 0; ; i++) + { + try + { + var numero = await _repo.ReservarNumeroAsync(command.PuntoDeVentaId, command.TipoComprobante); + return new ReservaNumeroDto(command.TipoComprobante, numero); + } + catch (DeadlockTransientException) when (i < _deadlockBackoffMs.Length) + { + // Deadlock — retry with backoff + await Task.Delay(_deadlockBackoffMs[i]); + } + // All other exceptions bubble up immediately + } + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Update/PuntoDeVentaUpdatedDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Update/PuntoDeVentaUpdatedDto.cs new file mode 100644 index 0000000..ee5065d --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Update/PuntoDeVentaUpdatedDto.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.PuntosDeVenta.Update; + +public sealed record PuntoDeVentaUpdatedDto( + int Id, + int MedioId, + short NumeroAFIP, + string Nombre, + string? Descripcion, + bool Activo); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommand.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommand.cs new file mode 100644 index 0000000..69bff98 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.PuntosDeVenta.Update; + +public sealed record UpdatePuntoDeVentaCommand( + int Id, + string Nombre, + short NumeroAFIP, + string? Descripcion); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandler.cs new file mode 100644 index 0000000..8daf0c0 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandler.cs @@ -0,0 +1,71 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.PuntosDeVenta.Update; + +public sealed class UpdatePuntoDeVentaCommandHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + private readonly IMedioRepository _medioRepo; + private readonly IAuditLogger _audit; + + public UpdatePuntoDeVentaCommandHandler( + IPuntoDeVentaRepository repo, + IMedioRepository medioRepo, + IAuditLogger audit) + { + _repo = repo; + _medioRepo = medioRepo; + _audit = audit; + } + + public async Task Handle(UpdatePuntoDeVentaCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new PuntoDeVentaNotFoundException(command.Id); + + var medio = await _medioRepo.GetByIdAsync(target.MedioId) + ?? throw new MedioNotFoundException(target.MedioId); + + if (!medio.Activo) + throw new MedioInactivoException(medio.Id); + + // Re-validate uniqueness excluding current entity (REQ-PDV-004) + var exists = await _repo.ExistsByNumeroAFIPInMedioAsync(target.MedioId, command.NumeroAFIP, excludeId: command.Id); + if (exists) + throw new NumeroAFIPDuplicadoException(target.MedioId, command.NumeroAFIP); + + var updated = target.WithUpdatedProfile(command.Nombre, command.NumeroAFIP, command.Descripcion); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "punto_de_venta.update", + targetType: "PuntoDeVenta", + targetId: command.Id.ToString(), + metadata: new + { + before = new { target.Nombre, target.NumeroAFIP, target.Descripcion }, + after = new { updated.Nombre, updated.NumeroAFIP, updated.Descripcion }, + }); + + tx.Complete(); + + return new PuntoDeVentaUpdatedDto( + Id: updated.Id, + MedioId: updated.MedioId, + NumeroAFIP: updated.NumeroAFIP, + Nombre: updated.Nombre, + Descripcion: updated.Descripcion, + Activo: updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandValidator.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandValidator.cs new file mode 100644 index 0000000..eb313a2 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; + +namespace SIGCM2.Application.PuntosDeVenta.Update; + +public sealed class UpdatePuntoDeVentaCommandValidator : AbstractValidator +{ + private const int NombreMaxLength = 100; + private const int DescripcionMaxLength = 255; + private const short NumeroAFIPMin = 1; + private const short NumeroAFIPMax = 9999; + + public UpdatePuntoDeVentaCommandValidator() + { + RuleFor(x => x.Id) + .GreaterThan(0).WithMessage("El id debe ser mayor a 0."); + + RuleFor(x => x.NumeroAFIP) + .InclusiveBetween(NumeroAFIPMin, NumeroAFIPMax) + .WithMessage($"El número AFIP debe estar entre {NumeroAFIPMin} y {NumeroAFIPMax}."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Descripcion) + .MaximumLength(DescripcionMaxLength).WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.") + .When(x => x.Descripcion is not null); + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/DeadlockTransientException.cs b/src/api/SIGCM2.Domain/Exceptions/DeadlockTransientException.cs new file mode 100644 index 0000000..fef02a6 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/DeadlockTransientException.cs @@ -0,0 +1,14 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown by Infrastructure when a database deadlock (SQL 1205) is detected. +/// Allows Application handlers to retry without referencing SqlClient. +/// +public sealed class DeadlockTransientException : DomainException +{ + public DeadlockTransientException() + : base("Se detectó un deadlock en la base de datos. Reintentando operación.") { } + + public DeadlockTransientException(Exception innerException) + : base("Se detectó un deadlock en la base de datos. Reintentando operación.", innerException) { } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandlerTests.cs new file mode 100644 index 0000000..da2744a --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandlerTests.cs @@ -0,0 +1,122 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.PuntosDeVenta.Create; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.Create; + +public class CreatePuntoDeVentaCommandHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly IMedioRepository _medioRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly CreatePuntoDeVentaCommandHandler _handler; + + private static Medio MakeMedio(int id = 5, bool activo = true) + => new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + private static CreatePuntoDeVentaCommand ValidCommand() => new( + MedioId: 5, + NumeroAFIP: 1, + Nombre: "PdV Central", + Descripcion: null); + + public CreatePuntoDeVentaCommandHandlerTests() + { + _handler = new CreatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit); + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5)); + _repo.ExistsByNumeroAFIPInMedioAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(10); + } + + // ── medio not found → throws ───────────────────────────────────────────── + + [Fact] + public async Task Handle_MedioNotFound_ThrowsMedioNotFoundException() + { + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns((Medio?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + // ── medio inactivo → throws ────────────────────────────────────────────── + + [Fact] + public async Task Handle_MedioInactivo_ThrowsMedioInactivoException() + { + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5, false)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + // ── NumeroAFIP duplicado → throws ──────────────────────────────────────── + + [Fact] + public async Task Handle_NumeroAFIPDuplicado_ThrowsNumeroAFIPDuplicadoException() + { + _repo.ExistsByNumeroAFIPInMedioAsync(5, 1, null, Arg.Any()).Returns(true); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + [Fact] + public async Task Handle_NumeroAFIPDuplicado_DoesNotCallAddAsync() + { + _repo.ExistsByNumeroAFIPInMedioAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + + try { await _handler.Handle(ValidCommand()); } catch (NumeroAFIPDuplicadoException) { } + + await _repo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithIdFromRepository() + { + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(10, result.Id); + } + + [Fact] + public async Task Handle_HappyPath_DtoContainsCorrectFields() + { + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(5, result.MedioId); + Assert.Equal(1, result.NumeroAFIP); + Assert.Equal("PdV Central", result.Nombre); + Assert.True(result.Activo); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditWithCreateAction() + { + await _handler.Handle(ValidCommand()); + + await _audit.Received(1).LogAsync( + action: "punto_de_venta.create", + targetType: "PuntoDeVenta", + targetId: "10", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + // ── audit fail-closed ──────────────────────────────────────────────────── + + [Fact] + public async Task Handle_AuditLoggerThrows_ExceptionBubblesUpAndAddNotCommitted() + { + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("audit fail"))); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandlerTests.cs new file mode 100644 index 0000000..5ac8a93 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandlerTests.cs @@ -0,0 +1,86 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.PuntosDeVenta.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.Deactivate; + +public class DeactivatePuntoDeVentaCommandHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly IMedioRepository _medioRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly DeactivatePuntoDeVentaCommandHandler _handler; + + private static PuntoDeVenta MakePdv(int id = 10, int medioId = 5, bool activo = true) + => new(id, medioId, 1, "PdV Test", null, activo, DateTime.UtcNow, null); + + private static Medio MakeMedio(int id = 5, bool activo = true) + => new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + public DeactivatePuntoDeVentaCommandHandlerTests() + { + _handler = new DeactivatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit); + _medioRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(MakeMedio(5, true)); + } + + [Fact] + public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((PuntoDeVenta?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivatePuntoDeVentaCommand(999))); + } + + [Fact] + public async Task Handle_AlreadyInactive_IsIdempotentAndDoesNotCallUpdateAsync() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: false)); + + await _handler.Handle(new DeactivatePuntoDeVentaCommand(10)); + + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AlreadyInactive_DoesNotWriteAuditEvent() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: false)); + + await _handler.Handle(new DeactivatePuntoDeVentaCommand(10)); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_ActivePdv_CallsUpdateAsyncWithInactiveEntity() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: true)); + + await _handler.Handle(new DeactivatePuntoDeVentaCommand(10)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(p => !p.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_ActivePdv_WritesAuditWithDeactivateAction() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: true)); + + await _handler.Handle(new DeactivatePuntoDeVentaCommand(10)); + + await _audit.Received(1).LogAsync( + action: "punto_de_venta.deactivate", + targetType: "PuntoDeVenta", + targetId: "10", + metadata: Arg.Any(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandlerTests.cs new file mode 100644 index 0000000..2602f11 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandlerTests.cs @@ -0,0 +1,46 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.PuntosDeVenta.GetById; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.GetById; + +public class GetPuntoDeVentaByIdQueryHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly GetPuntoDeVentaByIdQueryHandler _handler; + + public GetPuntoDeVentaByIdQueryHandlerTests() + { + _handler = new GetPuntoDeVentaByIdQueryHandler(_repo); + } + + private static PuntoDeVenta MakePdv(int id = 5) => + new(id, 2, 3, "PdV " + id, "Desc", true, DateTime.UtcNow, null); + + [Fact] + public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((PuntoDeVenta?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new GetPuntoDeVentaByIdQuery(999))); + } + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithCorrectFields() + { + var pdv = MakePdv(5); + _repo.GetByIdAsync(5, Arg.Any()).Returns(pdv); + + var result = await _handler.Handle(new GetPuntoDeVentaByIdQuery(5)); + + Assert.Equal(5, result.Id); + Assert.Equal(2, result.MedioId); + Assert.Equal(3, result.NumeroAFIP); + Assert.Equal("PdV 5", result.Nombre); + Assert.Equal("Desc", result.Descripcion); + Assert.True(result.Activo); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/List/ListPuntosDeVentaQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/List/ListPuntosDeVentaQueryHandlerTests.cs new file mode 100644 index 0000000..faa8e0c --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/List/ListPuntosDeVentaQueryHandlerTests.cs @@ -0,0 +1,76 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.PuntosDeVenta.List; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.List; + +public class ListPuntosDeVentaQueryHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly ListPuntosDeVentaQueryHandler _handler; + + public ListPuntosDeVentaQueryHandlerTests() + { + _handler = new ListPuntosDeVentaQueryHandler(_repo); + } + + private static PuntoDeVenta MakePdv(int id) => + new(id, 5, (short)id, "PdV " + id, null, true, DateTime.UtcNow, null); + + [Fact] + public async Task Handle_ReturnsPagedDtoItems() + { + var items = new List { MakePdv(1), MakePdv(2) }; + var pagedResult = new PagedResult(items, 1, 20, 2); + + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(pagedResult); + + var result = await _handler.Handle(new ListPuntosDeVentaQuery(1, 20, null, null)); + + Assert.Equal(2, result.Total); + Assert.Equal(2, result.Items.Count); + Assert.Equal(1, result.Items[0].NumeroAFIP); + } + + [Fact] + public async Task Handle_ClampsPageSizeToMax100() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 100, 0)); + + await _handler.Handle(new ListPuntosDeVentaQuery(1, 500, null, null)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.PageSize == 100), + Arg.Any()); + } + + [Fact] + public async Task Handle_ClampsPageToMin1() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + await _handler.Handle(new ListPuntosDeVentaQuery(0, 20, null, null)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Page == 1), + Arg.Any()); + } + + [Fact] + public async Task Handle_FiltersByMedioId() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + await _handler.Handle(new ListPuntosDeVentaQuery(1, 20, MedioId: 5, null)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.MedioId == 5), + Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandlerTests.cs new file mode 100644 index 0000000..4adc03b --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandlerTests.cs @@ -0,0 +1,52 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.PuntosDeVenta.ProximoNumero; +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.ProximoNumero; + +public class GetProximoNumeroQueryHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly GetProximoNumeroQueryHandler _handler; + + public GetProximoNumeroQueryHandlerTests() + { + _handler = new GetProximoNumeroQueryHandler(_repo); + } + + [Fact] + public async Task Handle_ExistingSequence_ReturnsUltimoNumeroMasUno() + { + _repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Returns(7); + + var result = await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaA)); + + Assert.Equal(TipoComprobante.FacturaA, result.TipoComprobante); + Assert.Equal(8, result.ProximoNumero); + } + + [Fact] + public async Task Handle_NoExistingSequence_ReturnsOne() + { + _repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaB, Arg.Any()) + .Returns((int?)null); + + var result = await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaB)); + + Assert.Equal(1, result.ProximoNumero); + } + + [Fact] + public async Task Handle_DoesNotCallReservarNumero() + { + _repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Returns(5); + + await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaA)); + + await _repo.DidNotReceive().ReservarNumeroAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandlerTests.cs new file mode 100644 index 0000000..b810d82 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandlerTests.cs @@ -0,0 +1,98 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.PuntosDeVenta.Deactivate; +using SIGCM2.Application.PuntosDeVenta.Reactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.Reactivate; + +public class ReactivatePuntoDeVentaCommandHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly IMedioRepository _medioRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly ReactivatePuntoDeVentaCommandHandler _handler; + + private static PuntoDeVenta MakePdv(int id = 10, int medioId = 5, bool activo = false) + => new(id, medioId, 1, "PdV Test", null, activo, DateTime.UtcNow, null); + + private static Medio MakeMedio(int id = 5, bool activo = true) + => new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + public ReactivatePuntoDeVentaCommandHandlerTests() + { + _handler = new ReactivatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit); + _medioRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(MakeMedio(5, true)); + } + + [Fact] + public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((PuntoDeVenta?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ReactivatePuntoDeVentaCommand(999))); + } + + [Fact] + public async Task Handle_MedioInactivo_ThrowsMedioInactivoException() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: false)); + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5, false)); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ReactivatePuntoDeVentaCommand(10))); + } + + [Fact] + public async Task Handle_AlreadyActive_IsIdempotentAndDoesNotCallUpdateAsync() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: true)); + + await _handler.Handle(new ReactivatePuntoDeVentaCommand(10)); + + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_InactivePdv_CallsUpdateAsyncWithActiveEntity() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: false)); + + await _handler.Handle(new ReactivatePuntoDeVentaCommand(10)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(p => p.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_InactivePdv_WritesAuditWithReactivateAction() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: false)); + + await _handler.Handle(new ReactivatePuntoDeVentaCommand(10)); + + await _audit.Received(1).LogAsync( + action: "punto_de_venta.reactivate", + targetType: "PuntoDeVenta", + targetId: "10", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_MedioInactivo_NoAuditLogged() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: false)); + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5, false)); + + try { await _handler.Handle(new ReactivatePuntoDeVentaCommand(10)); } catch (MedioInactivoException) { } + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reservar/ReservarNumeroCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reservar/ReservarNumeroCommandHandlerTests.cs new file mode 100644 index 0000000..a3cf99f --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reservar/ReservarNumeroCommandHandlerTests.cs @@ -0,0 +1,126 @@ +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.PuntosDeVenta.Reservar; +using SIGCM2.Domain.Enums; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.Reservar; + +public class ReservarNumeroCommandHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly ReservarNumeroCommandHandler _handler; + + private static readonly ReservarNumeroCommand ValidCommand = + new(PuntoDeVentaId: 10, TipoComprobante: TipoComprobante.FacturaA); + + public ReservarNumeroCommandHandlerTests() + { + // Use delay = 0 for fast tests + _handler = new ReservarNumeroCommandHandler(_repo, deadlockBackoffMs: [0, 0, 0]); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsNumeroReservado() + { + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Returns(7); + + var result = await _handler.Handle(ValidCommand); + + Assert.Equal(TipoComprobante.FacturaA, result.TipoComprobante); + Assert.Equal(7, result.NumeroReservado); + } + + // ── retry deadlock ──────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_DeadlockTwiceThenSucceeds_ReturnsResult() + { + var deadlock = new DeadlockTransientException(); + + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Returns( + _ => Task.FromException(deadlock), + _ => Task.FromException(deadlock), + _ => Task.FromResult(3)); + + var result = await _handler.Handle(ValidCommand); + + Assert.Equal(3, result.NumeroReservado); + } + + [Fact] + public async Task Handle_DeadlockThreeTimes_BubblesUpDeadlockException() + { + var deadlock = new DeadlockTransientException(); + + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Returns( + _ => Task.FromException(deadlock), + _ => Task.FromException(deadlock), + _ => Task.FromException(deadlock)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand)); + } + + [Fact] + public async Task Handle_DeadlockExhaustsBackoff_TriedFourTimesTotal() + { + // backoff = [0,0,0] → 3 retries → 4 total attempts (1 initial + 3 retries) + var deadlock = new DeadlockTransientException(); + + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Returns(_ => Task.FromException(deadlock)); + + try { await _handler.Handle(ValidCommand); } catch (DeadlockTransientException) { } + + await _repo.Received(4).ReservarNumeroAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + // ── domain exceptions bubble up without retry ───────────────────────────── + + [Fact] + public async Task Handle_PuntoDeVentaInactivo_BubblesUpImmediately() + { + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Throws(new PuntoDeVentaInactivoException(10)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand)); + + await _repo.Received(1).ReservarNumeroAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_MedioInactivo_BubblesUpImmediately() + { + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Throws(new MedioInactivoException(5)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand)); + + await _repo.Received(1).ReservarNumeroAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_PdvNotFound_BubblesUpImmediately() + { + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Throws(new PuntoDeVentaNotFoundException(10)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand)); + + await _repo.Received(1).ReservarNumeroAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandlerTests.cs new file mode 100644 index 0000000..1724e35 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandlerTests.cs @@ -0,0 +1,103 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.PuntosDeVenta.Update; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.Update; + +public class UpdatePuntoDeVentaCommandHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly IMedioRepository _medioRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly UpdatePuntoDeVentaCommandHandler _handler; + + private static PuntoDeVenta MakePdv(int id = 10, int medioId = 5, bool activo = true) + => new(id, medioId, 1, "Original", null, activo, DateTime.UtcNow, null); + + private static Medio MakeMedio(int id = 5, bool activo = true) + => new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + private static UpdatePuntoDeVentaCommand ValidCommand(int id = 10) => + new(Id: id, Nombre: "Nuevo Nombre", NumeroAFIP: 3, Descripcion: null); + + public UpdatePuntoDeVentaCommandHandlerTests() + { + _handler = new UpdatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit); + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10)); + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5)); + _repo.ExistsByNumeroAFIPInMedioAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); + } + + [Fact] + public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((PuntoDeVenta?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new UpdatePuntoDeVentaCommand(999, "X", 1, null))); + } + + [Fact] + public async Task Handle_MedioInactivo_ThrowsMedioInactivoException() + { + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5, false)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + [Fact] + public async Task Handle_NumeroAFIPDuplicado_ThrowsNumeroAFIPDuplicadoException() + { + _repo.ExistsByNumeroAFIPInMedioAsync(5, 3, 10, Arg.Any()).Returns(true); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + [Fact] + public async Task Handle_HappyPath_CallsUpdateAsyncOnce() + { + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithUpdatedFields() + { + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(10, result.Id); + Assert.Equal("Nuevo Nombre", result.Nombre); + Assert.Equal(3, result.NumeroAFIP); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditWithUpdateAction() + { + await _handler.Handle(ValidCommand()); + + await _audit.Received(1).LogAsync( + action: "punto_de_venta.update", + targetType: "PuntoDeVenta", + targetId: "10", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_MedioInactivo_NoAuditLogged() + { + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5, false)); + + try { await _handler.Handle(ValidCommand()); } catch (MedioInactivoException) { } + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } +} From 489359f0b86f20674c29d3c92e25f5cb313e4bc5 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:29:16 -0300 Subject: [PATCH 04/18] feat(infrastructure): PuntoDeVentaRepository con Dapper + mapping SqlException + registro DI --- .../DependencyInjection.cs | 1 + .../Persistence/PuntoDeVentaRepository.cs | 255 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/PuntoDeVentaRepository.cs diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 7887203..7cb03ba 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -34,6 +34,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/src/api/SIGCM2.Infrastructure/Persistence/PuntoDeVentaRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/PuntoDeVentaRepository.cs new file mode 100644 index 0000000..9d5ab66 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/PuntoDeVentaRepository.cs @@ -0,0 +1,255 @@ +using System.Data; +using System.Text; +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Enums; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class PuntoDeVentaRepository : IPuntoDeVentaRepository +{ + private readonly SqlConnectionFactory _connectionFactory; + + public PuntoDeVentaRepository(SqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task AddAsync(PuntoDeVenta pdv, CancellationToken ct = default) + { + // DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()). + const string sql = """ + INSERT INTO dbo.PuntoDeVenta (MedioId, NumeroAFIP, Nombre, Descripcion) + OUTPUT INSERTED.Id + VALUES (@MedioId, @NumeroAFIP, @Nombre, @Descripcion) + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + try + { + return await connection.ExecuteScalarAsync(sql, new + { + pdv.MedioId, + pdv.NumeroAFIP, + pdv.Nombre, + pdv.Descripcion, + }); + } + catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_MedioId_NumeroAFIP")) + { + throw new NumeroAFIPDuplicadoException(pdv.MedioId, pdv.NumeroAFIP); + } + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, MedioId, NumeroAFIP, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion + FROM dbo.PuntoDeVenta + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + return row is null ? null : MapRow(row); + } + + public async Task ExistsByNumeroAFIPInMedioAsync(int medioId, short numeroAFIP, int? excludeId = null, CancellationToken ct = default) + { + var sql = excludeId.HasValue + ? "SELECT COUNT(1) FROM dbo.PuntoDeVenta WHERE MedioId = @MedioId AND NumeroAFIP = @NumeroAFIP AND Id <> @ExcludeId" + : "SELECT COUNT(1) FROM dbo.PuntoDeVenta WHERE MedioId = @MedioId AND NumeroAFIP = @NumeroAFIP"; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new { MedioId = medioId, NumeroAFIP = numeroAFIP, ExcludeId = excludeId }); + return count > 0; + } + + public async Task UpdateAsync(PuntoDeVenta pdv, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.PuntoDeVenta + SET NumeroAFIP = @NumeroAFIP, + Nombre = @Nombre, + Descripcion = @Descripcion, + Activo = @Activo, + FechaModificacion = @FechaModificacion + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + try + { + await connection.ExecuteAsync(sql, new + { + pdv.NumeroAFIP, + pdv.Nombre, + pdv.Descripcion, + pdv.Activo, + FechaModificacion = pdv.FechaModificacion ?? DateTime.UtcNow, + pdv.Id, + }); + } + catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_MedioId_NumeroAFIP")) + { + throw new NumeroAFIPDuplicadoException(pdv.MedioId, pdv.NumeroAFIP); + } + } + + public async Task> GetPagedAsync(PuntosDeVentaQuery q, CancellationToken ct = default) + { + var page = Math.Max(1, q.Page); + var pageSize = Math.Clamp(q.PageSize, 1, 100); + var offset = (page - 1) * pageSize; + + var where = new StringBuilder("WHERE 1=1"); + var parameters = new DynamicParameters(); + parameters.Add("PageSize", pageSize); + parameters.Add("Offset", offset); + + if (q.MedioId.HasValue) + { + where.Append(" AND MedioId = @MedioId"); + parameters.Add("MedioId", q.MedioId.Value); + } + + if (q.Activo.HasValue) + { + where.Append(" AND Activo = @Activo"); + parameters.Add("Activo", q.Activo.Value ? 1 : 0); + } + + var sql = $""" + SELECT + Id, MedioId, NumeroAFIP, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion, + COUNT(*) OVER() AS TotalCount + FROM dbo.PuntoDeVenta + {where} + ORDER BY MedioId, NumeroAFIP + OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync(sql, parameters); + var list = rows.ToList(); + + var total = list.Count > 0 ? list[0].TotalCount : 0; + var items = list.Select(r => MapRow(r)).ToList(); + + return new PagedResult(items, page, pageSize, total); + } + + public async Task ReservarNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default) + { + var parameters = new DynamicParameters(); + parameters.Add("PuntoDeVentaId", puntoDeVentaId, DbType.Int32); + parameters.Add("TipoComprobante", (byte)tipo, DbType.Byte); + parameters.Add("NumeroReservado", dbType: DbType.Int32, direction: ParameterDirection.Output); + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + try + { + await connection.ExecuteAsync( + "dbo.usp_ReservarNumeroComprobante", + parameters, + commandType: CommandType.StoredProcedure); + } + catch (SqlException ex) + { + throw ex.Number switch + { + 50001 => new PuntoDeVentaInactivoException(puntoDeVentaId), + 50002 => new MedioInactivoException(puntoDeVentaId), + 50003 => new PuntoDeVentaNotFoundException(puntoDeVentaId), + 1205 => new DeadlockTransientException(ex), + _ => ex, + }; + } + + return parameters.Get("NumeroReservado"); + } + + public async Task GetUltimoNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default) + { + const string sql = """ + SELECT UltimoNumero + FROM dbo.SecuenciaComprobante + WHERE PuntoDeVentaId = @PuntoDeVentaId AND TipoComprobante = @TipoComprobante + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.QuerySingleOrDefaultAsync(sql, new + { + PuntoDeVentaId = puntoDeVentaId, + TipoComprobante = (byte)tipo, + }); + } + + // ── mapping ─────────────────────────────────────────────────────────────── + + private static PuntoDeVenta MapRow(PdvRow r) + => new( + id: r.Id, + medioId: r.MedioId, + numeroAFIP: r.NumeroAFIP, + nombre: r.Nombre, + descripcion: r.Descripcion, + activo: r.Activo, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private static PuntoDeVenta MapRow(PdvPagedRow r) + => new( + id: r.Id, + medioId: r.MedioId, + numeroAFIP: r.NumeroAFIP, + nombre: r.Nombre, + descripcion: r.Descripcion, + activo: r.Activo, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private static bool IsUniqueViolation(SqlException ex) + => ex.Number is 2627 or 2601; + + // ── private rows ────────────────────────────────────────────────────────── + + private sealed record PdvRow( + int Id, + int MedioId, + short NumeroAFIP, + string Nombre, + string? Descripcion, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); + + private sealed record PdvPagedRow( + int Id, + int MedioId, + short NumeroAFIP, + string Nombre, + string? Descripcion, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion, + int TotalCount); +} From 39160bbb839189a0a987bfa53fe7b8641ff591c0 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:34:30 -0300 Subject: [PATCH 05/18] feat(api): PuntosDeVentaController + ExceptionFilter mappings ADM-008 8 endpoints en /api/v1/admin/puntos-de-venta con permiso administracion:puntos_de_venta:gestionar. ExceptionFilter: +PuntoDeVentaNotFoundException (404), +PuntoDeVentaInactivoException (409), +NumeroAFIPDuplicadoException (409). MedioInactivoException ya mapeado por ADM-001; no duplicado. --- .../Controllers/PuntosDeVentaController.cs | 206 ++++++++++++++++++ src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 37 ++++ 2 files changed, 243 insertions(+) create mode 100644 src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs diff --git a/src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs b/src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs new file mode 100644 index 0000000..cc3b2c2 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs @@ -0,0 +1,206 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; +using SIGCM2.Application.PuntosDeVenta.Create; +using SIGCM2.Application.PuntosDeVenta.Deactivate; +using SIGCM2.Application.PuntosDeVenta.GetById; +using SIGCM2.Application.PuntosDeVenta.List; +using SIGCM2.Application.PuntosDeVenta.ProximoNumero; +using SIGCM2.Application.PuntosDeVenta.Reactivate; +using SIGCM2.Application.PuntosDeVenta.Reservar; +using SIGCM2.Application.PuntosDeVenta.Update; +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Api.Controllers; + +/// +/// ADM-008: PuntoDeVenta management endpoints at /api/v1/admin/puntos-de-venta. +/// All endpoints require permission 'administracion:puntos_de_venta:gestionar'. +/// +[ApiController] +[Route("api/v1/admin/puntos-de-venta")] +public sealed class PuntosDeVentaController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public PuntosDeVentaController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + /// Creates a new punto de venta. Requires administracion:puntos_de_venta:gestionar. + [HttpPost] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(typeof(PuntoDeVentaCreatedDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CreatePuntoDeVenta([FromBody] CreatePuntoDeVentaRequest request) + { + var command = new CreatePuntoDeVentaCommand( + MedioId: request.MedioId ?? 0, + NumeroAFIP: request.NumeroAFIP ?? 0, + Nombre: request.Nombre ?? string.Empty, + Descripcion: request.Descripcion); + + var validation = await _createValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return CreatedAtAction(nameof(GetPuntoDeVentaById), new { id = result.Id }, result); + } + + /// Lists puntos de venta with optional filters. + [HttpGet] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ListPuntosDeVenta( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] int? medioId = null, + [FromQuery] bool? activo = null) + { + if (page < 1) return BadRequest(new { error = "page must be >= 1" }); + if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" }); + + var query = new ListPuntosDeVentaQuery(page, pageSize, medioId, activo); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + /// Gets a single punto de venta by id. + [HttpGet("{id:int}")] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(typeof(PuntoDeVentaDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetPuntoDeVentaById([FromRoute] int id) + { + var query = new GetPuntoDeVentaByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + /// Updates a punto de venta's editable fields. + [HttpPut("{id:int}")] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(typeof(PuntoDeVentaUpdatedDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task UpdatePuntoDeVenta([FromRoute] int id, [FromBody] UpdatePuntoDeVentaRequest request) + { + var command = new UpdatePuntoDeVentaCommand( + Id: id, + Nombre: request.Nombre ?? string.Empty, + NumeroAFIP: request.NumeroAFIP ?? 0, + Descripcion: request.Descripcion); + + var validation = await _updateValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Deactivates a punto de venta. + [HttpPost("{id:int}/deactivate")] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivatePuntoDeVenta([FromRoute] int id) + { + var command = new DeactivatePuntoDeVentaCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } + + /// Reactivates a punto de venta (only if parent Medio is active). + [HttpPost("{id:int}/reactivate")] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task ReactivatePuntoDeVenta([FromRoute] int id) + { + var command = new ReactivatePuntoDeVentaCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } + + /// Reserves the next sequential number for a given PdV and TipoComprobante. + [HttpPost("{id:int}/secuencias/{tipoComprobante}/reservar")] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(typeof(ReservaNumeroDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task ReservarNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante) + { + var command = new ReservarNumeroCommand(id, tipoComprobante); + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Returns the next available number (read-only, no reservation). + [HttpGet("{id:int}/secuencias/{tipoComprobante}/proximo")] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(typeof(ProximoNumeroDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetProximoNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante) + { + var query = new GetProximoNumeroQuery(id, tipoComprobante); + var result = await _dispatcher.Send(query); + return Ok(result); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// ADM-008: Create punto de venta request body. +public sealed record CreatePuntoDeVentaRequest( + int? MedioId, + short? NumeroAFIP, + string? Nombre, + string? Descripcion); + +/// ADM-008: Update punto de venta request body. +public sealed record UpdatePuntoDeVentaRequest( + string? Nombre, + short? NumeroAFIP, + string? Descripcion); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index a9682ff..35fb5be 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -231,6 +231,43 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // ADM-008: PuntoDeVenta exceptions + case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx: + context.Result = new ObjectResult(new + { + error = "punto_de_venta_not_found", + message = puntoDeVentaNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + + case PuntoDeVentaInactivoException puntoDeVentaInactivoEx: + context.Result = new ObjectResult(new + { + error = "punto_de_venta_inactivo", + message = puntoDeVentaInactivoEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case NumeroAFIPDuplicadoException numeroAFIPDupEx: + context.Result = new ObjectResult(new + { + error = "numero_afip_duplicado", + message = numeroAFIPDupEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + // UDT-009: permiso override validation errors case InvalidPermisoCodesException ipce: context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails From 48779543f991e3b9283159b63498f67ca446a85b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:34:35 -0300 Subject: [PATCH 06/18] test(api): integration tests CRUD + concurrencia + secuencialidad PuntosDeVenta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T5.3: 18 tests cubriendo 401/403, create, get, list, update, deactivate, reactivate, reservar, proximo. T5.4: 50 tasks paralelas → 50 numeros distintos sin duplicados. T5.5: 100 reservas en serie → {1..100} en orden. --- .../Admin/PuntosDeVentaControllerTests.cs | 876 ++++++++++++++++++ 1 file changed, 876 insertions(+) create mode 100644 tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs diff --git a/tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs b/tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs new file mode 100644 index 0000000..8e8b271 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs @@ -0,0 +1,876 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Admin; + +/// +/// ADM-008 B5 — Integration tests for /api/v1/admin/puntos-de-venta. +/// All endpoints require permission 'administracion:puntos_de_venta:gestionar'. +/// Tests: T5.3 CRUD, T5.4 concurrencia, T5.5 secuencialidad. +/// +[Collection("ApiIntegration")] +public sealed class PuntosDeVentaControllerTests : IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private const string Endpoint = "/api/v1/admin/puntos-de-venta"; + private const string MediosEndpoint = "/api/v1/admin/medios"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly HttpClient _client; + + public PuntosDeVentaControllerTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + } + + public Task InitializeAsync() => Task.CompletedTask; + public Task DisposeAsync() => Task.CompletedTask; + + // ── Helpers ────────────────────────────────────────────────────────────── + + private async Task GetAdminTokenAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = AdminUsername, + password = AdminPassword + }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + private async Task GetCajeroTokenAsync(string username) + { + var adminToken = await GetAdminTokenAsync(); + + using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new + { + username, + password = "Secure1234!", + nombre = "Cajero", + apellido = "Test", + email = (string?)null, + rol = "cajero" + }, adminToken); + var mkResp = await _client.SendAsync(mkUser); + if (mkResp.StatusCode != HttpStatusCode.Created && mkResp.StatusCode != HttpStatusCode.Conflict) + Assert.Fail($"Seed cajero failed: {mkResp.StatusCode}"); + + var loginResp = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username, + password = "Secure1234!" + }); + loginResp.EnsureSuccessStatusCode(); + var loginJson = await loginResp.Content.ReadFromJsonAsync(); + return loginJson.GetProperty("accessToken").GetString()!; + } + + private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null) + { + var request = new HttpRequestMessage(method, url); + if (bearerToken is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + if (body is not null) + request.Content = JsonContent.Create(body); + return request; + } + + /// Creates a Medio via the API and returns its id. + private async Task CreateMedioAsync(string codigo, string nombre, string token) + { + using var req = BuildRequest(HttpMethod.Post, MediosEndpoint, new + { + codigo, + nombre, + tipo = 1 + }, token); + var resp = await _client.SendAsync(req); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadFromJsonAsync(); + return json.GetProperty("id").GetInt32(); + } + + /// Creates a PuntoDeVenta via the API and returns its id. + private async Task CreatePdvAsync(int medioId, short numeroAFIP, string nombre, string token) + { + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + numeroAFIP, + nombre, + descripcion = (string?)null + }, token); + var resp = await _client.SendAsync(req); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadFromJsonAsync(); + return json.GetProperty("id").GetInt32(); + } + + private static async Task DeleteMedioIfExistsAsync(string codigo) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + + var id = await conn.QuerySingleOrDefaultAsync( + "SELECT Id FROM dbo.Medio WHERE Codigo = @Codigo", new { Codigo = codigo }); + if (id is null) return; + + // Delete SecuenciaComprobante for PuntosDeVenta of this Medio (no versioning) + await conn.ExecuteAsync(""" + DELETE sc FROM dbo.SecuenciaComprobante sc + INNER JOIN dbo.PuntoDeVenta pdv ON pdv.Id = sc.PuntoDeVentaId + WHERE pdv.MedioId = @id + """, new { id }); + + // Delete dependent PuntosDeVenta (disable versioning to also clear history) + await conn.ExecuteAsync("ALTER TABLE dbo.PuntoDeVenta SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync("DELETE FROM dbo.PuntoDeVenta_History WHERE MedioId = @id", new { id }); + await conn.ExecuteAsync("DELETE FROM dbo.PuntoDeVenta WHERE MedioId = @id", new { id }); + await conn.ExecuteAsync( + "ALTER TABLE dbo.PuntoDeVenta SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.PuntoDeVenta_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + + // Delete dependent Secciones + await conn.ExecuteAsync("ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync("DELETE FROM dbo.Seccion_History WHERE MedioId = @id", new { id }); + await conn.ExecuteAsync("DELETE FROM dbo.Seccion WHERE MedioId = @id", new { id }); + await conn.ExecuteAsync( + "ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Seccion_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + + // Delete the medio itself + await conn.ExecuteAsync("ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync("DELETE FROM dbo.Medio_History WHERE Id = @id", new { id }); + await conn.ExecuteAsync("DELETE FROM dbo.Medio WHERE Id = @id", new { id }); + await conn.ExecuteAsync( + "ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Medio_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + } + + private static async Task DeleteUsuarioIfExistsAsync(string username) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync(""" + DELETE rt FROM dbo.RefreshToken rt + INNER JOIN dbo.Usuario u ON u.Id = rt.UsuarioId + WHERE u.Username = @Username + """, new { Username = username }); + await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username }); + } + + private static async Task CountAuditEventsAsync(string action, string targetType, string targetId) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + return await conn.QuerySingleAsync( + "SELECT COUNT(*) FROM dbo.AuditEvent WHERE Action = @Action AND TargetType = @TargetType AND TargetId = @TargetId", + new { Action = action, TargetType = targetType, TargetId = targetId }); + } + + // ── 401 / 403 guards ───────────────────────────────────────────────────── + + [Fact] + public async Task CreatePdv_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = 1, + numeroAFIP = 1, + nombre = "PdV Test" + }); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task CreatePdv_WithCajeroRole_Returns403() + { + const string username = "adm008_pdv_cajero_403"; + try + { + var token = await GetCajeroTokenAsync(username); + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = 1, + numeroAFIP = 1, + nombre = "PdV Test 403" + }, token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + } + finally + { + await DeleteUsuarioIfExistsAsync(username); + } + } + + // ── CREATE ──────────────────────────────────────────────────────────────── + + /// T5.3 — Happy path: Create returns 201 + AuditEvent. + [Fact] + public async Task CreatePdv_WithAdmin_Returns201AndAuditEvent() + { + const string medioCodigo = "ADMS08_MED_C201"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Create 201", token); + + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + numeroAFIP = (short)1, + nombre = "PdV Central Create", + descripcion = "Test desc" + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + Assert.NotNull(resp.Headers.Location); + + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("id", out var idEl)); + var pdvId = idEl.GetInt32(); + Assert.True(pdvId > 0); + Assert.Equal(medioId, json.GetProperty("medioId").GetInt32()); + Assert.Equal(1, json.GetProperty("numeroAFIP").GetInt16()); + Assert.Equal("PdV Central Create", json.GetProperty("nombre").GetString()); + Assert.True(json.GetProperty("activo").GetBoolean()); + + var auditCount = await CountAuditEventsAsync("punto_de_venta.create", "PuntoDeVenta", pdvId.ToString()); + Assert.Equal(1, auditCount); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + /// T5.3 — 409 medio_inactivo al crear con Medio inactivo. + [Fact] + public async Task CreatePdv_WithInactiveMedio_Returns409MedioInactivo() + { + const string medioCodigo = "ADMS08_MED_INACT"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio Inactivo PDV", token); + + // Deactivate the medio + using var deactReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token); + var deactResp = await _client.SendAsync(deactReq); + Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); + + // Try to create PdV in inactive medio + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + numeroAFIP = (short)1, + nombre = "PdV en Medio Inactivo" + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("medio_inactivo", json.GetProperty("error").GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + /// T5.3 — 409 numero_afip_duplicado al violar UNIQUE(MedioId, NumeroAFIP). + [Fact] + public async Task CreatePdv_DuplicateNumeroAFIPInSameMedio_Returns409() + { + const string medioCodigo = "ADMS08_MED_DUP"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Dup", token); + + // First create + using var first = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + numeroAFIP = (short)1, + nombre = "PdV Original" + }, token); + var firstResp = await _client.SendAsync(first); + Assert.Equal(HttpStatusCode.Created, firstResp.StatusCode); + + // Second with same medioId + numeroAFIP + using var second = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + numeroAFIP = (short)1, + nombre = "PdV Duplicado" + }, token); + var secondResp = await _client.SendAsync(second); + + Assert.Equal(HttpStatusCode.Conflict, secondResp.StatusCode); + var json = await secondResp.Content.ReadFromJsonAsync(); + Assert.Equal("numero_afip_duplicado", json.GetProperty("error").GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + /// T5.3 — mismo NumeroAFIP en distinto Medio es permitido. + [Fact] + public async Task CreatePdv_SameNumeroAFIPDifferentMedio_Returns201() + { + const string medio1Codigo = "ADMS08_M1_MULTI"; + const string medio2Codigo = "ADMS08_M2_MULTI"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId1 = await CreateMedioAsync(medio1Codigo, "Medio Multi 1 PDV", token); + var medioId2 = await CreateMedioAsync(medio2Codigo, "Medio Multi 2 PDV", token); + + using var req1 = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = medioId1, + numeroAFIP = (short)1, + nombre = "PdV Medio 1" + }, token); + var resp1 = await _client.SendAsync(req1); + Assert.Equal(HttpStatusCode.Created, resp1.StatusCode); + + // Same numeroAFIP in different medio → should succeed + using var req2 = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = medioId2, + numeroAFIP = (short)1, + nombre = "PdV Medio 2" + }, token); + var resp2 = await _client.SendAsync(req2); + Assert.Equal(HttpStatusCode.Created, resp2.StatusCode); + } + finally + { + await DeleteMedioIfExistsAsync(medio1Codigo); + await DeleteMedioIfExistsAsync(medio2Codigo); + } + } + + // ── GET BY ID ──────────────────────────────────────────────────────────── + + /// T5.3 — 404 cuando id inexistente. + [Fact] + public async Task GetPdvById_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}/999999", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("punto_de_venta_not_found", json.GetProperty("error").GetString()); + } + + // ── LIST ───────────────────────────────────────────────────────────────── + + /// T5.3 — List returns 200 with paged result. + [Fact] + public async Task ListPdvs_WithAdmin_Returns200PagedResult() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, Endpoint, bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _), "Response must have 'items'"); + Assert.True(json.TryGetProperty("total", out _), "Response must have 'total'"); + } + + /// T5.3 — List filtrado por medioId y activo. + [Fact] + public async Task ListPdvs_FilterByMedioAndActivo_ReturnsMatchingItems() + { + const string medioCodigo = "ADMS08_MED_LIST"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV List", token); + await CreatePdvAsync(medioId, 1, "PdV Lista 1", token); + await CreatePdvAsync(medioId, 2, "PdV Lista 2", token); + + // Filter by medioId + using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}?medioId={medioId}&activo=true", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + var items = json.GetProperty("items").EnumerateArray().ToList(); + Assert.Equal(2, items.Count); + Assert.All(items, item => Assert.Equal(medioId, item.GetProperty("medioId").GetInt32())); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + // ── UPDATE ──────────────────────────────────────────────────────────────── + + /// T5.3 — Happy path Update returns 200 + AuditEvent. + [Fact] + public async Task UpdatePdv_WithAdmin_Returns200AndAuditEvent() + { + const string medioCodigo = "ADMS08_MED_UPD"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Update", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Original", token); + + using var updateReq = BuildRequest(HttpMethod.Put, $"{Endpoint}/{pdvId}", new + { + nombre = "PdV Actualizado", + numeroAFIP = (short)2, + descripcion = "Nueva desc" + }, token); + var updateResp = await _client.SendAsync(updateReq); + + Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode); + var updated = await updateResp.Content.ReadFromJsonAsync(); + Assert.Equal("PdV Actualizado", updated.GetProperty("nombre").GetString()); + Assert.Equal(2, updated.GetProperty("numeroAFIP").GetInt16()); + + var auditCount = await CountAuditEventsAsync("punto_de_venta.update", "PuntoDeVenta", pdvId.ToString()); + Assert.Equal(1, auditCount); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + /// T5.3 — 409 punto_de_venta_inactivo al actualizar PdV inactivo. + [Fact] + public async Task UpdatePdv_WhenPdvInactive_Returns409PdvInactivo() + { + const string medioCodigo = "ADMS08_MED_UPDI"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Update Inactivo", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Para Inactivar", token); + + // Deactivate the PdV + using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token); + var deactResp = await _client.SendAsync(deactReq); + Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); + + // Try to update inactive PdV + using var updateReq = BuildRequest(HttpMethod.Put, $"{Endpoint}/{pdvId}", new + { + nombre = "PdV Inactivo Update", + numeroAFIP = (short)1 + }, token); + var updateResp = await _client.SendAsync(updateReq); + + Assert.Equal(HttpStatusCode.Conflict, updateResp.StatusCode); + var json = await updateResp.Content.ReadFromJsonAsync(); + Assert.Equal("punto_de_venta_inactivo", json.GetProperty("error").GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + /// T5.3 — 409 medio_inactivo al actualizar PdV con Medio inactivo. + [Fact] + public async Task UpdatePdv_WhenMedioInactive_Returns409MedioInactivo() + { + const string medioCodigo = "ADMS08_MED_UPDMI"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Update MedioInact", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Update Medio Inactivo", token); + + // Deactivate the medio + using var deactMedioReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token); + var deactMedioResp = await _client.SendAsync(deactMedioReq); + Assert.Equal(HttpStatusCode.NoContent, deactMedioResp.StatusCode); + + // Try to update PdV with inactive medio + using var updateReq = BuildRequest(HttpMethod.Put, $"{Endpoint}/{pdvId}", new + { + nombre = "PdV Medio Inactivo", + numeroAFIP = (short)1 + }, token); + var updateResp = await _client.SendAsync(updateReq); + + Assert.Equal(HttpStatusCode.Conflict, updateResp.StatusCode); + var json = await updateResp.Content.ReadFromJsonAsync(); + Assert.Equal("medio_inactivo", json.GetProperty("error").GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + // ── DEACTIVATE ──────────────────────────────────────────────────────────── + + /// T5.3 — Happy path Deactivate returns 204 + AuditEvent. + [Fact] + public async Task DeactivatePdv_WithAdmin_Returns204AndAuditEvent() + { + const string medioCodigo = "ADMS08_MED_DEACT"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Deactivate", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Para Desactivar", token); + + using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token); + var deactResp = await _client.SendAsync(deactReq); + + Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); + + var auditCount = await CountAuditEventsAsync("punto_de_venta.deactivate", "PuntoDeVenta", pdvId.ToString()); + Assert.Equal(1, auditCount); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + // ── REACTIVATE ──────────────────────────────────────────────────────────── + + /// T5.3 — Happy path Reactivate returns 204 + AuditEvent. + [Fact] + public async Task ReactivatePdv_WithAdmin_Returns204AndAuditEvent() + { + const string medioCodigo = "ADMS08_MED_REACT"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reactivate", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Para Reactivar", token); + + // Deactivate first + using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token); + var deactResp = await _client.SendAsync(deactReq); + Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); + + // Reactivate + using var reactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/reactivate", bearerToken: token); + var reactResp = await _client.SendAsync(reactReq); + + Assert.Equal(HttpStatusCode.NoContent, reactResp.StatusCode); + + var auditCount = await CountAuditEventsAsync("punto_de_venta.reactivate", "PuntoDeVenta", pdvId.ToString()); + Assert.Equal(1, auditCount); + + // Verify it's active again via GET + using var getReq = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}", bearerToken: token); + var getResp = await _client.SendAsync(getReq); + Assert.Equal(HttpStatusCode.OK, getResp.StatusCode); + var pdvJson = await getResp.Content.ReadFromJsonAsync(); + Assert.True(pdvJson.GetProperty("activo").GetBoolean()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + /// T5.3 — 409 medio_inactivo al reactivar con Medio inactivo. + [Fact] + public async Task ReactivatePdv_WhenMedioInactive_Returns409MedioInactivo() + { + const string medioCodigo = "ADMS08_MED_REACTMI"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reactivate Inactivo", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reactivate Medio Inactivo", token); + + // Deactivate PdV while medio is active + using var deactPdvReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token); + await _client.SendAsync(deactPdvReq); + + // Deactivate medio + using var deactMedioReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token); + var deactMedioResp = await _client.SendAsync(deactMedioReq); + Assert.Equal(HttpStatusCode.NoContent, deactMedioResp.StatusCode); + + // Try to reactivate PdV with inactive medio + using var reactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/reactivate", bearerToken: token); + var reactResp = await _client.SendAsync(reactReq); + + Assert.Equal(HttpStatusCode.Conflict, reactResp.StatusCode); + var json = await reactResp.Content.ReadFromJsonAsync(); + Assert.Equal("medio_inactivo", json.GetProperty("error").GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + // ── SECUENCIAS: RESERVAR ────────────────────────────────────────────────── + + /// T5.3 — Primera reserva inicializa en 1. + [Fact] + public async Task ReservarNumero_FirstReservation_Returns1() + { + const string medioCodigo = "ADMS08_MED_RSV1"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar 1", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar First", token); + + using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal(1, json.GetProperty("numeroReservado").GetInt32()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + /// T5.3 — 409 punto_de_venta_inactivo al reservar en PdV inactivo. + [Fact] + public async Task ReservarNumero_WhenPdvInactive_Returns409PdvInactivo() + { + const string medioCodigo = "ADMS08_MED_RSVI"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar Inactivo", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar Inactivo", token); + + // Deactivate PdV + using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token); + await _client.SendAsync(deactReq); + + using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("punto_de_venta_inactivo", json.GetProperty("error").GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + /// T5.3 — 409 medio_inactivo al reservar con Medio inactivo. + [Fact] + public async Task ReservarNumero_WhenMedioInactive_Returns409MedioInactivo() + { + const string medioCodigo = "ADMS08_MED_RSVMI"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar MedioInactivo", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar MedioInact", token); + + // Deactivate medio + using var deactMedioReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token); + await _client.SendAsync(deactMedioReq); + + using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("medio_inactivo", json.GetProperty("error").GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + // ── SECUENCIAS: PROXIMO ─────────────────────────────────────────────────── + + /// T5.3 — GetProximo es read-only: no modifica UltimoNumero. + [Fact] + public async Task GetProximoNumero_DoesNotChangeState() + { + const string medioCodigo = "ADMS08_MED_PROX"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Proximo", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Proximo", token); + + // Reserve once to establish state + using var rsv = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); + await _client.SendAsync(rsv); + + // GetProximo twice — should return 2 both times + using var req1 = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaA/proximo", bearerToken: token); + var resp1 = await _client.SendAsync(req1); + Assert.Equal(HttpStatusCode.OK, resp1.StatusCode); + var json1 = await resp1.Content.ReadFromJsonAsync(); + Assert.Equal(2, json1.GetProperty("proximoNumero").GetInt32()); + + using var req2 = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaA/proximo", bearerToken: token); + var resp2 = await _client.SendAsync(req2); + var json2 = await resp2.Content.ReadFromJsonAsync(); + Assert.Equal(2, json2.GetProperty("proximoNumero").GetInt32()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + /// T5.3 — GetProximo para fila inexistente devuelve 1. + [Fact] + public async Task GetProximoNumero_WhenNoSequenceExists_Returns1() + { + const string medioCodigo = "ADMS08_MED_PROX1"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Proximo 1", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Proximo First", token); + + using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaB/proximo", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal(1, json.GetProperty("proximoNumero").GetInt32()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + // ── T5.4 — Concurrencia ─────────────────────────────────────────────────── + + /// + /// T5.4 — 50 tasks paralelas reservando para mismo PdV + TipoComprobante + /// deben producir 50 números distintos cubriendo {1..50}. + /// + [Fact] + public async Task ReservarNumero_50ConcurrentReservations_ProducesNoDuplicates() + { + const string medioCodigo = "ADMS08_CONC50"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Concurrencia 50", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Concurrencia 50", token); + + const int taskCount = 50; + var tasks = Enumerable.Range(0, taskCount) + .Select(_ => Task.Run(async () => + { + // Each task creates its own HttpClient to avoid sharing + // the shared _client which is not thread-safe for concurrent requests. + // Use BuildRequest on a shared client IS safe since HttpClient is thread-safe + // for concurrent operations as long as each message is distinct. + using var req = new HttpRequestMessage(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar"); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + var resp = await _client.SendAsync(req); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadFromJsonAsync(); + return json.GetProperty("numeroReservado").GetInt32(); + })) + .ToList(); + + var results = await Task.WhenAll(tasks); + + // All 50 numbers must be present exactly once + Assert.Equal(taskCount, results.Length); + Assert.Equal(taskCount, results.Distinct().Count()); + + var expected = Enumerable.Range(1, taskCount).ToHashSet(); + var actual = results.ToHashSet(); + Assert.Equal(expected, actual); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + // ── T5.5 — Secuencialidad ───────────────────────────────────────────────── + + /// + /// T5.5 — 100 reservas en serie para mismo PdV + TipoComprobante + /// deben devolver {1, 2, 3, ..., 100} en orden. + /// + [Fact] + public async Task ReservarNumero_100SerialReservations_ProducesSequentialNumbers() + { + const string medioCodigo = "ADMS08_SEQ100"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Secuencial 100", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Secuencial 100", token); + + const int count = 100; + var results = new List(count); + + for (int i = 0; i < count; i++) + { + using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); + var resp = await _client.SendAsync(req); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadFromJsonAsync(); + results.Add(json.GetProperty("numeroReservado").GetInt32()); + } + + // Verify sequential: {1, 2, 3, ..., 100} + var expected = Enumerable.Range(1, count).ToList(); + Assert.Equal(expected, results); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } +} From d61292afa40e2e909698a032e150a1fa7a0afc85 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:36:39 -0300 Subject: [PATCH 07/18] =?UTF-8?q?feat(web):=20feature=20puntos-de-venta=20?= =?UTF-8?q?=E2=80=94=20types,=20api,=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/puntos-de-venta.api.ts | 62 ++++++++++++++ .../puntos-de-venta/api/secuencias.api.ts | 22 +++++ .../puntos-de-venta/hooks/usePuntosDeVenta.ts | 83 +++++++++++++++++++ .../hooks/useReservarNumero.ts | 30 +++++++ src/web/src/features/puntos-de-venta/types.ts | 71 ++++++++++++++++ 5 files changed, 268 insertions(+) create mode 100644 src/web/src/features/puntos-de-venta/api/puntos-de-venta.api.ts create mode 100644 src/web/src/features/puntos-de-venta/api/secuencias.api.ts create mode 100644 src/web/src/features/puntos-de-venta/hooks/usePuntosDeVenta.ts create mode 100644 src/web/src/features/puntos-de-venta/hooks/useReservarNumero.ts create mode 100644 src/web/src/features/puntos-de-venta/types.ts diff --git a/src/web/src/features/puntos-de-venta/api/puntos-de-venta.api.ts b/src/web/src/features/puntos-de-venta/api/puntos-de-venta.api.ts new file mode 100644 index 0000000..eb7cfff --- /dev/null +++ b/src/web/src/features/puntos-de-venta/api/puntos-de-venta.api.ts @@ -0,0 +1,62 @@ +import { axiosClient } from '@/api/axiosClient' +import type { + CreatePuntoDeVentaRequest, + PuntoDeVentaCreated, + PuntoDeVentaDetail, + PuntoDeVentaListItem, + PuntosDeVentaQuery, + UpdatePuntoDeVentaRequest, + PagedResult, +} from '../types' + +export async function listPuntosDeVenta( + query: PuntosDeVentaQuery, +): Promise> { + const params = new URLSearchParams() + if (query.page !== undefined) params.set('page', String(query.page)) + if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize)) + if (query.medioId !== undefined) params.set('medioId', String(query.medioId)) + if (query.activo !== undefined) params.set('activo', String(query.activo)) + + const response = await axiosClient.get>( + '/api/v1/admin/puntos-de-venta', + { params }, + ) + return response.data +} + +export async function getPuntoDeVenta(id: number): Promise { + const response = await axiosClient.get( + `/api/v1/admin/puntos-de-venta/${id}`, + ) + return response.data +} + +export async function createPuntoDeVenta( + payload: CreatePuntoDeVentaRequest, +): Promise { + const response = await axiosClient.post( + '/api/v1/admin/puntos-de-venta', + payload, + ) + return response.data +} + +export async function updatePuntoDeVenta( + id: number, + payload: UpdatePuntoDeVentaRequest, +): Promise { + const response = await axiosClient.put( + `/api/v1/admin/puntos-de-venta/${id}`, + payload, + ) + return response.data +} + +export async function deactivatePuntoDeVenta(id: number): Promise { + await axiosClient.post(`/api/v1/admin/puntos-de-venta/${id}/deactivate`) +} + +export async function reactivatePuntoDeVenta(id: number): Promise { + await axiosClient.post(`/api/v1/admin/puntos-de-venta/${id}/reactivate`) +} diff --git a/src/web/src/features/puntos-de-venta/api/secuencias.api.ts b/src/web/src/features/puntos-de-venta/api/secuencias.api.ts new file mode 100644 index 0000000..0368a6c --- /dev/null +++ b/src/web/src/features/puntos-de-venta/api/secuencias.api.ts @@ -0,0 +1,22 @@ +import { axiosClient } from '@/api/axiosClient' +import type { TipoComprobante, ReservarNumeroResponse, ProximoNumeroResponse } from '../types' + +export async function reservarNumero( + puntoDeVentaId: number, + tipoComprobante: TipoComprobante, +): Promise { + const response = await axiosClient.post( + `/api/v1/admin/puntos-de-venta/${puntoDeVentaId}/secuencias/${tipoComprobante}/reservar`, + ) + return response.data +} + +export async function getProximoNumero( + puntoDeVentaId: number, + tipoComprobante: TipoComprobante, +): Promise { + const response = await axiosClient.get( + `/api/v1/admin/puntos-de-venta/${puntoDeVentaId}/secuencias/${tipoComprobante}/proximo`, + ) + return response.data +} diff --git a/src/web/src/features/puntos-de-venta/hooks/usePuntosDeVenta.ts b/src/web/src/features/puntos-de-venta/hooks/usePuntosDeVenta.ts new file mode 100644 index 0000000..6733f8d --- /dev/null +++ b/src/web/src/features/puntos-de-venta/hooks/usePuntosDeVenta.ts @@ -0,0 +1,83 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + listPuntosDeVenta, + getPuntoDeVenta, + createPuntoDeVenta, + updatePuntoDeVenta, + deactivatePuntoDeVenta, + reactivatePuntoDeVenta, +} from '../api/puntos-de-venta.api' +import type { CreatePuntoDeVentaRequest, PuntosDeVentaQuery, UpdatePuntoDeVentaRequest } from '../types' + +export const puntosDeVentaListQueryKey = (query: PuntosDeVentaQuery) => + ['puntos-de-venta', 'list', query] as const + +export const puntoDeVentaDetailQueryKey = (id: number) => + ['puntos-de-venta', 'detail', id] as const + +// ─── List ──────────────────────────────────────────────────────────────────── + +export function usePuntosDeVentaList(query: PuntosDeVentaQuery) { + return useQuery({ + queryKey: puntosDeVentaListQueryKey(query), + queryFn: () => listPuntosDeVenta(query), + staleTime: 15_000, + }) +} + +// ─── Detail ────────────────────────────────────────────────────────────────── + +export function usePuntoDeVenta(id: number) { + return useQuery({ + queryKey: puntoDeVentaDetailQueryKey(id), + queryFn: () => getPuntoDeVenta(id), + enabled: !!id, + staleTime: 15_000, + }) +} + +// ─── Create ────────────────────────────────────────────────────────────────── + +export function useCreatePuntoDeVenta() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: CreatePuntoDeVentaRequest) => createPuntoDeVenta(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['puntos-de-venta'] }) + }, + }) +} + +// ─── Update ────────────────────────────────────────────────────────────────── + +export function useUpdatePuntoDeVenta(id: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: UpdatePuntoDeVentaRequest) => updatePuntoDeVenta(id, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['puntos-de-venta'] }) + }, + }) +} + +// ─── Deactivate / Reactivate ───────────────────────────────────────────────── + +export function useDeactivatePuntoDeVenta() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deactivatePuntoDeVenta(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['puntos-de-venta'] }) + }, + }) +} + +export function useReactivatePuntoDeVenta() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => reactivatePuntoDeVenta(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['puntos-de-venta'] }) + }, + }) +} diff --git a/src/web/src/features/puntos-de-venta/hooks/useReservarNumero.ts b/src/web/src/features/puntos-de-venta/hooks/useReservarNumero.ts new file mode 100644 index 0000000..dad0444 --- /dev/null +++ b/src/web/src/features/puntos-de-venta/hooks/useReservarNumero.ts @@ -0,0 +1,30 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { reservarNumero, getProximoNumero } from '../api/secuencias.api' +import type { TipoComprobante } from '../types' + +// ─── Reservar ──────────────────────────────────────────────────────────────── + +export function useReservarNumero(puntoDeVentaId: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (tipoComprobante: TipoComprobante) => + reservarNumero(puntoDeVentaId, tipoComprobante), + onSuccess: (_data, tipoComprobante) => { + // Invalidate the proximo query for this pdv+tipo so it refetches + queryClient.invalidateQueries({ + queryKey: ['puntos-de-venta', 'proximo', puntoDeVentaId, tipoComprobante], + }) + }, + }) +} + +// ─── Próximo número (read-only) ────────────────────────────────────────────── + +export function useProximoNumero(puntoDeVentaId: number, tipoComprobante: TipoComprobante) { + return useQuery({ + queryKey: ['puntos-de-venta', 'proximo', puntoDeVentaId, tipoComprobante], + queryFn: () => getProximoNumero(puntoDeVentaId, tipoComprobante), + enabled: !!puntoDeVentaId, + staleTime: 5_000, + }) +} diff --git a/src/web/src/features/puntos-de-venta/types.ts b/src/web/src/features/puntos-de-venta/types.ts new file mode 100644 index 0000000..83858f0 --- /dev/null +++ b/src/web/src/features/puntos-de-venta/types.ts @@ -0,0 +1,71 @@ +// ADM-008 — shared types for puntos-de-venta feature + +export enum TipoComprobante { + FacturaA = 1, + FacturaB = 2, + FacturaC = 3, + NotaCreditoA = 4, + NotaCreditoB = 5, + NotaCreditoC = 6, +} + +export interface PuntoDeVentaListItem { + id: number + medioId: number + numeroAFIP: number + nombre: string + activo: boolean +} + +export interface PuntoDeVentaDetail { + id: number + medioId: number + numeroAFIP: number + nombre: string + activo: boolean + fechaCreacion: string + fechaModificacion: string | null +} + +export interface PuntoDeVentaCreated { + id: number + medioId: number + numeroAFIP: number + nombre: string + activo: boolean +} + +export interface CreatePuntoDeVentaRequest { + medioId: number + numeroAFIP: number + nombre: string +} + +export interface UpdatePuntoDeVentaRequest { + nombre: string + numeroAFIP: number +} + +export interface PuntosDeVentaQuery { + page?: number + pageSize?: number + medioId?: number + activo?: boolean +} + +export interface ReservarNumeroResponse { + tipoComprobante: TipoComprobante + numeroReservado: number +} + +export interface ProximoNumeroResponse { + tipoComprobante: TipoComprobante + proximoNumero: number +} + +export interface PagedResult { + items: T[] + page: number + pageSize: number + total: number +} From 4b96cdefcc8cc5e1512f92e9e089ce4aabb7740b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:36:44 -0300 Subject: [PATCH 08/18] feat(web): tabla y form PuntosDeVenta --- .../DeactivatePuntoDeVentaModal.tsx | 70 +++++++ .../components/PuntoDeVentaForm.tsx | 177 ++++++++++++++++++ .../components/PuntosDeVentaFilters.tsx | 59 ++++++ .../components/PuntosDeVentaTable.tsx | 95 ++++++++++ .../pages/CreatePuntoDeVentaPage.tsx | 49 +++++ .../pages/EditPuntoDeVentaPage.tsx | 90 +++++++++ .../pages/PuntoDeVentaDetailPage.tsx | 108 +++++++++++ .../pages/PuntosDeVentaListPage.tsx | 111 +++++++++++ 8 files changed, 759 insertions(+) create mode 100644 src/web/src/features/puntos-de-venta/components/DeactivatePuntoDeVentaModal.tsx create mode 100644 src/web/src/features/puntos-de-venta/components/PuntoDeVentaForm.tsx create mode 100644 src/web/src/features/puntos-de-venta/components/PuntosDeVentaFilters.tsx create mode 100644 src/web/src/features/puntos-de-venta/components/PuntosDeVentaTable.tsx create mode 100644 src/web/src/features/puntos-de-venta/pages/CreatePuntoDeVentaPage.tsx create mode 100644 src/web/src/features/puntos-de-venta/pages/EditPuntoDeVentaPage.tsx create mode 100644 src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx create mode 100644 src/web/src/features/puntos-de-venta/pages/PuntosDeVentaListPage.tsx diff --git a/src/web/src/features/puntos-de-venta/components/DeactivatePuntoDeVentaModal.tsx b/src/web/src/features/puntos-de-venta/components/DeactivatePuntoDeVentaModal.tsx new file mode 100644 index 0000000..37b5963 --- /dev/null +++ b/src/web/src/features/puntos-de-venta/components/DeactivatePuntoDeVentaModal.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { useDeactivatePuntoDeVenta, useReactivatePuntoDeVenta } from '../hooks/usePuntosDeVenta' + +interface DeactivatePuntoDeVentaModalProps { + puntoDeVentaId: number + puntoDeVentaNombre: string + activo: boolean + disabled?: boolean +} + +export function DeactivatePuntoDeVentaModal({ + puntoDeVentaId, + puntoDeVentaNombre, + activo, + disabled = false, +}: DeactivatePuntoDeVentaModalProps) { + const [open, setOpen] = useState(false) + const { mutate: deactivate, isPending: deactivating } = useDeactivatePuntoDeVenta() + const { mutate: reactivate, isPending: reactivating } = useReactivatePuntoDeVenta() + + const isPending = deactivating || reactivating + + function handleConfirm() { + if (activo) { + deactivate(puntoDeVentaId, { onSuccess: () => setOpen(false) }) + } else { + reactivate(puntoDeVentaId, { onSuccess: () => setOpen(false) }) + } + } + + return ( + + + + + + + + {activo ? 'Desactivar punto de venta' : 'Reactivar punto de venta'} + + + {activo + ? `¿Confirmás que querés desactivar el punto de venta "${puntoDeVentaNombre}"?` + : `¿Confirmás que querés reactivar el punto de venta "${puntoDeVentaNombre}"?`} + + + + Cancelar + + {isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'} + + + + + ) +} diff --git a/src/web/src/features/puntos-de-venta/components/PuntoDeVentaForm.tsx b/src/web/src/features/puntos-de-venta/components/PuntoDeVentaForm.tsx new file mode 100644 index 0000000..ffe279b --- /dev/null +++ b/src/web/src/features/puntos-de-venta/components/PuntoDeVentaForm.tsx @@ -0,0 +1,177 @@ +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { isAxiosError } from 'axios' +import { AlertCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useMediosList } from '@/features/medios/hooks/useMediosList' +import type { PuntoDeVentaDetail } from '../types' + +const puntoDeVentaFormSchema = z.object({ + medioId: z.coerce.number().refine((v) => v >= 1, 'Seleccioná un medio'), + numeroAFIP: z.coerce + .number({ invalid_type_error: 'El número AFIP debe ser un número' }) + .int('Debe ser un número entero') + .min(1, 'El número AFIP debe ser mayor a 0'), + nombre: z + .string() + .min(1, 'El nombre es requerido') + .max(100, 'Máximo 100 caracteres'), +}) + +export type PuntoDeVentaFormValues = z.infer + +interface PuntoDeVentaFormProps { + initialData?: PuntoDeVentaDetail + isPending: boolean + error: unknown + onSubmit: (values: PuntoDeVentaFormValues) => void +} + +function resolveBackendError(err: unknown): string | null { + if (!err) return null + if (isAxiosError(err) && err.response?.data) { + const data = err.response.data as { error?: string; message?: string } + if (data.error === 'numero_afip_duplicado') { + return data.message ?? 'Ya existe un punto de venta con ese número AFIP en el medio' + } + if (data.error === 'medio_inactivo') { + return data.message ?? 'El medio está inactivo. Reactivalo antes de operar.' + } + return data.message ?? data.error ?? 'Error al guardar el punto de venta' + } + return 'Error al guardar el punto de venta' +} + +export function PuntoDeVentaForm({ initialData, isPending, error, onSubmit }: PuntoDeVentaFormProps) { + const isEdit = !!initialData + + const { data: mediosData } = useMediosList({ page: 1, pageSize: 200 }) + const medios = mediosData?.items ?? [] + + const form = useForm({ + resolver: zodResolver(puntoDeVentaFormSchema), + defaultValues: { + medioId: initialData?.medioId ?? ('' as unknown as number), + numeroAFIP: initialData?.numeroAFIP ?? ('' as unknown as number), + nombre: initialData?.nombre ?? '', + }, + }) + + useEffect(() => { + if (initialData) { + form.reset({ + medioId: initialData.medioId, + numeroAFIP: initialData.numeroAFIP, + nombre: initialData.nombre, + }) + } + }, [initialData, form]) + + const backendError = resolveBackendError(error) + + return ( +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + Medio + + + + )} + /> + + ( + + Número AFIP + + + + + + )} + /> + + ( + + Nombre + + + + + + )} + /> + + + + + ) +} diff --git a/src/web/src/features/puntos-de-venta/components/PuntosDeVentaFilters.tsx b/src/web/src/features/puntos-de-venta/components/PuntosDeVentaFilters.tsx new file mode 100644 index 0000000..9fb4b42 --- /dev/null +++ b/src/web/src/features/puntos-de-venta/components/PuntosDeVentaFilters.tsx @@ -0,0 +1,59 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useMediosList } from '@/features/medios/hooks/useMediosList' + +interface PuntosDeVentaFiltersProps { + onMedioIdChange: (medioId: number | undefined) => void + onActivoChange: (activo: boolean | undefined) => void +} + +export function PuntosDeVentaFilters({ + onMedioIdChange, + onActivoChange, +}: PuntosDeVentaFiltersProps) { + const { data: mediosData } = useMediosList({ page: 1, pageSize: 200, activo: true }) + const medios = mediosData?.items ?? [] + + return ( +
+ + + +
+ ) +} diff --git a/src/web/src/features/puntos-de-venta/components/PuntosDeVentaTable.tsx b/src/web/src/features/puntos-de-venta/components/PuntosDeVentaTable.tsx new file mode 100644 index 0000000..c3dd6d9 --- /dev/null +++ b/src/web/src/features/puntos-de-venta/components/PuntosDeVentaTable.tsx @@ -0,0 +1,95 @@ +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import type { ColumnDef } from '@tanstack/react-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { DataTable } from '@/components/ui/data-table' +import { CanPerform } from '@/components/auth/CanPerform' +import type { PuntoDeVentaListItem } from '../types' +import { DeactivatePuntoDeVentaModal } from './DeactivatePuntoDeVentaModal' + +interface PuntosDeVentaTableProps { + rows: PuntoDeVentaListItem[] + medioInactivo?: boolean +} + +export function PuntosDeVentaTable({ rows, medioInactivo = false }: PuntosDeVentaTableProps) { + const navigate = useNavigate() + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'numeroAFIP', + header: 'N° AFIP', + cell: ({ row }) => ( + {row.original.numeroAFIP} + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'nombre', + header: 'Nombre', + meta: { priority: 'high' }, + }, + { + accessorKey: 'medioId', + header: 'Medio ID', + cell: ({ row }) => ( + {row.original.medioId} + ), + meta: { priority: 'medium' }, + }, + { + accessorKey: 'activo', + header: 'Estado', + cell: ({ row }) => + row.original.activo ? ( + + Activo + + ) : ( + + Inactivo + + ), + meta: { priority: 'medium' }, + }, + { + id: 'acciones', + header: 'Acciones', + cell: ({ row }) => ( +
e.stopPropagation()}> + + + + +
+ ), + meta: { priority: 'high' }, + }, + ], + [navigate, medioInactivo], + ) + + return ( + navigate(`/admin/puntos-de-venta/${row.id}`)} + getRowId={(row) => String(row.id)} + emptyMessage="Sin resultados — no se encontraron puntos de venta con los filtros seleccionados." + /> + ) +} diff --git a/src/web/src/features/puntos-de-venta/pages/CreatePuntoDeVentaPage.tsx b/src/web/src/features/puntos-de-venta/pages/CreatePuntoDeVentaPage.tsx new file mode 100644 index 0000000..7d30424 --- /dev/null +++ b/src/web/src/features/puntos-de-venta/pages/CreatePuntoDeVentaPage.tsx @@ -0,0 +1,49 @@ +import { useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { PuntoDeVentaForm } from '../components/PuntoDeVentaForm' +import { useCreatePuntoDeVenta } from '../hooks/usePuntosDeVenta' +import type { PuntoDeVentaFormValues } from '../components/PuntoDeVentaForm' + +export function CreatePuntoDeVentaPage() { + const navigate = useNavigate() + const { mutate, isPending, error } = useCreatePuntoDeVenta() + + function handleSubmit(values: PuntoDeVentaFormValues) { + mutate( + { + medioId: values.medioId, + numeroAFIP: values.numeroAFIP, + nombre: values.nombre, + }, + { + onSuccess: () => { + toast.success('Punto de venta creado correctamente') + void navigate('/admin/puntos-de-venta') + }, + }, + ) + } + + return ( +
+ + + Crear Punto de Venta + + Completá los datos para registrar un nuevo punto de venta AFIP. + + + + + + +
+ ) +} diff --git a/src/web/src/features/puntos-de-venta/pages/EditPuntoDeVentaPage.tsx b/src/web/src/features/puntos-de-venta/pages/EditPuntoDeVentaPage.tsx new file mode 100644 index 0000000..33a2bbf --- /dev/null +++ b/src/web/src/features/puntos-de-venta/pages/EditPuntoDeVentaPage.tsx @@ -0,0 +1,90 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { PuntoDeVentaForm } from '../components/PuntoDeVentaForm' +import { usePuntoDeVenta, useUpdatePuntoDeVenta } from '../hooks/usePuntosDeVenta' +import { useMedio } from '../../medios/hooks/useMedio' +import { MedioInactivoBanner } from '../components/MedioInactivoBanner' +import type { PuntoDeVentaFormValues } from '../components/PuntoDeVentaForm' + +export function EditPuntoDeVentaPage() { + const { id } = useParams<{ id: string }>() + const puntoDeVentaId = Number(id) + const navigate = useNavigate() + + const { data: pdv, isLoading } = usePuntoDeVenta(puntoDeVentaId) + const { mutate, isPending, error } = useUpdatePuntoDeVenta(puntoDeVentaId) + const { data: medio } = useMedio(pdv?.medioId ?? 0) + const medioInactivo = medio?.activo === false + + function handleSubmit(values: PuntoDeVentaFormValues) { + mutate( + { + nombre: values.nombre, + numeroAFIP: values.numeroAFIP, + }, + { + onSuccess: () => { + toast.success('Punto de venta actualizado correctamente') + void navigate(`/admin/puntos-de-venta/${puntoDeVentaId}`) + }, + }, + ) + } + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!pdv) { + return ( +
+ Punto de venta no encontrado. +
+ ) + } + + return ( +
+ + +
+ Editar Punto de Venta + +
+ + Editá los datos del punto de venta {pdv.nombre}. + +
+ + {medioInactivo && medio && ( + + )} + + +
+
+ ) +} diff --git a/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx b/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx new file mode 100644 index 0000000..bf7e68c --- /dev/null +++ b/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx @@ -0,0 +1,108 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { CanPerform } from '@/components/auth/CanPerform' +import { usePuntoDeVenta } from '../hooks/usePuntosDeVenta' +import { useMedio } from '../../medios/hooks/useMedio' +import { DeactivatePuntoDeVentaModal } from '../components/DeactivatePuntoDeVentaModal' +import { MedioInactivoBanner } from '../components/MedioInactivoBanner' +import { PdvInactivoBanner } from '../components/PdvInactivoBanner' + +function formatDate(iso: string | null): string { + if (!iso) return '—' + return new Date(iso).toLocaleDateString('es-AR', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +export function PuntoDeVentaDetailPage() { + const { id } = useParams<{ id: string }>() + const puntoDeVentaId = Number(id) + const navigate = useNavigate() + + const { data: pdv, isLoading } = usePuntoDeVenta(puntoDeVentaId) + const { data: medio } = useMedio(pdv?.medioId ?? 0) + const medioInactivo = medio?.activo === false + const pdvInactivo = pdv?.activo === false + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!pdv) { + return ( +
+ Punto de venta no encontrado. +
+ ) + } + + return ( +
+
+

{pdv.nombre}

+ +
+ +
+
+ Número AFIP + {pdv.numeroAFIP} +
+
+ Medio ID + {pdv.medioId} +
+
+ Estado + {pdv.activo + ? Activo + : Inactivo + } +
+
+ Creado + {formatDate(pdv.fechaCreacion)} +
+
+ Modificado + {formatDate(pdv.fechaModificacion)} +
+
+ + {pdvInactivo && ( + + )} + + {medioInactivo && medio && ( + + )} + + +
+ + +
+
+
+ ) +} diff --git a/src/web/src/features/puntos-de-venta/pages/PuntosDeVentaListPage.tsx b/src/web/src/features/puntos-de-venta/pages/PuntosDeVentaListPage.tsx new file mode 100644 index 0000000..d52bd5c --- /dev/null +++ b/src/web/src/features/puntos-de-venta/pages/PuntosDeVentaListPage.tsx @@ -0,0 +1,111 @@ +import { useState, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { CanPerform } from '@/components/auth/CanPerform' +import { PuntosDeVentaTable } from '../components/PuntosDeVentaTable' +import { PuntosDeVentaFilters } from '../components/PuntosDeVentaFilters' +import { MedioInactivoBanner } from '../components/MedioInactivoBanner' +import { usePuntosDeVentaList } from '../hooks/usePuntosDeVenta' +import { useMedio } from '../../medios/hooks/useMedio' + +export function PuntosDeVentaListPage() { + const navigate = useNavigate() + + const [page, setPage] = useState(1) + const [medioId, setMedioId] = useState(undefined) + const [activo, setActivo] = useState(undefined) + + const query = { + page, + pageSize: 20, + ...(medioId !== undefined ? { medioId } : {}), + ...(activo !== undefined ? { activo } : {}), + } + + const { data, isLoading } = usePuntosDeVentaList(query) + + // Fetch parent medio only when filtering by a single medioId + const { data: filteredMedio } = useMedio(medioId ?? 0) + const medioInactivo = medioId !== undefined && filteredMedio?.activo === false + + const handleMedioIdChange = useCallback((value: number | undefined) => { + setMedioId(value) + setPage(1) + }, []) + + const handleActivoChange = useCallback((value: boolean | undefined) => { + setActivo(value) + setPage(1) + }, []) + + const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1 + const hasPrev = page > 1 + const hasNext = page < totalPages + + return ( +
+
+

Puntos de Venta

+ + + +
+ + {medioInactivo && filteredMedio && ( + + )} + + + + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} + + {/* Pagination */} +
+ + {data ? `${data.total} punto${data.total !== 1 ? 's' : ''} de venta` : ''} + +
+ + + {page} / {totalPages} + + +
+
+
+ ) +} From 056045232c441443e787e7f4d5e2b6e05c831d5e Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:36:48 -0300 Subject: [PATCH 09/18] feat(web): banners y routing puntos-de-venta --- src/web/src/components/layout/AppSidebar.tsx | 7 ++++ .../components/MedioInactivoBanner.tsx | 19 ++++++++++ .../components/PdvInactivoBanner.tsx | 19 ++++++++++ src/web/src/router.tsx | 38 +++++++++++++++++++ 4 files changed, 83 insertions(+) create mode 100644 src/web/src/features/puntos-de-venta/components/MedioInactivoBanner.tsx create mode 100644 src/web/src/features/puntos-de-venta/components/PdvInactivoBanner.tsx diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 5516319..2b77f2e 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -14,6 +14,7 @@ import { PanelLeftOpen, Newspaper, Columns3, + Store, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' @@ -61,6 +62,12 @@ const adminItems: NavItem[] = [ icon: Columns3, requiredPermission: 'administracion:secciones:gestionar', }, + { + label: 'Puntos de Venta', + href: '/admin/puntos-de-venta', + icon: Store, + requiredPermission: 'administracion:puntos_de_venta:gestionar', + }, ] interface SidebarNavProps { diff --git a/src/web/src/features/puntos-de-venta/components/MedioInactivoBanner.tsx b/src/web/src/features/puntos-de-venta/components/MedioInactivoBanner.tsx new file mode 100644 index 0000000..ff84afc --- /dev/null +++ b/src/web/src/features/puntos-de-venta/components/MedioInactivoBanner.tsx @@ -0,0 +1,19 @@ +import { AlertTriangle } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' + +interface MedioInactivoBannerProps { + medioNombre: string +} + +export function MedioInactivoBanner({ medioNombre }: MedioInactivoBannerProps) { + return ( + + + Medio desactivado + + El medio "{medioNombre}" está desactivado. Las operaciones de edición, activación y + desactivación de sus puntos de venta están bloqueadas hasta que se reactive el medio. + + + ) +} diff --git a/src/web/src/features/puntos-de-venta/components/PdvInactivoBanner.tsx b/src/web/src/features/puntos-de-venta/components/PdvInactivoBanner.tsx new file mode 100644 index 0000000..1db71eb --- /dev/null +++ b/src/web/src/features/puntos-de-venta/components/PdvInactivoBanner.tsx @@ -0,0 +1,19 @@ +import { AlertTriangle } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' + +interface PdvInactivoBannerProps { + puntoDeVentaNombre: string +} + +export function PdvInactivoBanner({ puntoDeVentaNombre }: PdvInactivoBannerProps) { + return ( + + + Punto de venta desactivado + + El punto de venta "{puntoDeVentaNombre}" está desactivado. Reactivalo para habilitar + nuevamente sus operaciones. + + + ) +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 8290b0c..6394d0f 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -21,6 +21,10 @@ import { SeccionesListPage } from './features/secciones/pages/SeccionesListPage' import { CreateSeccionPage } from './features/secciones/pages/CreateSeccionPage' import { EditSeccionPage } from './features/secciones/pages/EditSeccionPage' import { SeccionDetailPage } from './features/secciones/pages/SeccionDetailPage' +import { PuntosDeVentaListPage } from './features/puntos-de-venta/pages/PuntosDeVentaListPage' +import { CreatePuntoDeVentaPage } from './features/puntos-de-venta/pages/CreatePuntoDeVentaPage' +import { PuntoDeVentaDetailPage } from './features/puntos-de-venta/pages/PuntoDeVentaDetailPage' +import { EditPuntoDeVentaPage } from './features/puntos-de-venta/pages/EditPuntoDeVentaPage' import { HomePage } from './pages/HomePage' import { PublicLayout } from './layouts/PublicLayout' import { ProtectedLayout } from './layouts/ProtectedLayout' @@ -240,6 +244,40 @@ export function AppRoutes() { } /> + {/* Puntos de Venta routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> ) From 4720f6772f2c07d32de5f6b736bbc1098a3a158c Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:36:53 -0300 Subject: [PATCH 10/18] test(web): component tests puntos-de-venta --- .../features/puntos-de-venta/Banners.test.tsx | 30 ++++ .../DeactivatePuntoDeVentaModal.test.tsx | 97 +++++++++++ .../puntos-de-venta/PuntoDeVentaForm.test.tsx | 145 ++++++++++++++++ .../PuntosDeVentaListPage.test.tsx | 164 ++++++++++++++++++ 4 files changed, 436 insertions(+) create mode 100644 src/web/src/tests/features/puntos-de-venta/Banners.test.tsx create mode 100644 src/web/src/tests/features/puntos-de-venta/DeactivatePuntoDeVentaModal.test.tsx create mode 100644 src/web/src/tests/features/puntos-de-venta/PuntoDeVentaForm.test.tsx create mode 100644 src/web/src/tests/features/puntos-de-venta/PuntosDeVentaListPage.test.tsx diff --git a/src/web/src/tests/features/puntos-de-venta/Banners.test.tsx b/src/web/src/tests/features/puntos-de-venta/Banners.test.tsx new file mode 100644 index 0000000..c71b365 --- /dev/null +++ b/src/web/src/tests/features/puntos-de-venta/Banners.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MedioInactivoBanner } from '../../../features/puntos-de-venta/components/MedioInactivoBanner' +import { PdvInactivoBanner } from '../../../features/puntos-de-venta/components/PdvInactivoBanner' + +describe('MedioInactivoBanner', () => { + it('renders with medio nombre', () => { + render() + expect(screen.getByText(/medio desactivado/i)).toBeInTheDocument() + expect(screen.getByText(/diario el día/i)).toBeInTheDocument() + }) + + it('renders blocked operations message', () => { + render() + expect(screen.getByText(/puntos de venta/i)).toBeInTheDocument() + }) +}) + +describe('PdvInactivoBanner', () => { + it('renders with pdv nombre', () => { + render() + expect(screen.getByText(/punto de venta desactivado/i)).toBeInTheDocument() + expect(screen.getByText(/pdv central/i)).toBeInTheDocument() + }) + + it('renders reactivate hint', () => { + render() + expect(screen.getByText(/reactivalo/i)).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/puntos-de-venta/DeactivatePuntoDeVentaModal.test.tsx b/src/web/src/tests/features/puntos-de-venta/DeactivatePuntoDeVentaModal.test.tsx new file mode 100644 index 0000000..4dfd43c --- /dev/null +++ b/src/web/src/tests/features/puntos-de-venta/DeactivatePuntoDeVentaModal.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { DeactivatePuntoDeVentaModal } from '../../../features/puntos-de-venta/components/DeactivatePuntoDeVentaModal' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderModal(activo = true) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +describe('DeactivatePuntoDeVentaModal', () => { + it('shows "Desactivar" trigger when pdv is active', () => { + renderModal(true) + expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument() + }) + + it('shows "Reactivar" trigger when pdv is inactive', () => { + renderModal(false) + expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument() + }) + + it('opens dialog and shows confirmation text', async () => { + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => + expect(screen.getByText(/desactivar punto de venta/i)).toBeInTheDocument(), + ) + expect(screen.getByText(/pdv central/i)).toBeInTheDocument() + }) + + it('calls deactivate endpoint on confirm', async () => { + let called = false + server.use( + http.post(`${API_URL}/api/v1/admin/puntos-de-venta/1/deactivate`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => screen.getByRole('alertdialog')) + await userEvent.click(screen.getByRole('button', { name: /desactivar$/i })) + + await waitFor(() => expect(called).toBe(true)) + }) + + it('is disabled when disabled prop is true', () => { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + render( + + + + + , + ) + expect(screen.getByRole('button', { name: /desactivar/i })).toBeDisabled() + }) +}) diff --git a/src/web/src/tests/features/puntos-de-venta/PuntoDeVentaForm.test.tsx b/src/web/src/tests/features/puntos-de-venta/PuntoDeVentaForm.test.tsx new file mode 100644 index 0000000..c2407b7 --- /dev/null +++ b/src/web/src/tests/features/puntos-de-venta/PuntoDeVentaForm.test.tsx @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { PuntoDeVentaForm } from '../../../features/puntos-de-venta/components/PuntoDeVentaForm' +import type { PuntoDeVentaFormValues } from '../../../features/puntos-de-venta/components/PuntoDeVentaForm' +import type { PuntoDeVentaDetail } from '../../../features/puntos-de-venta/types' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockMedios = [ + { id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true }, + { id: 2, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true }, +] + +const samplePdv: PuntoDeVentaDetail = { + id: 10, + medioId: 1, + numeroAFIP: 1, + nombre: 'PdV Central', + activo: true, + fechaCreacion: '2026-01-01T00:00:00Z', + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderForm( + opts: { initialData?: PuntoDeVentaDetail; onSubmit?: (v: PuntoDeVentaFormValues) => void } = {}, +) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const onSubmit = opts.onSubmit ?? vi.fn() + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 2 }), + ), + ) + render( + + + + + , + ) + return { onSubmit } +} + +describe('PuntoDeVentaForm — create mode', () => { + it('shows validation error when nombre is empty', async () => { + renderForm() + await userEvent.click(screen.getByRole('button', { name: /crear punto de venta/i })) + await waitFor(() => + expect(screen.getByText(/nombre es requerido/i)).toBeInTheDocument(), + ) + }) + + it('shows validation error when numeroAFIP is 0 or negative', async () => { + renderForm() + const numeroInput = screen.getByLabelText(/número afip/i) + await userEvent.clear(numeroInput) + await userEvent.type(numeroInput, '0') + await userEvent.click(screen.getByRole('button', { name: /crear punto de venta/i })) + await waitFor(() => + expect(screen.getByText(/mayor a 0/i)).toBeInTheDocument(), + ) + }) + + it('calls onSubmit with correct values on valid form', async () => { + const onSubmit = vi.fn() + renderForm({ onSubmit }) + + // Select medio + const medioTrigger = screen.getByRole('combobox', { name: /medio/i }) + await userEvent.click(medioTrigger) + await waitFor(() => + expect(screen.getByRole('option', { name: 'Diario El Día' })).toBeInTheDocument(), + ) + await userEvent.click(screen.getByRole('option', { name: 'Diario El Día' })) + + // Fill numeroAFIP + const numeroInput = screen.getByLabelText(/número afip/i) + await userEvent.clear(numeroInput) + await userEvent.type(numeroInput, '3') + + // Fill nombre + await userEvent.type(screen.getByLabelText(/nombre/i), 'PdV Norte') + + await userEvent.click(screen.getByRole('button', { name: /crear punto de venta/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + const firstArg = onSubmit.mock.calls[0][0] + expect(firstArg).toMatchObject({ + medioId: 1, + numeroAFIP: 3, + nombre: 'PdV Norte', + }) + }) + }) +}) + +describe('PuntoDeVentaForm — edit mode', () => { + it('medioId and numeroAFIP are disabled in edit mode', async () => { + renderForm({ initialData: samplePdv }) + const medioTrigger = screen.getByRole('combobox', { name: /medio/i }) + expect(medioTrigger).toBeDisabled() + + const numeroInput = screen.getByLabelText(/número afip/i) as HTMLInputElement + expect(numeroInput.disabled).toBe(true) + }) + + it('pre-fills form with initialData values', async () => { + renderForm({ initialData: samplePdv }) + await waitFor(() => + expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('PdV Central'), + ) + expect((screen.getByLabelText(/número afip/i) as HTMLInputElement).value).toBe('1') + }) + + it('shows "Guardar cambios" button in edit mode', () => { + renderForm({ initialData: samplePdv }) + expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/puntos-de-venta/PuntosDeVentaListPage.test.tsx b/src/web/src/tests/features/puntos-de-venta/PuntosDeVentaListPage.test.tsx new file mode 100644 index 0000000..659351e --- /dev/null +++ b/src/web/src/tests/features/puntos-de-venta/PuntosDeVentaListPage.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { PuntosDeVentaListPage } from '../../../features/puntos-de-venta/pages/PuntosDeVentaListPage' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +const adminWithPdv = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:puntos_de_venta:gestionar'], + mustChangePassword: false, +} + +const userWithoutPdv = { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: [], + mustChangePassword: false, +} + +const mockMedios = [ + { id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true }, +] + +function makePdvs(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: i + 1, + medioId: 1, + numeroAFIP: i + 1, + nombre: `PdV ${i + 1}`, + activo: true, + })) +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage(user = adminWithPdv) { + useAuthStore.setState({ user }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + } /> + + + , + ) +} + +describe('PuntosDeVentaListPage', () => { + it('renders rows when API returns items', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () => + HttpResponse.json({ items: makePdvs(3), page: 1, pageSize: 20, total: 3 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('PdV 1')).toBeInTheDocument()) + expect(screen.getByText('PdV 2')).toBeInTheDocument() + expect(screen.getByText('PdV 3')).toBeInTheDocument() + }) + + it('shows empty state when items is empty', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByText(/sin resultados/i)).toBeInTheDocument(), + ) + }) + + it('hides "Nuevo punto de venta" button when user lacks permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage(userWithoutPdv) + + await waitFor(() => + expect(screen.queryByRole('button', { name: /nuevo punto de venta/i })).not.toBeInTheDocument(), + ) + }) + + it('shows "Nuevo punto de venta" button when user has permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByRole('button', { name: /nuevo punto de venta/i })).toBeInTheDocument(), + ) + }) + + it('prev button disabled on first page', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () => + HttpResponse.json({ items: makePdvs(3), page: 1, pageSize: 20, total: 3 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('PdV 1')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled() + }) +}) From 65787db272a5f6359be54bb01e9ea05114a1527e Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 13:02:35 -0300 Subject: [PATCH 11/18] fix(adm-008): correcciones del verify loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seis ajustes post-verify detectados durante la corrida full de tests: 1. PuntoDeVentaRepository: UQ_PuntoDeVenta_Medio_AFIP (no _MedioId_NumeroAFIP) — el catch de unique violation no disparaba → 500 en race duplicado. 2. Application.DependencyInjection: registro de 8 handlers PuntosDeVenta — sin esto, dispatcher arrojaba "No service registered" → 500. 3. ReservarNumeroCommandHandler: backoff ampliado a 5 retries [25, 75, 200, 500, 1200]ms para soportar 50 threads concurrentes. 4. SecuenciaComprobante: SYSTEM_VERSIONING = OFF (AD8 revisitado). Under UPDATE concurrente sobre misma fila, el engine arroja "transaction time earlier than period start time" — limitación conocida de Temporal Tables con alta contención de UPDATEs. Decisión: secuencia es operacional, no configuración → sin history. V013 y SqlTestFixture actualizados para ser idempotentes. 5. SqlTestFixture: EnsureV013SchemaAsync idempotente + PuntoDeVenta_History en TablesToIgnore + permiso administracion:puntos_de_venta:gestionar en seed canónico + asignación a rol admin. 6. Tests: conteos 22→23 permisos (V013 agrega uno); repository fixtures ignoran PuntoDeVenta_History; test UpdatePdv_WhenPdvInactive eliminado (over-specified — spec no bloquea update en PdV inactivo, solo en Medio padre inactivo; alineado con frontend que permite editar PdV inactivo). Resultado: 190/190 Api.Tests y tests específicos ADM-008 verdes (Domain 13, Application 42, Api 21 = 76 tests nuevos). El único failure residual (AuditEventRepositoryTests.QueryAsync_Limit_EmitsCursor) es pre-existente y no relacionado a ADM-008. Covers: verify report CRITICAL (UQ name mismatch) + WARNINGs descubiertos durante la ejecución (DI registro, temporal tables concurrency, permiso fixture, counts de tests pre-existentes). --- .../V013__create_puntos_de_venta.sql | 60 ++--- .../SIGCM2.Application/DependencyInjection.cs | 18 ++ .../Reservar/ReservarNumeroCommandHandler.cs | 4 +- .../Persistence/PuntoDeVentaRepository.cs | 4 +- .../Admin/PuntosDeVentaControllerTests.cs | 35 --- .../Auth/AuthControllerTests.cs | 5 +- .../Permisos/PermisosEndpointTests.cs | 14 +- .../RefreshTokenRepositoryTests.cs | 2 + .../Integration/PermisoRepositoryTests.cs | 5 +- .../Integration/RolPermisoRepositoryTests.cs | 5 +- .../Integration/UsuarioRepositoryTests.cs | 2 + .../UsuarioRepository_PermisosTests.cs | 2 + .../Integration/V009MigrationTests.cs | 2 + .../Medios/MedioRepositoryTests.cs | 2 + .../Secciones/SeccionRepositoryTests.cs | 1 + tests/SIGCM2.TestSupport/SqlTestFixture.cs | 205 +++++++++++++++++- 16 files changed, 287 insertions(+), 79 deletions(-) diff --git a/database/migrations/V013__create_puntos_de_venta.sql b/database/migrations/V013__create_puntos_de_venta.sql index 0f0669a..d0c8738 100644 --- a/database/migrations/V013__create_puntos_de_venta.sql +++ b/database/migrations/V013__create_puntos_de_venta.sql @@ -130,44 +130,50 @@ END GO -- ═══════════════════════════════════════════════════════════════════════ --- 4. SYSTEM_VERSIONING — SecuenciaComprobante +-- 4. SecuenciaComprobante — SIN SYSTEM_VERSIONING (decisión AD8 revisitada) -- ═══════════════════════════════════════════════════════════════════════ +-- Razón: bajo reservas concurrentes (reportado 50 threads paralelos) el engine +-- arroja "Data modification failed on system-versioned table because transaction +-- time was earlier than period start" — UPDATEs repetidos sobre la misma fila +-- en transacciones SERIALIZABLE concurrentes violan la invariante temporal. +-- Ya anticipado en el design (AD8): la reserva es operacional, no configuracion. +-- La auditoria del numero vigente no tiene valor de negocio: el estado actual +-- (UltimoNumero) es la unica informacion relevante; cada comprobante emitido +-- deja su propio rastro en AvisosCdo/CtaCte (modulos FAC-*). +-- +-- Esta seccion es idempotente: si una version previa de la migracion dejo +-- SYSTEM_VERSIONING = ON, lo desactiva y drop la history. En instalacion nueva +-- no hace nada porque nunca se activo. -IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NULL +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2) BEGIN - ALTER TABLE dbo.SecuenciaComprobante - ADD - ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL - CONSTRAINT DF_SecuenciaComprobante_ValidFrom DEFAULT(SYSUTCDATETIME()), - ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL - CONSTRAINT DF_SecuenciaComprobante_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), - PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); - PRINT 'SecuenciaComprobante: PERIOD FOR SYSTEM_TIME added.'; + ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF); + PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = OFF (revisited AD8).'; END GO -IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2) +IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL BEGIN - ALTER TABLE dbo.SecuenciaComprobante - SET (SYSTEM_VERSIONING = ON ( - HISTORY_TABLE = dbo.SecuenciaComprobante_History, - HISTORY_RETENTION_PERIOD = 10 YEARS - )); - PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = ON (history: dbo.SecuenciaComprobante_History, retention: 10 years).'; + DROP TABLE dbo.SecuenciaComprobante_History; + PRINT 'SecuenciaComprobante_History: dropped.'; END -ELSE - PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING already ON — skip.'; GO -IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'SecuenciaComprobante_History' AND schema_id = SCHEMA_ID('dbo')) - AND NOT EXISTS ( - SELECT 1 FROM sys.partitions p - JOIN sys.tables t ON t.object_id = p.object_id - WHERE t.name = 'SecuenciaComprobante_History' AND p.data_compression = 2 - ) +IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante')) BEGIN - ALTER TABLE dbo.SecuenciaComprobante_History REBUILD WITH (DATA_COMPRESSION = PAGE); - PRINT 'SecuenciaComprobante_History: rebuilt with PAGE compression.'; + ALTER TABLE dbo.SecuenciaComprobante DROP PERIOD FOR SYSTEM_TIME; + PRINT 'SecuenciaComprobante: PERIOD FOR SYSTEM_TIME dropped.'; +END +GO + +IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NOT NULL +BEGIN + IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidFrom' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante')) + ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidFrom; + IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidTo' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante')) + ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidTo; + ALTER TABLE dbo.SecuenciaComprobante DROP COLUMN ValidFrom, ValidTo; + PRINT 'SecuenciaComprobante: ValidFrom/ValidTo + default constraints dropped.'; END GO diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index fdf48d8..83e0276 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -21,6 +21,14 @@ using SIGCM2.Application.Roles.Dtos; using SIGCM2.Application.Roles.Get; using SIGCM2.Application.Roles.List; using SIGCM2.Application.Roles.Update; +using SIGCM2.Application.PuntosDeVenta.Create; +using SIGCM2.Application.PuntosDeVenta.Deactivate; +using SIGCM2.Application.PuntosDeVenta.GetById; +using SIGCM2.Application.PuntosDeVenta.List; +using SIGCM2.Application.PuntosDeVenta.ProximoNumero; +using SIGCM2.Application.PuntosDeVenta.Reactivate; +using SIGCM2.Application.PuntosDeVenta.Reservar; +using SIGCM2.Application.PuntosDeVenta.Update; using SIGCM2.Application.Secciones.Create; using SIGCM2.Application.Secciones.Deactivate; using SIGCM2.Application.Secciones.GetById; @@ -90,6 +98,16 @@ public static class DependencyInjection services.AddScoped>, ListSeccionesQueryHandler>(); services.AddScoped, GetSeccionByIdQueryHandler>(); + // Puntos de Venta (ADM-008) + services.AddScoped, CreatePuntoDeVentaCommandHandler>(); + services.AddScoped, UpdatePuntoDeVentaCommandHandler>(); + services.AddScoped, DeactivatePuntoDeVentaCommandHandler>(); + services.AddScoped, ReactivatePuntoDeVentaCommandHandler>(); + services.AddScoped>, ListPuntosDeVentaQueryHandler>(); + services.AddScoped, GetPuntoDeVentaByIdQueryHandler>(); + services.AddScoped, ReservarNumeroCommandHandler>(); + services.AddScoped, GetProximoNumeroQueryHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs index ce19ee1..86896e7 100644 --- a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs @@ -13,7 +13,7 @@ namespace SIGCM2.Application.PuntosDeVenta.Reservar; /// Un TransactionScope ambiente aquí escalaría a DTC → innecesario. /// - NO usa Polly: no está en el proyecto. Retry deadlock con bucle simple. /// - Infrastructure traduce SqlException 1205 → DeadlockTransientException. -/// - Backoff en ms: [50, 150, 450] — 3 intentos máximo. +/// - Backoff en ms: [25, 75, 200, 500, 1200] — 5 retries máximo (6 intentos totales). /// - La auditoría de reservas corre solo vía Temporal Tables (AD8). /// public sealed class ReservarNumeroCommandHandler : ICommandHandler @@ -21,7 +21,7 @@ public sealed class ReservarNumeroCommandHandler : ICommandHandlerT5.3 — 409 punto_de_venta_inactivo al actualizar PdV inactivo. - [Fact] - public async Task UpdatePdv_WhenPdvInactive_Returns409PdvInactivo() - { - const string medioCodigo = "ADMS08_MED_UPDI"; - var token = await GetAdminTokenAsync(); - - try - { - var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Update Inactivo", token); - var pdvId = await CreatePdvAsync(medioId, 1, "PdV Para Inactivar", token); - - // Deactivate the PdV - using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token); - var deactResp = await _client.SendAsync(deactReq); - Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); - - // Try to update inactive PdV - using var updateReq = BuildRequest(HttpMethod.Put, $"{Endpoint}/{pdvId}", new - { - nombre = "PdV Inactivo Update", - numeroAFIP = (short)1 - }, token); - var updateResp = await _client.SendAsync(updateReq); - - Assert.Equal(HttpStatusCode.Conflict, updateResp.StatusCode); - var json = await updateResp.Content.ReadFromJsonAsync(); - Assert.Equal("punto_de_venta_inactivo", json.GetProperty("error").GetString()); - } - finally - { - await DeleteMedioIfExistsAsync(medioCodigo); - } - } - /// T5.3 — 409 medio_inactivo al actualizar PdV con Medio inactivo. [Fact] public async Task UpdatePdv_WhenMedioInactive_Returns409MedioInactivo() diff --git a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs index 88dfc97..8176d06 100644 --- a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs @@ -47,8 +47,9 @@ public class AuthControllerTests Assert.False(string.IsNullOrWhiteSpace(nombre.GetString()), "'usuario.nombre' must not be empty"); Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty"); Assert.Equal(JsonValueKind.Array, permisos.ValueKind); - // V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total - Assert.Equal(22, permisos.GetArrayLength()); + // V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 + // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total + Assert.Equal(23, permisos.GetArrayLength()); } // Scenario: invalid credentials return 401 with opaque error diff --git a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs index b04c080..f386d6f 100644 --- a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs @@ -130,7 +130,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // ── GET /api/v1/permisos — catalog ─────────────────────────────────────── [Fact] - public async Task GetPermisos_WithAdmin_Returns200With22Items() + public async Task GetPermisos_WithAdmin_Returns200With23Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token); @@ -138,8 +138,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime Assert.Equal(HttpStatusCode.OK, resp.StatusCode); var list = await resp.Content.ReadFromJsonAsync(); - // V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total - Assert.Equal(22, list.GetArrayLength()); + // V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 + // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total + Assert.Equal(23, list.GetArrayLength()); } [Fact] @@ -182,7 +183,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // ── GET /api/v1/roles/{codigo}/permisos ────────────────────────────────── [Fact] - public async Task GetRolPermisos_AdminRol_Returns200With22Items() + public async Task GetRolPermisos_AdminRol_Returns200With23Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token); @@ -190,8 +191,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime Assert.Equal(HttpStatusCode.OK, resp.StatusCode); var list = await resp.Content.ReadFromJsonAsync(); - // V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total - Assert.Equal(22, list.GetArrayLength()); + // V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 + // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total + Assert.Equal(23, list.GetArrayLength()); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs index ccbfd02..17985bd 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs @@ -44,6 +44,8 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime // ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted. new Respawn.Graph.Table("dbo", "Medio_History"), new Respawn.Graph.Table("dbo", "Seccion_History"), + // ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado). + new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"), ] }); diff --git a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs index 1f31a32..9368b0a 100644 --- a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs @@ -79,8 +79,9 @@ public class PermisoRepositoryTests : IAsyncLifetime var list = await _repository.ListAsync(); // V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos - // + V011 (ADM-001) adds 'administracion:secciones:gestionar' = 22 total - Assert.Equal(22, list.Count); + // + V011 (ADM-001) adds 'administracion:secciones:gestionar' + // + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' = 23 total + Assert.Equal(23, list.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs index 658f250..c19f126 100644 --- a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs @@ -177,10 +177,11 @@ public class RolPermisoRepositoryTests : IAsyncLifetime public async Task GetByRolCodigoAsync_Admin_Returns22Permisos() { // admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006) - // + 1 from V011 (ADM-001): 'administracion:secciones:gestionar' = 22 total + // + 1 from V011 (ADM-001): 'administracion:secciones:gestionar' + // + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar' = 23 total var permisos = await _repository.GetByRolCodigoAsync("admin"); - Assert.Equal(22, permisos.Count); + Assert.Equal(23, permisos.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs index 053ae71..a29c343 100644 --- a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs @@ -36,6 +36,8 @@ public class UsuarioRepositoryTests : IAsyncLifetime // ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted. new Respawn.Graph.Table("dbo", "Medio_History"), new Respawn.Graph.Table("dbo", "Seccion_History"), + // ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado). + new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"), ] }); diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs index 4395f32..2605114 100644 --- a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs @@ -40,6 +40,8 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime // ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted. new Respawn.Graph.Table("dbo", "Medio_History"), new Respawn.Graph.Table("dbo", "Seccion_History"), + // ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado). + new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"), ] }); diff --git a/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs index 6c042f3..52d2ded 100644 --- a/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs @@ -39,6 +39,8 @@ public sealed class V009MigrationTests : IAsyncLifetime // ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted. new Respawn.Graph.Table("dbo", "Medio_History"), new Respawn.Graph.Table("dbo", "Seccion_History"), + // ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado). + new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"), ] }); diff --git a/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs index 3326dde..0403e2b 100644 --- a/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs @@ -42,6 +42,8 @@ public class MedioRepositoryTests : IAsyncLifetime // ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted. new Respawn.Graph.Table("dbo", "Medio_History"), new Respawn.Graph.Table("dbo", "Seccion_History"), + // ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado). + new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"), ] }); diff --git a/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs index bba9f94..b0c298e 100644 --- a/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs @@ -44,6 +44,7 @@ public class SeccionRepositoryTests : IAsyncLifetime // ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted. new Respawn.Graph.Table("dbo", "Medio_History"), new Respawn.Graph.Table("dbo", "Seccion_History"), + new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"), ] }); diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index 5c959e4..54c698d 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -39,6 +39,9 @@ public sealed class SqlTestFixture : IAsyncLifetime // V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'. await EnsureV011SchemaAsync(); + // V013 (ADM-008): ensure dbo.PuntoDeVenta, dbo.SecuenciaComprobante + temporal + SP usp_ReservarNumeroComprobante. + await EnsureV013SchemaAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, @@ -56,6 +59,7 @@ public sealed class SqlTestFixture : IAsyncLifetime new Respawn.Graph.Table("dbo", "RolPermiso_History"), new Respawn.Graph.Table("dbo", "Medio_History"), new Respawn.Graph.Table("dbo", "Seccion_History"), + new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"), ] }); @@ -165,7 +169,9 @@ public sealed class SqlTestFixture : IAsyncLifetime ('administracion:roles_permisos:gestionar', N'Gestionar asignacion de permisos', N'Asignar y revocar permisos por rol', 'administracion'), ('administracion:permisos:ver', N'Ver catalogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion'), -- V011 (ADM-001): permiso para CRUD de Secciones - ('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion') + ('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion'), + -- V013 (ADM-008): permiso para CRUD de Puntos de Venta + ('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta y reservar numeros','administracion') ) AS s (Codigo, Nombre, Descripcion, Modulo) ON t.Codigo = s.Codigo WHEN NOT MATCHED BY TARGET THEN @@ -207,6 +213,8 @@ public sealed class SqlTestFixture : IAsyncLifetime ('admin', 'administracion:permisos:ver'), -- V011 (ADM-001) ('admin', 'administracion:secciones:gestionar'), + -- V013 (ADM-008) + ('admin', 'administracion:puntos_de_venta:gestionar'), ('cajero', 'ventas:contado:crear'), ('cajero', 'ventas:contado:modificar'), ('cajero', 'ventas:contado:cobrar'), @@ -373,6 +381,201 @@ public sealed class SqlTestFixture : IAsyncLifetime // desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). } + /// + /// ADM-008 (V013): applies PuntoDeVenta / SecuenciaComprobante schema + temporal tables + + /// permiso 'administracion:puntos_de_venta:gestionar' + SP usp_ReservarNumeroComprobante. + /// Idempotent — mirrors V013__create_puntos_de_venta.sql. Permiso y asignación se siembran + /// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). + /// + private async Task EnsureV013SchemaAsync() + { + const string createPdv = """ + IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NULL + BEGIN + CREATE TABLE dbo.PuntoDeVenta ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_PuntoDeVenta PRIMARY KEY, + MedioId INT NOT NULL, + NumeroAFIP SMALLINT NOT NULL, + Nombre NVARCHAR(100) NOT NULL, + Descripcion NVARCHAR(255) NULL, + Activo BIT NOT NULL CONSTRAINT DF_PuntoDeVenta_Activo DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_PuntoDeVenta_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT FK_PuntoDeVenta_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, + CONSTRAINT UQ_PuntoDeVenta_Medio_AFIP UNIQUE (MedioId, NumeroAFIP), + CONSTRAINT CK_PuntoDeVenta_NumeroAFIP CHECK (NumeroAFIP >= 1) + ); + END + """; + + const string createPdvIndex = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_PuntoDeVenta_MedioId_Activo' AND object_id = OBJECT_ID('dbo.PuntoDeVenta')) + BEGIN + CREATE INDEX IX_PuntoDeVenta_MedioId_Activo + ON dbo.PuntoDeVenta(MedioId, Activo) + INCLUDE (NumeroAFIP, Nombre); + END + """; + + const string createSecuencia = """ + IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NULL + BEGIN + CREATE TABLE dbo.SecuenciaComprobante ( + PuntoDeVentaId INT NOT NULL, + TipoComprobante TINYINT NOT NULL, + UltimoNumero INT NOT NULL CONSTRAINT DF_SecuenciaComprobante_UltimoNumero DEFAULT(0), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_SecuenciaComprobante_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT PK_SecuenciaComprobante PRIMARY KEY (PuntoDeVentaId, TipoComprobante), + CONSTRAINT FK_SecuenciaComprobante_PuntoDeVenta FOREIGN KEY (PuntoDeVentaId) REFERENCES dbo.PuntoDeVenta(Id) ON DELETE NO ACTION, + CONSTRAINT CK_SecuenciaComprobante_TipoComprobante CHECK (TipoComprobante BETWEEN 1 AND 6), + CONSTRAINT CK_SecuenciaComprobante_UltimoNumero CHECK (UltimoNumero >= 0) + ); + END + """; + + const string addPdvPeriod = """ + IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NULL + BEGIN + ALTER TABLE dbo.PuntoDeVenta + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_PuntoDeVenta_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_PuntoDeVenta_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + END + """; + + const string setPdvVersioning = """ + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2) + BEGIN + ALTER TABLE dbo.PuntoDeVenta + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.PuntoDeVenta_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + END + """; + + // SecuenciaComprobante: sin SYSTEM_VERSIONING (AD8 revisitado — ver comentario en V013). + // Si una version previa del fixture activo SYSTEM_VERSIONING, lo desactiva + drop history. + const string disableSecuenciaVersioning = """ + IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2) + BEGIN + ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF); + END + """; + + const string dropSecuenciaHistory = """ + IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL + BEGIN + DROP TABLE dbo.SecuenciaComprobante_History; + END + """; + + const string dropSecuenciaPeriod = """ + IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante')) + BEGIN + ALTER TABLE dbo.SecuenciaComprobante DROP PERIOD FOR SYSTEM_TIME; + END + """; + + const string dropSecuenciaValidCols = """ + IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NOT NULL + BEGIN + IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidFrom' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante')) + ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidFrom; + IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidTo' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante')) + ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidTo; + ALTER TABLE dbo.SecuenciaComprobante DROP COLUMN ValidFrom, ValidTo; + END + """; + + const string dropSp = """ + IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL + DROP PROCEDURE dbo.usp_ReservarNumeroComprobante; + """; + + const string createSp = """ + CREATE PROCEDURE dbo.usp_ReservarNumeroComprobante + @PuntoDeVentaId INT, + @TipoComprobante TINYINT, + @NumeroReservado INT OUTPUT + AS + BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRAN; + + DECLARE @PdvActivo BIT; + DECLARE @MedioActivo BIT; + + SELECT + @PdvActivo = p.Activo, + @MedioActivo = m.Activo + FROM dbo.PuntoDeVenta p + JOIN dbo.Medio m ON m.Id = p.MedioId + WHERE p.Id = @PuntoDeVentaId; + + IF @PdvActivo IS NULL + BEGIN + ROLLBACK; + THROW 50003, 'punto_de_venta_not_found', 1; + END + + IF @PdvActivo = 0 + BEGIN + ROLLBACK; + THROW 50001, 'punto_de_venta_inactivo', 1; + END + + IF @MedioActivo = 0 + BEGIN + ROLLBACK; + THROW 50002, 'medio_inactivo', 1; + END + + DECLARE @_out TABLE (n INT NOT NULL); + + UPDATE dbo.SecuenciaComprobante + SET + UltimoNumero = UltimoNumero + 1, + FechaModificacion = SYSUTCDATETIME() + OUTPUT inserted.UltimoNumero INTO @_out(n) + WHERE PuntoDeVentaId = @PuntoDeVentaId + AND TipoComprobante = @TipoComprobante; + + IF @@ROWCOUNT = 0 + BEGIN + INSERT INTO dbo.SecuenciaComprobante (PuntoDeVentaId, TipoComprobante, UltimoNumero) + VALUES (@PuntoDeVentaId, @TipoComprobante, 1); + SET @NumeroReservado = 1; + END + ELSE + BEGIN + SELECT @NumeroReservado = n FROM @_out; + END + + COMMIT; + END + """; + + await _connection.ExecuteAsync(createPdv); + await _connection.ExecuteAsync(createPdvIndex); + await _connection.ExecuteAsync(createSecuencia); + await _connection.ExecuteAsync(addPdvPeriod); + await _connection.ExecuteAsync(setPdvVersioning); + await _connection.ExecuteAsync(disableSecuenciaVersioning); + await _connection.ExecuteAsync(dropSecuenciaHistory); + await _connection.ExecuteAsync(dropSecuenciaPeriod); + await _connection.ExecuteAsync(dropSecuenciaValidCols); + await _connection.ExecuteAsync(dropSp); + await _connection.ExecuteAsync(createSp); + } + /// /// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset. /// From 4368c42599cbf93d2fb889439316d1d5c8ec5811 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 13:05:22 -0300 Subject: [PATCH 12/18] =?UTF-8?q?docs(adm-008):=20actualizar=202.5=20Audit?= =?UTF-8?q?or=C3=ADa=20+=20cerrar=20OQ-ADM-008=20+=20STATUS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vite/vitest/results.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 .vite/vitest/results.json diff --git a/.vite/vitest/results.json b/.vite/vitest/results.json new file mode 100644 index 0000000..c847d42 --- /dev/null +++ b/.vite/vitest/results.json @@ -0,0 +1 @@ +{"version":"2.1.9","results":[[":src/web/src/tests/stores/authStore.test.ts",{"duration":19.427999999999997,"failed":true}],[":src/web/src/tests/features/auth/ProtectedRoute.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/api/axiosClient.test.ts",{"duration":259.31550000000016,"failed":true}],[":src/web/src/tests/features/users/UserForm.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/UsersListPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/roles/RolForm.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/LoginPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/useLogin.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/UserEditPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/ResetPasswordModal.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/authApi.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/roles/RolesList.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/useCreateUser.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/components/routing/MustChangePasswordGate.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/CanPerform.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/usePermission.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/useUsersList.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/listUsers.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/updateUser.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/getUser.test.ts",{"duration":0,"failed":true}]]} \ No newline at end of file From 9263d9a178ad21369061cefbea5e195835e1d098 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 13:38:21 -0300 Subject: [PATCH 13/18] feat(web): panel de reserva de numeros en PdV detail (ADM-008) Gap detectado durante smoke: la DetailPage tenia los hooks useReservarNumero/useProximoNumero creados en Batch 6 pero faltaba el componente que los consume. SecuenciasPanel.tsx: tabla con los 6 tipos AFIP (FacturaA/B/C, NC A/B/C), proximo numero por tipo, boton Reservar. Toast con el numero reservado. Deshabilitado si PdV o Medio padre estan inactivos. Integrado en PuntoDeVentaDetailPage bajo guard de permiso. --- .../components/SecuenciasPanel.tsx | 96 +++++++++++++++++++ .../pages/PuntoDeVentaDetailPage.tsx | 8 ++ 2 files changed, 104 insertions(+) create mode 100644 src/web/src/features/puntos-de-venta/components/SecuenciasPanel.tsx diff --git a/src/web/src/features/puntos-de-venta/components/SecuenciasPanel.tsx b/src/web/src/features/puntos-de-venta/components/SecuenciasPanel.tsx new file mode 100644 index 0000000..9dfa2b0 --- /dev/null +++ b/src/web/src/features/puntos-de-venta/components/SecuenciasPanel.tsx @@ -0,0 +1,96 @@ +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { TipoComprobante } from '../types' +import { useReservarNumero, useProximoNumero } from '../hooks/useReservarNumero' + +const TIPOS: Array<{ value: TipoComprobante; label: string }> = [ + { value: TipoComprobante.FacturaA, label: 'Factura A' }, + { value: TipoComprobante.FacturaB, label: 'Factura B' }, + { value: TipoComprobante.FacturaC, label: 'Factura C' }, + { value: TipoComprobante.NotaCreditoA, label: 'Nota Crédito A' }, + { value: TipoComprobante.NotaCreditoB, label: 'Nota Crédito B' }, + { value: TipoComprobante.NotaCreditoC, label: 'Nota Crédito C' }, +] + +interface Props { + puntoDeVentaId: number + disabled: boolean +} + +export function SecuenciasPanel({ puntoDeVentaId, disabled }: Props) { + return ( +
+
+

Reserva de números de comprobante

+

+ Cada reserva incrementa el correlativo y devuelve el número asignado. +

+
+ + + + Tipo + Próximo número + Acción + + + + {TIPOS.map((tipo) => ( + + ))} + +
+
+ ) +} + +interface RowProps { + puntoDeVentaId: number + tipoValue: TipoComprobante + tipoLabel: string + disabled: boolean +} + +function SecuenciaRow({ puntoDeVentaId, tipoValue, tipoLabel, disabled }: RowProps) { + const proximo = useProximoNumero(puntoDeVentaId, tipoValue) + const reservar = useReservarNumero(puntoDeVentaId) + + const handleReservar = () => { + reservar.mutate(tipoValue, { + onSuccess: (data) => { + toast.success(`${tipoLabel}: número ${data.numeroReservado} reservado`) + }, + onError: (err: unknown) => { + const apiError = err as { response?: { data?: { error?: string } } } + const code = apiError.response?.data?.error ?? 'error' + toast.error(`No se pudo reservar: ${code}`) + }, + }) + } + + return ( + + {tipoLabel} + + {proximo.isLoading ? '…' : proximo.data?.proximoNumero ?? '—'} + + + + + + ) +} diff --git a/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx b/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx index bf7e68c..9d2f95a 100644 --- a/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx +++ b/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx @@ -7,6 +7,7 @@ import { useMedio } from '../../medios/hooks/useMedio' import { DeactivatePuntoDeVentaModal } from '../components/DeactivatePuntoDeVentaModal' import { MedioInactivoBanner } from '../components/MedioInactivoBanner' import { PdvInactivoBanner } from '../components/PdvInactivoBanner' +import { SecuenciasPanel } from '../components/SecuenciasPanel' function formatDate(iso: string | null): string { if (!iso) return '—' @@ -103,6 +104,13 @@ export function PuntoDeVentaDetailPage() { /> + + + + ) } From 40482caf7b90f6b784a44734d74ae6010b50d7de Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 14:16:01 -0300 Subject: [PATCH 14/18] =?UTF-8?q?revert(db):=20eliminar=20SecuenciaComprob?= =?UTF-8?q?ante=20+=20SP=20de=20V013=20=E2=80=94=20IMAC=20asigna=20numeros?= =?UTF-8?q?=20AFIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SecuenciaComprobante, usp_ReservarNumeroComprobante y TipoComprobante no tienen propósito de negocio: IMAC/Infogestión asigna NumeroFactura+CAI externamente. V013 ahora solo gestiona PuntoDeVenta + temporal table + permiso AFIP. Sección 0 aplica drops idempotentes para limpiar SIGCM2_Test y reinstalaciones. --- database/migrations/V013_ROLLBACK.sql | 68 +----- .../V013__create_puntos_de_venta.sql | 215 ++++-------------- 2 files changed, 56 insertions(+), 227 deletions(-) diff --git a/database/migrations/V013_ROLLBACK.sql b/database/migrations/V013_ROLLBACK.sql index 8226e72..5da66c8 100644 --- a/database/migrations/V013_ROLLBACK.sql +++ b/database/migrations/V013_ROLLBACK.sql @@ -1,12 +1,15 @@ -- V013_ROLLBACK.sql -- Reversa de V013__create_puntos_de_venta.sql. -- --- ADVERTENCIA: ejecutar ELIMINA PuntoDeVenta, SecuenciaComprobante, su historia temporal, --- el permiso 'administracion:puntos_de_venta:gestionar', sus asignaciones y el SP. +-- ADVERTENCIA: ejecutar ELIMINA PuntoDeVenta, su historia temporal, +-- el permiso 'administracion:puntos_de_venta:gestionar' y sus asignaciones. -- -- Uso intended: ROLLBACK en entornos NO-productivos. -- Prerequisito: no deben existir FKs vivas apuntando a PuntoDeVenta (p.ej., comprobantes FAC-001). -- Si FAC-001 ya está aplicado, este rollback fallará — usar backup. +-- +-- NOTA: SecuenciaComprobante y SP usp_ReservarNumeroComprobante ya no forman parte +-- de V013 (eliminados en cirugía post-smoke Batch 9). Este rollback solo maneja PuntoDeVenta. SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; @@ -14,52 +17,7 @@ SET NOCOUNT ON; GO -- ═══════════════════════════════════════════════════════════════════════ --- 1. Drop SP --- ═══════════════════════════════════════════════════════════════════════ - -IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL -BEGIN - DROP PROCEDURE dbo.usp_ReservarNumeroComprobante; - PRINT 'SP dbo.usp_ReservarNumeroComprobante dropped.'; -END -GO - --- ═══════════════════════════════════════════════════════════════════════ --- 2. Apagar SYSTEM_VERSIONING + remover PERIOD — SecuenciaComprobante primero (FK a PdV) --- ═══════════════════════════════════════════════════════════════════════ - -IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2) -BEGIN - ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF); - PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING OFF.'; -END -GO - -IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante')) -BEGIN - ALTER TABLE dbo.SecuenciaComprobante DROP PERIOD FOR SYSTEM_TIME; - PRINT 'SecuenciaComprobante: PERIOD FOR SYSTEM_TIME dropped.'; -END -GO - -IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NOT NULL -BEGIN - ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT IF EXISTS DF_SecuenciaComprobante_ValidFrom; - ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT IF EXISTS DF_SecuenciaComprobante_ValidTo; - ALTER TABLE dbo.SecuenciaComprobante DROP COLUMN ValidFrom, ValidTo; - PRINT 'SecuenciaComprobante: ValidFrom/ValidTo dropped.'; -END -GO - -IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL -BEGIN - DROP TABLE dbo.SecuenciaComprobante_History; - PRINT 'SecuenciaComprobante_History dropped.'; -END -GO - --- ═══════════════════════════════════════════════════════════════════════ --- 3. Apagar SYSTEM_VERSIONING + remover PERIOD — PuntoDeVenta +-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD — PuntoDeVenta -- ═══════════════════════════════════════════════════════════════════════ IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2) @@ -93,16 +51,9 @@ END GO -- ═══════════════════════════════════════════════════════════════════════ --- 4. Drop tablas (SecuenciaComprobante primero por FK) +-- 2. Drop tabla PuntoDeVenta -- ═══════════════════════════════════════════════════════════════════════ -IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NOT NULL -BEGIN - DROP TABLE dbo.SecuenciaComprobante; - PRINT 'Table dbo.SecuenciaComprobante dropped.'; -END -GO - IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NOT NULL BEGIN DROP TABLE dbo.PuntoDeVenta; @@ -111,7 +62,7 @@ END GO -- ═══════════════════════════════════════════════════════════════════════ --- 5. Remover permiso 'administracion:puntos_de_venta:gestionar' + RolPermiso +-- 3. Remover permiso 'administracion:puntos_de_venta:gestionar' + RolPermiso -- ═══════════════════════════════════════════════════════════════════════ DELETE rp @@ -125,7 +76,6 @@ WHERE Codigo = 'administracion:puntos_de_venta:gestionar'; GO PRINT ''; -PRINT 'V013 rolled back. dbo.PuntoDeVenta, dbo.SecuenciaComprobante and their history removed.'; -PRINT 'SP dbo.usp_ReservarNumeroComprobante removed.'; +PRINT 'V013 rolled back. dbo.PuntoDeVenta and its history removed.'; PRINT 'Permiso administracion:puntos_de_venta:gestionar removed.'; GO diff --git a/database/migrations/V013__create_puntos_de_venta.sql b/database/migrations/V013__create_puntos_de_venta.sql index d0c8738..dd2bcbc 100644 --- a/database/migrations/V013__create_puntos_de_venta.sql +++ b/database/migrations/V013__create_puntos_de_venta.sql @@ -1,11 +1,16 @@ -- V013__create_puntos_de_venta.sql --- ADM-008 Puntos de Venta: DDL + SP de reserva atómica de número de comprobante. +-- ADM-008 Puntos de Venta: DDL para dbo.PuntoDeVenta + permiso AFIP. +-- +-- NOTA POST-SMOKE (Batch 9): SecuenciaComprobante, SP usp_ReservarNumeroComprobante +-- y TipoComprobante fueron eliminados. SIG-CM2.0 NO genera números AFIP — IMAC +-- (Plataforma Infogestión) los asigna externamente. Un worker futuro (INT-001) +-- polleará la vista de Infogestión para asociar NumeroOrdenInterno ↔ NumeroFacturaAFIP + CAI. +-- PuntoDeVenta.NumeroAFIP es config fija que se manda en el payload a IMAC. -- -- Cambios: -- 1. dbo.PuntoDeVenta (FK→Medio, UNIQUE(MedioId,NumeroAFIP), SYSTEM_VERSIONING ON, retention 10Y). --- 2. dbo.SecuenciaComprobante (FK→PuntoDeVenta, PK(PuntoDeVentaId,TipoComprobante), SYSTEM_VERSIONING ON, retention 10Y). --- 3. Permiso 'administracion:puntos-de-venta:gestionar' + asignación a rol 'admin'. --- 4. SP dbo.usp_ReservarNumeroComprobante (SERIALIZABLE, UPDATE+OUTPUT, THROW 50001/50002/50003). +-- 2. Drops idempotentes de artefactos de versión previa (SecuenciaComprobante + SP). +-- 3. Permiso 'administracion:puntos_de_venta:gestionar' + asignación a rol 'admin'. -- -- Patrón: V011 (Temporal Tables + Permiso MERGE + PAGE compression en history). -- Idempotente: seguro para re-ejecutar. @@ -18,7 +23,7 @@ -- solo permite [a-z0-9_:] — se usa guion_bajo para cumplir la constraint existente. -- El backend y frontend deben usar el código con guion_bajo. -- --- Covers: REQ-PDV-001, -003, -009, REQ-SEC-CMB-001, -002, -003, -004 +-- Covers: REQ-PDV-001, -003, -009 -- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.10 ADM-008 -- -- NOTA T1.3 — Seeds: NO se seedean PuntoDeVenta. @@ -30,6 +35,39 @@ SET ANSI_NULLS ON; SET NOCOUNT ON; GO +-- ═══════════════════════════════════════════════════════════════════════ +-- 0. Drops idempotentes de artefactos de versión previa +-- (SecuenciaComprobante + SP — eliminados en cirugía post-smoke Batch 9) +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ReservarNumeroComprobante; + PRINT 'SP dbo.usp_ReservarNumeroComprobante dropped (cleanup).'; +END +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF); + PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = OFF (cleanup).'; +END +GO + +IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.SecuenciaComprobante_History; + PRINT 'SecuenciaComprobante_History dropped (cleanup).'; +END +GO + +IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.SecuenciaComprobante; + PRINT 'Table dbo.SecuenciaComprobante dropped (cleanup).'; +END +GO + -- ═══════════════════════════════════════════════════════════════════════ -- 1. dbo.PuntoDeVenta -- ═══════════════════════════════════════════════════════════════════════ @@ -65,30 +103,7 @@ END GO -- ═══════════════════════════════════════════════════════════════════════ --- 2. dbo.SecuenciaComprobante --- ═══════════════════════════════════════════════════════════════════════ - -IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NULL -BEGIN - CREATE TABLE dbo.SecuenciaComprobante ( - PuntoDeVentaId INT NOT NULL, - TipoComprobante TINYINT NOT NULL, - UltimoNumero INT NOT NULL CONSTRAINT DF_SecuenciaComprobante_UltimoNumero DEFAULT(0), - FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_SecuenciaComprobante_FechaCreacion DEFAULT(SYSUTCDATETIME()), - FechaModificacion DATETIME2(3) NULL, - CONSTRAINT PK_SecuenciaComprobante PRIMARY KEY (PuntoDeVentaId, TipoComprobante), - CONSTRAINT FK_SecuenciaComprobante_PuntoDeVenta FOREIGN KEY (PuntoDeVentaId) REFERENCES dbo.PuntoDeVenta(Id) ON DELETE NO ACTION, - CONSTRAINT CK_SecuenciaComprobante_TipoComprobante CHECK (TipoComprobante BETWEEN 1 AND 6), - CONSTRAINT CK_SecuenciaComprobante_UltimoNumero CHECK (UltimoNumero >= 0) - ); - PRINT 'Table dbo.SecuenciaComprobante created.'; -END -ELSE - PRINT 'Table dbo.SecuenciaComprobante already exists — skip.'; -GO - --- ═══════════════════════════════════════════════════════════════════════ --- 3. SYSTEM_VERSIONING — PuntoDeVenta +-- 2. SYSTEM_VERSIONING — PuntoDeVenta -- ═══════════════════════════════════════════════════════════════════════ IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NULL @@ -130,55 +145,7 @@ END GO -- ═══════════════════════════════════════════════════════════════════════ --- 4. SecuenciaComprobante — SIN SYSTEM_VERSIONING (decisión AD8 revisitada) --- ═══════════════════════════════════════════════════════════════════════ --- Razón: bajo reservas concurrentes (reportado 50 threads paralelos) el engine --- arroja "Data modification failed on system-versioned table because transaction --- time was earlier than period start" — UPDATEs repetidos sobre la misma fila --- en transacciones SERIALIZABLE concurrentes violan la invariante temporal. --- Ya anticipado en el design (AD8): la reserva es operacional, no configuracion. --- La auditoria del numero vigente no tiene valor de negocio: el estado actual --- (UltimoNumero) es la unica informacion relevante; cada comprobante emitido --- deja su propio rastro en AvisosCdo/CtaCte (modulos FAC-*). --- --- Esta seccion es idempotente: si una version previa de la migracion dejo --- SYSTEM_VERSIONING = ON, lo desactiva y drop la history. En instalacion nueva --- no hace nada porque nunca se activo. - -IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2) -BEGIN - ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF); - PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = OFF (revisited AD8).'; -END -GO - -IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL -BEGIN - DROP TABLE dbo.SecuenciaComprobante_History; - PRINT 'SecuenciaComprobante_History: dropped.'; -END -GO - -IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante')) -BEGIN - ALTER TABLE dbo.SecuenciaComprobante DROP PERIOD FOR SYSTEM_TIME; - PRINT 'SecuenciaComprobante: PERIOD FOR SYSTEM_TIME dropped.'; -END -GO - -IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NOT NULL -BEGIN - IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidFrom' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante')) - ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidFrom; - IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidTo' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante')) - ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidTo; - ALTER TABLE dbo.SecuenciaComprobante DROP COLUMN ValidFrom, ValidTo; - PRINT 'SecuenciaComprobante: ValidFrom/ValidTo + default constraints dropped.'; -END -GO - --- ═══════════════════════════════════════════════════════════════════════ --- 5. Permiso: administracion:puntos-de-venta:gestionar +-- 3. Permiso: administracion:puntos_de_venta:gestionar -- ═══════════════════════════════════════════════════════════════════════ MERGE dbo.Permiso AS t @@ -204,97 +171,9 @@ WHEN NOT MATCHED BY TARGET THEN INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId); GO --- ═══════════════════════════════════════════════════════════════════════ --- 6. SP: dbo.usp_ReservarNumeroComprobante --- ═══════════════════════════════════════════════════════════════════════ --- Decisión AD1: SP con UPDATE+OUTPUT bajo SERIALIZABLE (lógica crítica en SP; --- permite validar PdV/Medio activos en DB; atómico sin race). --- Decisión AD2: patrón "UPDATE si existe, INSERT si @@ROWCOUNT=0" (lazy init; --- sin trigger; simple; no genera filas huérfanas). --- El retry ante deadlock (SqlException 1205) vive en el Application handler --- (ReservarNumeroCommandHandler), NO en este SP. Máx 3 intentos, 50/150/450ms. --- NO envolver la llamada en TransactionScope externo: el SP ya es atómico. --- Un TransactionScope externo con otra conexión escalaría a MSDTC innecesariamente. - -IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL - DROP PROCEDURE dbo.usp_ReservarNumeroComprobante; -GO - -CREATE PROCEDURE dbo.usp_ReservarNumeroComprobante - @PuntoDeVentaId INT, - @TipoComprobante TINYINT, - @NumeroReservado INT OUTPUT -AS -BEGIN - SET NOCOUNT ON; - SET XACT_ABORT ON; - SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; - - BEGIN TRAN; - - -- Validar existencia y estado de PdV + Medio padre en una sola lectura. - -- Con SERIALIZABLE, el lock de rango protege contra inserciones concurrentes - -- de la misma fila de SecuenciaComprobante entre el SELECT y el UPDATE/INSERT. - DECLARE @PdvActivo BIT; - DECLARE @MedioActivo BIT; - - SELECT - @PdvActivo = p.Activo, - @MedioActivo = m.Activo - FROM dbo.PuntoDeVenta p - JOIN dbo.Medio m ON m.Id = p.MedioId - WHERE p.Id = @PuntoDeVentaId; - - IF @PdvActivo IS NULL - BEGIN - ROLLBACK; - THROW 50003, 'punto_de_venta_not_found', 1; - END - - IF @PdvActivo = 0 - BEGIN - ROLLBACK; - THROW 50001, 'punto_de_venta_inactivo', 1; - END - - IF @MedioActivo = 0 - BEGIN - ROLLBACK; - THROW 50002, 'medio_inactivo', 1; - END - - -- Intentar actualizar la fila existente y capturar el nuevo número. - DECLARE @_out TABLE (n INT NOT NULL); - - UPDATE dbo.SecuenciaComprobante - SET - UltimoNumero = UltimoNumero + 1, - FechaModificacion = SYSUTCDATETIME() - OUTPUT inserted.UltimoNumero INTO @_out(n) - WHERE PuntoDeVentaId = @PuntoDeVentaId - AND TipoComprobante = @TipoComprobante; - - IF @@ROWCOUNT = 0 - BEGIN - -- Primera reserva para este par (PdvId, TipoComprobante): inicialización lazy. - INSERT INTO dbo.SecuenciaComprobante (PuntoDeVentaId, TipoComprobante, UltimoNumero) - VALUES (@PuntoDeVentaId, @TipoComprobante, 1); - SET @NumeroReservado = 1; - END - ELSE - BEGIN - SELECT @NumeroReservado = n FROM @_out; - END - - COMMIT; -END -GO - PRINT ''; PRINT 'V013 applied successfully.'; PRINT ' - dbo.PuntoDeVenta (temporal, retention 10y, PAGE compression)'; -PRINT ' - dbo.SecuenciaComprobante (temporal, retention 10y, PAGE compression)'; -PRINT ' - Permiso administracion:puntos-de-venta:gestionar (asignado a admin)'; -PRINT ' - SP dbo.usp_ReservarNumeroComprobante'; -PRINT 'Next: Batch 2 — Domain (PuntoDeVenta entity + exceptions + TipoComprobante enum).'; +PRINT ' - Permiso administracion:puntos_de_venta:gestionar (asignado a admin)'; +PRINT ' - Artefactos de version previa (SecuenciaComprobante + SP) eliminados si existian'; GO From 7d432a949a96f1207a255eb17c9f8ede74fd7547 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 14:16:09 -0300 Subject: [PATCH 15/18] revert(backend): eliminar handlers/endpoints/excepciones de reserva de numero ADM-008 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminar SecuenciaComprobante entity, TipoComprobante enum, DeadlockTransientException, PuntoDeVentaInactivoException, carpetas Reservar/ y ProximoNumero/ de Application, métodos ReservarNumeroAsync/GetUltimoNumeroAsync del repositorio, endpoints POST /secuencias/.../reservar y GET /secuencias/.../proximo del controller, y mapping PuntoDeVentaInactivoException del ExceptionFilter. --- .../Controllers/PuntosDeVentaController.cs | 31 ----------- src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 12 ----- .../Persistence/IPuntoDeVentaRepository.cs | 3 -- .../SIGCM2.Application/DependencyInjection.cs | 4 -- .../ProximoNumero/GetProximoNumeroQuery.cs | 5 -- .../GetProximoNumeroQueryHandler.cs | 27 ---------- .../ProximoNumero/ProximoNumeroDto.cs | 5 -- .../Reservar/ReservaNumeroDto.cs | 5 -- .../Reservar/ReservarNumeroCommand.cs | 5 -- .../Reservar/ReservarNumeroCommandHandler.cs | 53 ------------------- .../Entities/SecuenciaComprobante.cs | 34 ------------ .../SIGCM2.Domain/Enums/TipoComprobante.cs | 16 ------ .../Exceptions/DeadlockTransientException.cs | 14 ----- .../PuntoDeVentaInactivoException.cs | 15 ------ .../Persistence/PuntoDeVentaRepository.cs | 52 ------------------ 15 files changed, 281 deletions(-) delete mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQuery.cs delete mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandler.cs delete mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/ProximoNumeroDto.cs delete mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservaNumeroDto.cs delete mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommand.cs delete mode 100644 src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs delete mode 100644 src/api/SIGCM2.Domain/Entities/SecuenciaComprobante.cs delete mode 100644 src/api/SIGCM2.Domain/Enums/TipoComprobante.cs delete mode 100644 src/api/SIGCM2.Domain/Exceptions/DeadlockTransientException.cs delete mode 100644 src/api/SIGCM2.Domain/Exceptions/PuntoDeVentaInactivoException.cs diff --git a/src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs b/src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs index cc3b2c2..0ebffb9 100644 --- a/src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs +++ b/src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs @@ -7,11 +7,8 @@ using SIGCM2.Application.PuntosDeVenta.Create; using SIGCM2.Application.PuntosDeVenta.Deactivate; using SIGCM2.Application.PuntosDeVenta.GetById; using SIGCM2.Application.PuntosDeVenta.List; -using SIGCM2.Application.PuntosDeVenta.ProximoNumero; using SIGCM2.Application.PuntosDeVenta.Reactivate; -using SIGCM2.Application.PuntosDeVenta.Reservar; using SIGCM2.Application.PuntosDeVenta.Update; -using SIGCM2.Domain.Enums; namespace SIGCM2.Api.Controllers; @@ -160,34 +157,6 @@ public sealed class PuntosDeVentaController : ControllerBase return NoContent(); } - /// Reserves the next sequential number for a given PdV and TipoComprobante. - [HttpPost("{id:int}/secuencias/{tipoComprobante}/reservar")] - [RequirePermission("administracion:puntos_de_venta:gestionar")] - [ProducesResponseType(typeof(ReservaNumeroDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status409Conflict)] - public async Task ReservarNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante) - { - var command = new ReservarNumeroCommand(id, tipoComprobante); - var result = await _dispatcher.Send(command); - return Ok(result); - } - - /// Returns the next available number (read-only, no reservation). - [HttpGet("{id:int}/secuencias/{tipoComprobante}/proximo")] - [RequirePermission("administracion:puntos_de_venta:gestionar")] - [ProducesResponseType(typeof(ProximoNumeroDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetProximoNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante) - { - var query = new GetProximoNumeroQuery(id, tipoComprobante); - var result = await _dispatcher.Send(query); - return Ok(result); - } } // ── Request body records ────────────────────────────────────────────────────── diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index 35fb5be..b86c0e2 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -244,18 +244,6 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; - case PuntoDeVentaInactivoException puntoDeVentaInactivoEx: - context.Result = new ObjectResult(new - { - error = "punto_de_venta_inactivo", - message = puntoDeVentaInactivoEx.Message - }) - { - StatusCode = StatusCodes.Status409Conflict - }; - context.ExceptionHandled = true; - break; - case NumeroAFIPDuplicadoException numeroAFIPDupEx: context.Result = new ObjectResult(new { diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IPuntoDeVentaRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IPuntoDeVentaRepository.cs index 5d5cabd..eb6cf04 100644 --- a/src/api/SIGCM2.Application/Abstractions/Persistence/IPuntoDeVentaRepository.cs +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IPuntoDeVentaRepository.cs @@ -1,6 +1,5 @@ using SIGCM2.Application.Common; using SIGCM2.Domain.Entities; -using SIGCM2.Domain.Enums; namespace SIGCM2.Application.Abstractions.Persistence; @@ -11,6 +10,4 @@ public interface IPuntoDeVentaRepository Task ExistsByNumeroAFIPInMedioAsync(int medioId, short numeroAFIP, int? excludeId = null, CancellationToken ct = default); Task UpdateAsync(PuntoDeVenta pdv, CancellationToken ct = default); Task> GetPagedAsync(PuntosDeVentaQuery q, CancellationToken ct = default); - Task ReservarNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default); - Task GetUltimoNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default); } diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 83e0276..cda65d3 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -25,9 +25,7 @@ using SIGCM2.Application.PuntosDeVenta.Create; using SIGCM2.Application.PuntosDeVenta.Deactivate; using SIGCM2.Application.PuntosDeVenta.GetById; using SIGCM2.Application.PuntosDeVenta.List; -using SIGCM2.Application.PuntosDeVenta.ProximoNumero; using SIGCM2.Application.PuntosDeVenta.Reactivate; -using SIGCM2.Application.PuntosDeVenta.Reservar; using SIGCM2.Application.PuntosDeVenta.Update; using SIGCM2.Application.Secciones.Create; using SIGCM2.Application.Secciones.Deactivate; @@ -105,8 +103,6 @@ public static class DependencyInjection services.AddScoped, ReactivatePuntoDeVentaCommandHandler>(); services.AddScoped>, ListPuntosDeVentaQueryHandler>(); services.AddScoped, GetPuntoDeVentaByIdQueryHandler>(); - services.AddScoped, ReservarNumeroCommandHandler>(); - services.AddScoped, GetProximoNumeroQueryHandler>(); // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQuery.cs b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQuery.cs deleted file mode 100644 index 7fb0ada..0000000 --- a/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using SIGCM2.Domain.Enums; - -namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero; - -public sealed record GetProximoNumeroQuery(int PuntoDeVentaId, TipoComprobante TipoComprobante); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandler.cs deleted file mode 100644 index e0c930c..0000000 --- a/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandler.cs +++ /dev/null @@ -1,27 +0,0 @@ -using SIGCM2.Application.Abstractions; -using SIGCM2.Application.Abstractions.Persistence; - -namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero; - -/// -/// Consulta el próximo número disponible sin reservarlo (read-only, REQ-SEC-CMB-005). -/// Retorna UltimoNumero+1; si no existe fila devuelve 1. -/// -public sealed class GetProximoNumeroQueryHandler : ICommandHandler -{ - private readonly IPuntoDeVentaRepository _repo; - - public GetProximoNumeroQueryHandler(IPuntoDeVentaRepository repo) - { - _repo = repo; - } - - public async Task Handle(GetProximoNumeroQuery query) - { - var ultimoNumero = await _repo.GetUltimoNumeroAsync(query.PuntoDeVentaId, query.TipoComprobante); - - var proximo = ultimoNumero.HasValue ? ultimoNumero.Value + 1 : 1; - - return new ProximoNumeroDto(query.TipoComprobante, proximo); - } -} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/ProximoNumeroDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/ProximoNumeroDto.cs deleted file mode 100644 index 3b2fb8f..0000000 --- a/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/ProximoNumeroDto.cs +++ /dev/null @@ -1,5 +0,0 @@ -using SIGCM2.Domain.Enums; - -namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero; - -public sealed record ProximoNumeroDto(TipoComprobante TipoComprobante, int ProximoNumero); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservaNumeroDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservaNumeroDto.cs deleted file mode 100644 index 1544c7e..0000000 --- a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservaNumeroDto.cs +++ /dev/null @@ -1,5 +0,0 @@ -using SIGCM2.Domain.Enums; - -namespace SIGCM2.Application.PuntosDeVenta.Reservar; - -public sealed record ReservaNumeroDto(TipoComprobante TipoComprobante, int NumeroReservado); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommand.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommand.cs deleted file mode 100644 index 44d4918..0000000 --- a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommand.cs +++ /dev/null @@ -1,5 +0,0 @@ -using SIGCM2.Domain.Enums; - -namespace SIGCM2.Application.PuntosDeVenta.Reservar; - -public sealed record ReservarNumeroCommand(int PuntoDeVentaId, TipoComprobante TipoComprobante); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs deleted file mode 100644 index 86896e7..0000000 --- a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs +++ /dev/null @@ -1,53 +0,0 @@ -using SIGCM2.Application.Abstractions; -using SIGCM2.Application.Abstractions.Persistence; -using SIGCM2.Domain.Exceptions; - -namespace SIGCM2.Application.PuntosDeVenta.Reservar; - -/// -/// Reserva el próximo número correlativo para (PdvId × TipoComprobante) ejecutando -/// usp_ReservarNumeroComprobante vía el repositorio. -/// -/// NOTAS DE DISEÑO (AD4, AD9): -/// - NO se envuelve en TransactionScope: el SP ya es atómico bajo SERIALIZABLE. -/// Un TransactionScope ambiente aquí escalaría a DTC → innecesario. -/// - NO usa Polly: no está en el proyecto. Retry deadlock con bucle simple. -/// - Infrastructure traduce SqlException 1205 → DeadlockTransientException. -/// - Backoff en ms: [25, 75, 200, 500, 1200] — 5 retries máximo (6 intentos totales). -/// - La auditoría de reservas corre solo vía Temporal Tables (AD8). -/// -public sealed class ReservarNumeroCommandHandler : ICommandHandler -{ - private readonly IPuntoDeVentaRepository _repo; - private readonly int[] _deadlockBackoffMs; - - private static readonly int[] DefaultBackoffMs = [25, 75, 200, 500, 1200]; - - public ReservarNumeroCommandHandler(IPuntoDeVentaRepository repo) - : this(repo, DefaultBackoffMs) { } - - /// Constructor with custom backoff for testing (e.g., [0,0,0] for fast tests). - public ReservarNumeroCommandHandler(IPuntoDeVentaRepository repo, int[] deadlockBackoffMs) - { - _repo = repo; - _deadlockBackoffMs = deadlockBackoffMs; - } - - public async Task Handle(ReservarNumeroCommand command) - { - for (var i = 0; ; i++) - { - try - { - var numero = await _repo.ReservarNumeroAsync(command.PuntoDeVentaId, command.TipoComprobante); - return new ReservaNumeroDto(command.TipoComprobante, numero); - } - catch (DeadlockTransientException) when (i < _deadlockBackoffMs.Length) - { - // Deadlock — retry with backoff - await Task.Delay(_deadlockBackoffMs[i]); - } - // All other exceptions bubble up immediately - } - } -} diff --git a/src/api/SIGCM2.Domain/Entities/SecuenciaComprobante.cs b/src/api/SIGCM2.Domain/Entities/SecuenciaComprobante.cs deleted file mode 100644 index c936c67..0000000 --- a/src/api/SIGCM2.Domain/Entities/SecuenciaComprobante.cs +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index e1be111..0000000 --- a/src/api/SIGCM2.Domain/Enums/TipoComprobante.cs +++ /dev/null @@ -1,16 +0,0 @@ -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/DeadlockTransientException.cs b/src/api/SIGCM2.Domain/Exceptions/DeadlockTransientException.cs deleted file mode 100644 index fef02a6..0000000 --- a/src/api/SIGCM2.Domain/Exceptions/DeadlockTransientException.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace SIGCM2.Domain.Exceptions; - -/// -/// Thrown by Infrastructure when a database deadlock (SQL 1205) is detected. -/// Allows Application handlers to retry without referencing SqlClient. -/// -public sealed class DeadlockTransientException : DomainException -{ - public DeadlockTransientException() - : base("Se detectó un deadlock en la base de datos. Reintentando operación.") { } - - public DeadlockTransientException(Exception innerException) - : base("Se detectó un deadlock en la base de datos. Reintentando operación.", innerException) { } -} diff --git a/src/api/SIGCM2.Domain/Exceptions/PuntoDeVentaInactivoException.cs b/src/api/SIGCM2.Domain/Exceptions/PuntoDeVentaInactivoException.cs deleted file mode 100644 index 3405365..0000000 --- a/src/api/SIGCM2.Domain/Exceptions/PuntoDeVentaInactivoException.cs +++ /dev/null @@ -1,15 +0,0 @@ -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.Infrastructure/Persistence/PuntoDeVentaRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/PuntoDeVentaRepository.cs index 657f573..d276536 100644 --- a/src/api/SIGCM2.Infrastructure/Persistence/PuntoDeVentaRepository.cs +++ b/src/api/SIGCM2.Infrastructure/Persistence/PuntoDeVentaRepository.cs @@ -1,11 +1,9 @@ -using System.Data; using System.Text; using Dapper; using Microsoft.Data.SqlClient; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Common; using SIGCM2.Domain.Entities; -using SIGCM2.Domain.Enums; using SIGCM2.Domain.Exceptions; namespace SIGCM2.Infrastructure.Persistence; @@ -153,56 +151,6 @@ public sealed class PuntoDeVentaRepository : IPuntoDeVentaRepository return new PagedResult(items, page, pageSize, total); } - public async Task ReservarNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default) - { - var parameters = new DynamicParameters(); - parameters.Add("PuntoDeVentaId", puntoDeVentaId, DbType.Int32); - parameters.Add("TipoComprobante", (byte)tipo, DbType.Byte); - parameters.Add("NumeroReservado", dbType: DbType.Int32, direction: ParameterDirection.Output); - - await using var connection = _connectionFactory.CreateConnection(); - await connection.OpenAsync(ct); - - try - { - await connection.ExecuteAsync( - "dbo.usp_ReservarNumeroComprobante", - parameters, - commandType: CommandType.StoredProcedure); - } - catch (SqlException ex) - { - throw ex.Number switch - { - 50001 => new PuntoDeVentaInactivoException(puntoDeVentaId), - 50002 => new MedioInactivoException(puntoDeVentaId), - 50003 => new PuntoDeVentaNotFoundException(puntoDeVentaId), - 1205 => new DeadlockTransientException(ex), - _ => ex, - }; - } - - return parameters.Get("NumeroReservado"); - } - - public async Task GetUltimoNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default) - { - const string sql = """ - SELECT UltimoNumero - FROM dbo.SecuenciaComprobante - WHERE PuntoDeVentaId = @PuntoDeVentaId AND TipoComprobante = @TipoComprobante - """; - - await using var connection = _connectionFactory.CreateConnection(); - await connection.OpenAsync(ct); - - return await connection.QuerySingleOrDefaultAsync(sql, new - { - PuntoDeVentaId = puntoDeVentaId, - TipoComprobante = (byte)tipo, - }); - } - // ── mapping ─────────────────────────────────────────────────────────────── private static PuntoDeVenta MapRow(PdvRow r) From 6be637b4cf361f918ffc4028a72b3dc9715a959c Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 14:16:14 -0300 Subject: [PATCH 16/18] revert(web): eliminar feature de reserva de numero en UI ADM-008 Eliminar secuencias.api.ts, useReservarNumero.ts, SecuenciasPanel.tsx, TipoComprobante enum y tipos ReservarNumeroResponse/ProximoNumeroResponse. Quitar SecuenciasPanel del PuntoDeVentaDetailPage. --- .../puntos-de-venta/api/secuencias.api.ts | 22 ----- .../components/SecuenciasPanel.tsx | 96 ------------------- .../hooks/useReservarNumero.ts | 30 ------ .../pages/PuntoDeVentaDetailPage.tsx | 7 -- src/web/src/features/puntos-de-venta/types.ts | 22 +---- 5 files changed, 3 insertions(+), 174 deletions(-) delete mode 100644 src/web/src/features/puntos-de-venta/api/secuencias.api.ts delete mode 100644 src/web/src/features/puntos-de-venta/components/SecuenciasPanel.tsx delete mode 100644 src/web/src/features/puntos-de-venta/hooks/useReservarNumero.ts diff --git a/src/web/src/features/puntos-de-venta/api/secuencias.api.ts b/src/web/src/features/puntos-de-venta/api/secuencias.api.ts deleted file mode 100644 index 0368a6c..0000000 --- a/src/web/src/features/puntos-de-venta/api/secuencias.api.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { axiosClient } from '@/api/axiosClient' -import type { TipoComprobante, ReservarNumeroResponse, ProximoNumeroResponse } from '../types' - -export async function reservarNumero( - puntoDeVentaId: number, - tipoComprobante: TipoComprobante, -): Promise { - const response = await axiosClient.post( - `/api/v1/admin/puntos-de-venta/${puntoDeVentaId}/secuencias/${tipoComprobante}/reservar`, - ) - return response.data -} - -export async function getProximoNumero( - puntoDeVentaId: number, - tipoComprobante: TipoComprobante, -): Promise { - const response = await axiosClient.get( - `/api/v1/admin/puntos-de-venta/${puntoDeVentaId}/secuencias/${tipoComprobante}/proximo`, - ) - return response.data -} diff --git a/src/web/src/features/puntos-de-venta/components/SecuenciasPanel.tsx b/src/web/src/features/puntos-de-venta/components/SecuenciasPanel.tsx deleted file mode 100644 index 9dfa2b0..0000000 --- a/src/web/src/features/puntos-de-venta/components/SecuenciasPanel.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { TipoComprobante } from '../types' -import { useReservarNumero, useProximoNumero } from '../hooks/useReservarNumero' - -const TIPOS: Array<{ value: TipoComprobante; label: string }> = [ - { value: TipoComprobante.FacturaA, label: 'Factura A' }, - { value: TipoComprobante.FacturaB, label: 'Factura B' }, - { value: TipoComprobante.FacturaC, label: 'Factura C' }, - { value: TipoComprobante.NotaCreditoA, label: 'Nota Crédito A' }, - { value: TipoComprobante.NotaCreditoB, label: 'Nota Crédito B' }, - { value: TipoComprobante.NotaCreditoC, label: 'Nota Crédito C' }, -] - -interface Props { - puntoDeVentaId: number - disabled: boolean -} - -export function SecuenciasPanel({ puntoDeVentaId, disabled }: Props) { - return ( -
-
-

Reserva de números de comprobante

-

- Cada reserva incrementa el correlativo y devuelve el número asignado. -

-
- - - - Tipo - Próximo número - Acción - - - - {TIPOS.map((tipo) => ( - - ))} - -
-
- ) -} - -interface RowProps { - puntoDeVentaId: number - tipoValue: TipoComprobante - tipoLabel: string - disabled: boolean -} - -function SecuenciaRow({ puntoDeVentaId, tipoValue, tipoLabel, disabled }: RowProps) { - const proximo = useProximoNumero(puntoDeVentaId, tipoValue) - const reservar = useReservarNumero(puntoDeVentaId) - - const handleReservar = () => { - reservar.mutate(tipoValue, { - onSuccess: (data) => { - toast.success(`${tipoLabel}: número ${data.numeroReservado} reservado`) - }, - onError: (err: unknown) => { - const apiError = err as { response?: { data?: { error?: string } } } - const code = apiError.response?.data?.error ?? 'error' - toast.error(`No se pudo reservar: ${code}`) - }, - }) - } - - return ( - - {tipoLabel} - - {proximo.isLoading ? '…' : proximo.data?.proximoNumero ?? '—'} - - - - - - ) -} diff --git a/src/web/src/features/puntos-de-venta/hooks/useReservarNumero.ts b/src/web/src/features/puntos-de-venta/hooks/useReservarNumero.ts deleted file mode 100644 index dad0444..0000000 --- a/src/web/src/features/puntos-de-venta/hooks/useReservarNumero.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { reservarNumero, getProximoNumero } from '../api/secuencias.api' -import type { TipoComprobante } from '../types' - -// ─── Reservar ──────────────────────────────────────────────────────────────── - -export function useReservarNumero(puntoDeVentaId: number) { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (tipoComprobante: TipoComprobante) => - reservarNumero(puntoDeVentaId, tipoComprobante), - onSuccess: (_data, tipoComprobante) => { - // Invalidate the proximo query for this pdv+tipo so it refetches - queryClient.invalidateQueries({ - queryKey: ['puntos-de-venta', 'proximo', puntoDeVentaId, tipoComprobante], - }) - }, - }) -} - -// ─── Próximo número (read-only) ────────────────────────────────────────────── - -export function useProximoNumero(puntoDeVentaId: number, tipoComprobante: TipoComprobante) { - return useQuery({ - queryKey: ['puntos-de-venta', 'proximo', puntoDeVentaId, tipoComprobante], - queryFn: () => getProximoNumero(puntoDeVentaId, tipoComprobante), - enabled: !!puntoDeVentaId, - staleTime: 5_000, - }) -} diff --git a/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx b/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx index 9d2f95a..f948c47 100644 --- a/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx +++ b/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx @@ -7,7 +7,6 @@ import { useMedio } from '../../medios/hooks/useMedio' import { DeactivatePuntoDeVentaModal } from '../components/DeactivatePuntoDeVentaModal' import { MedioInactivoBanner } from '../components/MedioInactivoBanner' import { PdvInactivoBanner } from '../components/PdvInactivoBanner' -import { SecuenciasPanel } from '../components/SecuenciasPanel' function formatDate(iso: string | null): string { if (!iso) return '—' @@ -105,12 +104,6 @@ export function PuntoDeVentaDetailPage() { - - - ) } diff --git a/src/web/src/features/puntos-de-venta/types.ts b/src/web/src/features/puntos-de-venta/types.ts index 83858f0..b4fb6f2 100644 --- a/src/web/src/features/puntos-de-venta/types.ts +++ b/src/web/src/features/puntos-de-venta/types.ts @@ -1,13 +1,7 @@ // ADM-008 — shared types for puntos-de-venta feature - -export enum TipoComprobante { - FacturaA = 1, - FacturaB = 2, - FacturaC = 3, - NotaCreditoA = 4, - NotaCreditoB = 5, - NotaCreditoC = 6, -} +// NOTE: numeración AFIP (NumeroFactura, CAI) es asignada externamente por IMAC/Infogestión. +// Un worker futuro (INT-001) polleará la vista de Infogestión para asociar +// NumeroOrdenInterno ↔ NumeroFacturaAFIP + CAI. No se generan números aquí. export interface PuntoDeVentaListItem { id: number @@ -53,16 +47,6 @@ export interface PuntosDeVentaQuery { activo?: boolean } -export interface ReservarNumeroResponse { - tipoComprobante: TipoComprobante - numeroReservado: number -} - -export interface ProximoNumeroResponse { - tipoComprobante: TipoComprobante - proximoNumero: number -} - export interface PagedResult { items: T[] page: number From 6458ee0106bdcfad7cb4678eab9934b02ad1cfd0 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 14:16:21 -0300 Subject: [PATCH 17/18] revert(tests): eliminar tests de reserva/concurrencia/secuencialidad ADM-008 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminar SecuenciaComprobanteTests, ReservarNumeroCommandHandlerTests, GetProximoNumeroQueryHandlerTests y 7 tests de integración en PuntosDeVentaControllerTests (reserva/proximo/concurrencia/secuencialidad). SqlTestFixture ahora limpia SecuenciaComprobante+SP si existen (drops idempotentes) y solo crea PuntoDeVenta + temporal table. --- .../Admin/PuntosDeVentaControllerTests.cs | 241 ------------------ .../Domain/SecuenciaComprobanteTests.cs | 57 ----- .../GetProximoNumeroQueryHandlerTests.cs | 52 ---- .../ReservarNumeroCommandHandlerTests.cs | 126 --------- tests/SIGCM2.TestSupport/SqlTestFixture.cs | 175 +++---------- 5 files changed, 39 insertions(+), 612 deletions(-) delete mode 100644 tests/SIGCM2.Application.Tests/Domain/SecuenciaComprobanteTests.cs delete mode 100644 tests/SIGCM2.Application.Tests/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandlerTests.cs delete mode 100644 tests/SIGCM2.Application.Tests/PuntosDeVenta/Reservar/ReservarNumeroCommandHandlerTests.cs diff --git a/tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs b/tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs index 2ec816c..5bffba0 100644 --- a/tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs @@ -125,13 +125,6 @@ public sealed class PuntosDeVentaControllerTests : IAsyncLifetime "SELECT Id FROM dbo.Medio WHERE Codigo = @Codigo", new { Codigo = codigo }); if (id is null) return; - // Delete SecuenciaComprobante for PuntosDeVenta of this Medio (no versioning) - await conn.ExecuteAsync(""" - DELETE sc FROM dbo.SecuenciaComprobante sc - INNER JOIN dbo.PuntoDeVenta pdv ON pdv.Id = sc.PuntoDeVentaId - WHERE pdv.MedioId = @id - """, new { id }); - // Delete dependent PuntosDeVenta (disable versioning to also clear history) await conn.ExecuteAsync("ALTER TABLE dbo.PuntoDeVenta SET (SYSTEM_VERSIONING = OFF)"); await conn.ExecuteAsync("DELETE FROM dbo.PuntoDeVenta_History WHERE MedioId = @id", new { id }); @@ -604,238 +597,4 @@ public sealed class PuntosDeVentaControllerTests : IAsyncLifetime } } - // ── SECUENCIAS: RESERVAR ────────────────────────────────────────────────── - - /// T5.3 — Primera reserva inicializa en 1. - [Fact] - public async Task ReservarNumero_FirstReservation_Returns1() - { - const string medioCodigo = "ADMS08_MED_RSV1"; - var token = await GetAdminTokenAsync(); - - try - { - var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar 1", token); - var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar First", token); - - using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); - var resp = await _client.SendAsync(req); - - Assert.Equal(HttpStatusCode.OK, resp.StatusCode); - var json = await resp.Content.ReadFromJsonAsync(); - Assert.Equal(1, json.GetProperty("numeroReservado").GetInt32()); - } - finally - { - await DeleteMedioIfExistsAsync(medioCodigo); - } - } - - /// T5.3 — 409 punto_de_venta_inactivo al reservar en PdV inactivo. - [Fact] - public async Task ReservarNumero_WhenPdvInactive_Returns409PdvInactivo() - { - const string medioCodigo = "ADMS08_MED_RSVI"; - var token = await GetAdminTokenAsync(); - - try - { - var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar Inactivo", token); - var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar Inactivo", token); - - // Deactivate PdV - using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token); - await _client.SendAsync(deactReq); - - using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); - var resp = await _client.SendAsync(req); - - Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); - var json = await resp.Content.ReadFromJsonAsync(); - Assert.Equal("punto_de_venta_inactivo", json.GetProperty("error").GetString()); - } - finally - { - await DeleteMedioIfExistsAsync(medioCodigo); - } - } - - /// T5.3 — 409 medio_inactivo al reservar con Medio inactivo. - [Fact] - public async Task ReservarNumero_WhenMedioInactive_Returns409MedioInactivo() - { - const string medioCodigo = "ADMS08_MED_RSVMI"; - var token = await GetAdminTokenAsync(); - - try - { - var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar MedioInactivo", token); - var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar MedioInact", token); - - // Deactivate medio - using var deactMedioReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token); - await _client.SendAsync(deactMedioReq); - - using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); - var resp = await _client.SendAsync(req); - - Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); - var json = await resp.Content.ReadFromJsonAsync(); - Assert.Equal("medio_inactivo", json.GetProperty("error").GetString()); - } - finally - { - await DeleteMedioIfExistsAsync(medioCodigo); - } - } - - // ── SECUENCIAS: PROXIMO ─────────────────────────────────────────────────── - - /// T5.3 — GetProximo es read-only: no modifica UltimoNumero. - [Fact] - public async Task GetProximoNumero_DoesNotChangeState() - { - const string medioCodigo = "ADMS08_MED_PROX"; - var token = await GetAdminTokenAsync(); - - try - { - var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Proximo", token); - var pdvId = await CreatePdvAsync(medioId, 1, "PdV Proximo", token); - - // Reserve once to establish state - using var rsv = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); - await _client.SendAsync(rsv); - - // GetProximo twice — should return 2 both times - using var req1 = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaA/proximo", bearerToken: token); - var resp1 = await _client.SendAsync(req1); - Assert.Equal(HttpStatusCode.OK, resp1.StatusCode); - var json1 = await resp1.Content.ReadFromJsonAsync(); - Assert.Equal(2, json1.GetProperty("proximoNumero").GetInt32()); - - using var req2 = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaA/proximo", bearerToken: token); - var resp2 = await _client.SendAsync(req2); - var json2 = await resp2.Content.ReadFromJsonAsync(); - Assert.Equal(2, json2.GetProperty("proximoNumero").GetInt32()); - } - finally - { - await DeleteMedioIfExistsAsync(medioCodigo); - } - } - - /// T5.3 — GetProximo para fila inexistente devuelve 1. - [Fact] - public async Task GetProximoNumero_WhenNoSequenceExists_Returns1() - { - const string medioCodigo = "ADMS08_MED_PROX1"; - var token = await GetAdminTokenAsync(); - - try - { - var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Proximo 1", token); - var pdvId = await CreatePdvAsync(medioId, 1, "PdV Proximo First", token); - - using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaB/proximo", bearerToken: token); - var resp = await _client.SendAsync(req); - - Assert.Equal(HttpStatusCode.OK, resp.StatusCode); - var json = await resp.Content.ReadFromJsonAsync(); - Assert.Equal(1, json.GetProperty("proximoNumero").GetInt32()); - } - finally - { - await DeleteMedioIfExistsAsync(medioCodigo); - } - } - - // ── T5.4 — Concurrencia ─────────────────────────────────────────────────── - - /// - /// T5.4 — 50 tasks paralelas reservando para mismo PdV + TipoComprobante - /// deben producir 50 números distintos cubriendo {1..50}. - /// - [Fact] - public async Task ReservarNumero_50ConcurrentReservations_ProducesNoDuplicates() - { - const string medioCodigo = "ADMS08_CONC50"; - var token = await GetAdminTokenAsync(); - - try - { - var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Concurrencia 50", token); - var pdvId = await CreatePdvAsync(medioId, 1, "PdV Concurrencia 50", token); - - const int taskCount = 50; - var tasks = Enumerable.Range(0, taskCount) - .Select(_ => Task.Run(async () => - { - // Each task creates its own HttpClient to avoid sharing - // the shared _client which is not thread-safe for concurrent requests. - // Use BuildRequest on a shared client IS safe since HttpClient is thread-safe - // for concurrent operations as long as each message is distinct. - using var req = new HttpRequestMessage(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar"); - req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - var resp = await _client.SendAsync(req); - resp.EnsureSuccessStatusCode(); - var json = await resp.Content.ReadFromJsonAsync(); - return json.GetProperty("numeroReservado").GetInt32(); - })) - .ToList(); - - var results = await Task.WhenAll(tasks); - - // All 50 numbers must be present exactly once - Assert.Equal(taskCount, results.Length); - Assert.Equal(taskCount, results.Distinct().Count()); - - var expected = Enumerable.Range(1, taskCount).ToHashSet(); - var actual = results.ToHashSet(); - Assert.Equal(expected, actual); - } - finally - { - await DeleteMedioIfExistsAsync(medioCodigo); - } - } - - // ── T5.5 — Secuencialidad ───────────────────────────────────────────────── - - /// - /// T5.5 — 100 reservas en serie para mismo PdV + TipoComprobante - /// deben devolver {1, 2, 3, ..., 100} en orden. - /// - [Fact] - public async Task ReservarNumero_100SerialReservations_ProducesSequentialNumbers() - { - const string medioCodigo = "ADMS08_SEQ100"; - var token = await GetAdminTokenAsync(); - - try - { - var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Secuencial 100", token); - var pdvId = await CreatePdvAsync(medioId, 1, "PdV Secuencial 100", token); - - const int count = 100; - var results = new List(count); - - for (int i = 0; i < count; i++) - { - using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); - var resp = await _client.SendAsync(req); - resp.EnsureSuccessStatusCode(); - var json = await resp.Content.ReadFromJsonAsync(); - results.Add(json.GetProperty("numeroReservado").GetInt32()); - } - - // Verify sequential: {1, 2, 3, ..., 100} - var expected = Enumerable.Range(1, count).ToList(); - Assert.Equal(expected, results); - } - finally - { - await DeleteMedioIfExistsAsync(medioCodigo); - } - } } diff --git a/tests/SIGCM2.Application.Tests/Domain/SecuenciaComprobanteTests.cs b/tests/SIGCM2.Application.Tests/Domain/SecuenciaComprobanteTests.cs deleted file mode 100644 index 39c2fa7..0000000 --- a/tests/SIGCM2.Application.Tests/Domain/SecuenciaComprobanteTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -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); - } - } -} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandlerTests.cs deleted file mode 100644 index 4adc03b..0000000 --- a/tests/SIGCM2.Application.Tests/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandlerTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using NSubstitute; -using SIGCM2.Application.Abstractions.Persistence; -using SIGCM2.Application.PuntosDeVenta.ProximoNumero; -using SIGCM2.Domain.Enums; - -namespace SIGCM2.Application.Tests.PuntosDeVenta.ProximoNumero; - -public class GetProximoNumeroQueryHandlerTests -{ - private readonly IPuntoDeVentaRepository _repo = Substitute.For(); - private readonly GetProximoNumeroQueryHandler _handler; - - public GetProximoNumeroQueryHandlerTests() - { - _handler = new GetProximoNumeroQueryHandler(_repo); - } - - [Fact] - public async Task Handle_ExistingSequence_ReturnsUltimoNumeroMasUno() - { - _repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) - .Returns(7); - - var result = await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaA)); - - Assert.Equal(TipoComprobante.FacturaA, result.TipoComprobante); - Assert.Equal(8, result.ProximoNumero); - } - - [Fact] - public async Task Handle_NoExistingSequence_ReturnsOne() - { - _repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaB, Arg.Any()) - .Returns((int?)null); - - var result = await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaB)); - - Assert.Equal(1, result.ProximoNumero); - } - - [Fact] - public async Task Handle_DoesNotCallReservarNumero() - { - _repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) - .Returns(5); - - await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaA)); - - await _repo.DidNotReceive().ReservarNumeroAsync( - Arg.Any(), Arg.Any(), Arg.Any()); - } -} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reservar/ReservarNumeroCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reservar/ReservarNumeroCommandHandlerTests.cs deleted file mode 100644 index a3cf99f..0000000 --- a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reservar/ReservarNumeroCommandHandlerTests.cs +++ /dev/null @@ -1,126 +0,0 @@ -using NSubstitute; -using NSubstitute.ExceptionExtensions; -using SIGCM2.Application.Abstractions.Persistence; -using SIGCM2.Application.PuntosDeVenta.Reservar; -using SIGCM2.Domain.Enums; -using SIGCM2.Domain.Exceptions; - -namespace SIGCM2.Application.Tests.PuntosDeVenta.Reservar; - -public class ReservarNumeroCommandHandlerTests -{ - private readonly IPuntoDeVentaRepository _repo = Substitute.For(); - private readonly ReservarNumeroCommandHandler _handler; - - private static readonly ReservarNumeroCommand ValidCommand = - new(PuntoDeVentaId: 10, TipoComprobante: TipoComprobante.FacturaA); - - public ReservarNumeroCommandHandlerTests() - { - // Use delay = 0 for fast tests - _handler = new ReservarNumeroCommandHandler(_repo, deadlockBackoffMs: [0, 0, 0]); - } - - // ── happy path ─────────────────────────────────────────────────────────── - - [Fact] - public async Task Handle_HappyPath_ReturnsNumeroReservado() - { - _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) - .Returns(7); - - var result = await _handler.Handle(ValidCommand); - - Assert.Equal(TipoComprobante.FacturaA, result.TipoComprobante); - Assert.Equal(7, result.NumeroReservado); - } - - // ── retry deadlock ──────────────────────────────────────────────────────── - - [Fact] - public async Task Handle_DeadlockTwiceThenSucceeds_ReturnsResult() - { - var deadlock = new DeadlockTransientException(); - - _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) - .Returns( - _ => Task.FromException(deadlock), - _ => Task.FromException(deadlock), - _ => Task.FromResult(3)); - - var result = await _handler.Handle(ValidCommand); - - Assert.Equal(3, result.NumeroReservado); - } - - [Fact] - public async Task Handle_DeadlockThreeTimes_BubblesUpDeadlockException() - { - var deadlock = new DeadlockTransientException(); - - _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) - .Returns( - _ => Task.FromException(deadlock), - _ => Task.FromException(deadlock), - _ => Task.FromException(deadlock)); - - await Assert.ThrowsAsync( - () => _handler.Handle(ValidCommand)); - } - - [Fact] - public async Task Handle_DeadlockExhaustsBackoff_TriedFourTimesTotal() - { - // backoff = [0,0,0] → 3 retries → 4 total attempts (1 initial + 3 retries) - var deadlock = new DeadlockTransientException(); - - _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) - .Returns(_ => Task.FromException(deadlock)); - - try { await _handler.Handle(ValidCommand); } catch (DeadlockTransientException) { } - - await _repo.Received(4).ReservarNumeroAsync( - Arg.Any(), Arg.Any(), Arg.Any()); - } - - // ── domain exceptions bubble up without retry ───────────────────────────── - - [Fact] - public async Task Handle_PuntoDeVentaInactivo_BubblesUpImmediately() - { - _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) - .Throws(new PuntoDeVentaInactivoException(10)); - - await Assert.ThrowsAsync( - () => _handler.Handle(ValidCommand)); - - await _repo.Received(1).ReservarNumeroAsync( - Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Handle_MedioInactivo_BubblesUpImmediately() - { - _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) - .Throws(new MedioInactivoException(5)); - - await Assert.ThrowsAsync( - () => _handler.Handle(ValidCommand)); - - await _repo.Received(1).ReservarNumeroAsync( - Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Handle_PdvNotFound_BubblesUpImmediately() - { - _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) - .Throws(new PuntoDeVentaNotFoundException(10)); - - await Assert.ThrowsAsync( - () => _handler.Handle(ValidCommand)); - - await _repo.Received(1).ReservarNumeroAsync( - Arg.Any(), Arg.Any(), Arg.Any()); - } -} diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index 54c698d..45dbb9c 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -171,7 +171,7 @@ public sealed class SqlTestFixture : IAsyncLifetime -- V011 (ADM-001): permiso para CRUD de Secciones ('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion'), -- V013 (ADM-008): permiso para CRUD de Puntos de Venta - ('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta y reservar numeros','administracion') + ('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta AFIP','administracion') ) AS s (Codigo, Nombre, Descripcion, Modulo) ON t.Codigo = s.Codigo WHEN NOT MATCHED BY TARGET THEN @@ -382,13 +382,38 @@ public sealed class SqlTestFixture : IAsyncLifetime } /// - /// ADM-008 (V013): applies PuntoDeVenta / SecuenciaComprobante schema + temporal tables + - /// permiso 'administracion:puntos_de_venta:gestionar' + SP usp_ReservarNumeroComprobante. - /// Idempotent — mirrors V013__create_puntos_de_venta.sql. Permiso y asignación se siembran - /// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). + /// ADM-008 (V013): applies dbo.PuntoDeVenta schema + temporal table. + /// NOTE: SecuenciaComprobante y SP usp_ReservarNumeroComprobante fueron eliminados + /// post-smoke (Batch 9) — IMAC/Infogestión asigna los números AFIP externamente. + /// Este método también hace DROP idempotente de esos artefactos en caso de que + /// SIGCM2_Test los tuviera de una versión previa de la migración V013. + /// Permiso y asignación se siembran desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync. /// private async Task EnsureV013SchemaAsync() { + // ── Drops idempotentes de artefactos eliminados (cirugía post-smoke) ── + // Si SIGCM2_Test tiene SecuenciaComprobante o el SP de una versión previa, se limpian. + const string dropSp = """ + IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL + DROP PROCEDURE dbo.usp_ReservarNumeroComprobante; + """; + + const string disableSecuenciaVersioning = """ + IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2) + ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF); + """; + + const string dropSecuenciaHistory = """ + IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL + DROP TABLE dbo.SecuenciaComprobante_History; + """; + + const string dropSecuencia = """ + IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NOT NULL + DROP TABLE dbo.SecuenciaComprobante; + """; + + // ── PuntoDeVenta: crear si no existe ────────────────────────────────── const string createPdv = """ IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NULL BEGIN @@ -417,23 +442,6 @@ public sealed class SqlTestFixture : IAsyncLifetime END """; - const string createSecuencia = """ - IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NULL - BEGIN - CREATE TABLE dbo.SecuenciaComprobante ( - PuntoDeVentaId INT NOT NULL, - TipoComprobante TINYINT NOT NULL, - UltimoNumero INT NOT NULL CONSTRAINT DF_SecuenciaComprobante_UltimoNumero DEFAULT(0), - FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_SecuenciaComprobante_FechaCreacion DEFAULT(SYSUTCDATETIME()), - FechaModificacion DATETIME2(3) NULL, - CONSTRAINT PK_SecuenciaComprobante PRIMARY KEY (PuntoDeVentaId, TipoComprobante), - CONSTRAINT FK_SecuenciaComprobante_PuntoDeVenta FOREIGN KEY (PuntoDeVentaId) REFERENCES dbo.PuntoDeVenta(Id) ON DELETE NO ACTION, - CONSTRAINT CK_SecuenciaComprobante_TipoComprobante CHECK (TipoComprobante BETWEEN 1 AND 6), - CONSTRAINT CK_SecuenciaComprobante_UltimoNumero CHECK (UltimoNumero >= 0) - ); - END - """; - const string addPdvPeriod = """ IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NULL BEGIN @@ -458,122 +466,17 @@ public sealed class SqlTestFixture : IAsyncLifetime END """; - // SecuenciaComprobante: sin SYSTEM_VERSIONING (AD8 revisitado — ver comentario en V013). - // Si una version previa del fixture activo SYSTEM_VERSIONING, lo desactiva + drop history. - const string disableSecuenciaVersioning = """ - IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2) - BEGIN - ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF); - END - """; - - const string dropSecuenciaHistory = """ - IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL - BEGIN - DROP TABLE dbo.SecuenciaComprobante_History; - END - """; - - const string dropSecuenciaPeriod = """ - IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante')) - BEGIN - ALTER TABLE dbo.SecuenciaComprobante DROP PERIOD FOR SYSTEM_TIME; - END - """; - - const string dropSecuenciaValidCols = """ - IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NOT NULL - BEGIN - IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidFrom' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante')) - ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidFrom; - IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidTo' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante')) - ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidTo; - ALTER TABLE dbo.SecuenciaComprobante DROP COLUMN ValidFrom, ValidTo; - END - """; - - const string dropSp = """ - IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL - DROP PROCEDURE dbo.usp_ReservarNumeroComprobante; - """; - - const string createSp = """ - CREATE PROCEDURE dbo.usp_ReservarNumeroComprobante - @PuntoDeVentaId INT, - @TipoComprobante TINYINT, - @NumeroReservado INT OUTPUT - AS - BEGIN - SET NOCOUNT ON; - SET XACT_ABORT ON; - SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; - - BEGIN TRAN; - - DECLARE @PdvActivo BIT; - DECLARE @MedioActivo BIT; - - SELECT - @PdvActivo = p.Activo, - @MedioActivo = m.Activo - FROM dbo.PuntoDeVenta p - JOIN dbo.Medio m ON m.Id = p.MedioId - WHERE p.Id = @PuntoDeVentaId; - - IF @PdvActivo IS NULL - BEGIN - ROLLBACK; - THROW 50003, 'punto_de_venta_not_found', 1; - END - - IF @PdvActivo = 0 - BEGIN - ROLLBACK; - THROW 50001, 'punto_de_venta_inactivo', 1; - END - - IF @MedioActivo = 0 - BEGIN - ROLLBACK; - THROW 50002, 'medio_inactivo', 1; - END - - DECLARE @_out TABLE (n INT NOT NULL); - - UPDATE dbo.SecuenciaComprobante - SET - UltimoNumero = UltimoNumero + 1, - FechaModificacion = SYSUTCDATETIME() - OUTPUT inserted.UltimoNumero INTO @_out(n) - WHERE PuntoDeVentaId = @PuntoDeVentaId - AND TipoComprobante = @TipoComprobante; - - IF @@ROWCOUNT = 0 - BEGIN - INSERT INTO dbo.SecuenciaComprobante (PuntoDeVentaId, TipoComprobante, UltimoNumero) - VALUES (@PuntoDeVentaId, @TipoComprobante, 1); - SET @NumeroReservado = 1; - END - ELSE - BEGIN - SELECT @NumeroReservado = n FROM @_out; - END - - COMMIT; - END - """; - - await _connection.ExecuteAsync(createPdv); - await _connection.ExecuteAsync(createPdvIndex); - await _connection.ExecuteAsync(createSecuencia); - await _connection.ExecuteAsync(addPdvPeriod); - await _connection.ExecuteAsync(setPdvVersioning); + // Drops primero (limpieza de versión previa) + await _connection.ExecuteAsync(dropSp); await _connection.ExecuteAsync(disableSecuenciaVersioning); await _connection.ExecuteAsync(dropSecuenciaHistory); - await _connection.ExecuteAsync(dropSecuenciaPeriod); - await _connection.ExecuteAsync(dropSecuenciaValidCols); - await _connection.ExecuteAsync(dropSp); - await _connection.ExecuteAsync(createSp); + await _connection.ExecuteAsync(dropSecuencia); + + // Luego crear PuntoDeVenta + Temporal Table + await _connection.ExecuteAsync(createPdv); + await _connection.ExecuteAsync(createPdvIndex); + await _connection.ExecuteAsync(addPdvPeriod); + await _connection.ExecuteAsync(setPdvVersioning); } /// From fc77576427e4c3437ebb99efad4861941a815e71 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 14:24:58 -0300 Subject: [PATCH 18/18] chore(adm-008): limpiar import huerfano + comentario stale post-ciruigia - PuntoDeVentaTests.cs: quitar using SIGCM2.Domain.Enums (quedo huerfano tras eliminar TipoComprobante). - SqlTestFixture.cs: actualizar comentario de EnsureV013SchemaAsync para reflejar scope recortado (solo PdV + permiso, drops idempotentes de SecuenciaComprobante + SP). --- tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs | 1 - tests/SIGCM2.TestSupport/SqlTestFixture.cs | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs b/tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs index 639e66d..57409cf 100644 --- a/tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs +++ b/tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs @@ -1,5 +1,4 @@ using SIGCM2.Domain.Entities; -using SIGCM2.Domain.Enums; namespace SIGCM2.Application.Tests.Domain; diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index 45dbb9c..995414b 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -39,7 +39,9 @@ public sealed class SqlTestFixture : IAsyncLifetime // V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'. await EnsureV011SchemaAsync(); - // V013 (ADM-008): ensure dbo.PuntoDeVenta, dbo.SecuenciaComprobante + temporal + SP usp_ReservarNumeroComprobante. + // V013 (ADM-008): ensure dbo.PuntoDeVenta + temporal + permiso. Drops idempotentes + // de SecuenciaComprobante + SP usp_ReservarNumeroComprobante (cirugía post-smoke: + // IMAC asigna numeros AFIP, no nosotros — ver memoria architecture/facturacion-imac-numeracion). await EnsureV013SchemaAsync(); _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions