fix(adm-008): correcciones del verify loop
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).
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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<ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>, ListSeccionesQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetSeccionByIdQuery, SeccionDetailDto>, GetSeccionByIdQueryHandler>();
|
||||
|
||||
// Puntos de Venta (ADM-008)
|
||||
services.AddScoped<ICommandHandler<CreatePuntoDeVentaCommand, PuntoDeVentaCreatedDto>, CreatePuntoDeVentaCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdatePuntoDeVentaCommand, PuntoDeVentaUpdatedDto>, UpdatePuntoDeVentaCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>, DeactivatePuntoDeVentaCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ReactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>, ReactivatePuntoDeVentaCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>, ListPuntosDeVentaQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>, GetPuntoDeVentaByIdQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<ReservarNumeroCommand, ReservaNumeroDto>, ReservarNumeroCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<GetProximoNumeroQuery, ProximoNumeroDto>, GetProximoNumeroQueryHandler>();
|
||||
|
||||
// FluentValidation validators (scans entire Application assembly)
|
||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||
|
||||
|
||||
@@ -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).
|
||||
/// </summary>
|
||||
public sealed class ReservarNumeroCommandHandler : ICommandHandler<ReservarNumeroCommand, ReservaNumeroDto>
|
||||
@@ -21,7 +21,7 @@ public sealed class ReservarNumeroCommandHandler : ICommandHandler<ReservarNumer
|
||||
private readonly IPuntoDeVentaRepository _repo;
|
||||
private readonly int[] _deadlockBackoffMs;
|
||||
|
||||
private static readonly int[] DefaultBackoffMs = [50, 150, 450];
|
||||
private static readonly int[] DefaultBackoffMs = [25, 75, 200, 500, 1200];
|
||||
|
||||
public ReservarNumeroCommandHandler(IPuntoDeVentaRepository repo)
|
||||
: this(repo, DefaultBackoffMs) { }
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class PuntoDeVentaRepository : IPuntoDeVentaRepository
|
||||
pdv.Descripcion,
|
||||
});
|
||||
}
|
||||
catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_MedioId_NumeroAFIP"))
|
||||
catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_Medio_AFIP"))
|
||||
{
|
||||
throw new NumeroAFIPDuplicadoException(pdv.MedioId, pdv.NumeroAFIP);
|
||||
}
|
||||
@@ -102,7 +102,7 @@ public sealed class PuntoDeVentaRepository : IPuntoDeVentaRepository
|
||||
pdv.Id,
|
||||
});
|
||||
}
|
||||
catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_MedioId_NumeroAFIP"))
|
||||
catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_Medio_AFIP"))
|
||||
{
|
||||
throw new NumeroAFIPDuplicadoException(pdv.MedioId, pdv.NumeroAFIP);
|
||||
}
|
||||
|
||||
@@ -465,41 +465,6 @@ public sealed class PuntosDeVentaControllerTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T5.3 — 409 punto_de_venta_inactivo al actualizar PdV inactivo.</summary>
|
||||
[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<JsonElement>();
|
||||
Assert.Equal("punto_de_venta_inactivo", json.GetProperty("error").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteMedioIfExistsAsync(medioCodigo);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T5.3 — 409 medio_inactivo al actualizar PdV con Medio inactivo.</summary>
|
||||
[Fact]
|
||||
public async Task UpdatePdv_WhenMedioInactive_Returns409MedioInactivo()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<JsonElement>();
|
||||
// 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<JsonElement>();
|
||||
// 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]
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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).
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user