From 65787db272a5f6359be54bb01e9ea05114a1527e Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 13:02:35 -0300 Subject: [PATCH] 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. ///