using Dapper; using Microsoft.Data.SqlClient; using Respawn; using Xunit; namespace SIGCM2.TestSupport; /// /// Manages a real SQL Server test database. /// Resets state between test runs using Respawn. /// Seeds the admin user after each reset. /// public sealed class SqlTestFixture : IAsyncLifetime { private readonly string _connectionString; private SqlConnection _connection = null!; private Respawner _respawner = null!; /// Parameterless ctor for xUnit ICollectionFixture — uses SIGCM2_Test_App. public SqlTestFixture() : this(TestConnectionStrings.AppTestDb) { } /// /// Explicit connection string ctor — used by TestWebAppFactory (same assembly). /// Internal to satisfy xUnit's "single public constructor" rule for ICollectionFixture. /// internal SqlTestFixture(string connectionString) { _connectionString = connectionString; } public async Task InitializeAsync() { _connection = new SqlConnection(_connectionString); await _connection.OpenAsync(); // V008: ensure MustChangePassword column and IX_Usuario_Activo_Rol exist in test DB await EnsureV008SchemaAsync(); // V009: update PermisosJson DEFAULT constraint and migrate legacy rows await EnsureV009SchemaAsync(); // V010 (UDT-010): verify audit infrastructure + temporal tables are active. // Applied manually via: sqlcmd ... -i database/migrations/V010__audit_infrastructure.sql await EnsureV010SchemaAsync(); // V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'. await EnsureV011SchemaAsync(); // 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(); // V014 (ADM-009): ensure dbo.TipoDeIva + dbo.IngresosBrutos + temporal + seed + permiso fiscal. await EnsureV014SchemaAsync(); // V015 (UDT-011): ensure dbo.v_AuditEvent_Local + dbo.v_SecurityEvent_Local views exist. await EnsureV015SchemaAsync(); // V016 (CAT-001): ensure dbo.Rubro + temporal + permiso 'catalogo:rubros:gestionar'. await EnsureV016SchemaAsync(); // V017 (PRD-001): ensure dbo.ProductType + temporal + permiso 'catalogo:tipos:gestionar'. await EnsureV017SchemaAsync(); // V018 (PRD-002): ensure dbo.Product + temporal + permiso 'catalogo:productos:gestionar'. await EnsureV018SchemaAsync(); // V019 (PRD-003): ensure dbo.ProductPrices + temporal + SP usp_AddProductPrice. await EnsureV019SchemaAsync(); // V020/V021/V022 (PRC-001): ensure dbo.ChargeableCharConfig + temporal + SPs + permission + seed. await EnsureV021SchemaAsync(); // V023 (PRC-001 scope delta): refactor ChargeableCharConfig — MedioId → ProductTypeId + ReactivateWithGuard SP. await EnsureV023SchemaAsync(); // V024 (PRC-001 scope delta): reseed global rows with PricePerUnit = 0.0000 (opt-in billing). await EnsureV024SeedAsync(); _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, // Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks. // Permiso and RolPermiso are seeded by V005/V006 — never wipe or integration tests lose the permission catalog. // *_History tables: UDT-010/ADM-001 system-versioned — Respawn cannot DELETE them directly (engine rejects). TablesToIgnore = [ new Respawn.Graph.Table("dbo", "Rol"), new Respawn.Graph.Table("dbo", "Permiso"), new Respawn.Graph.Table("dbo", "RolPermiso"), new Respawn.Graph.Table("dbo", "Usuario_History"), new Respawn.Graph.Table("dbo", "Rol_History"), new Respawn.Graph.Table("dbo", "Permiso_History"), 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"), // ADM-009 (V014): TipoDeIva + IngresosBrutos son temporales. new Respawn.Graph.Table("dbo", "TipoDeIva_History"), new Respawn.Graph.Table("dbo", "IngresosBrutos_History"), // Seed de TipoDeIva e IngresosBrutos son datos de referencia — no limpiar con Respawn. new Respawn.Graph.Table("dbo", "TipoDeIva"), new Respawn.Graph.Table("dbo", "IngresosBrutos"), // CAT-001 (V016): Rubro es temporal — history no puede deletearse directo. new Respawn.Graph.Table("dbo", "Rubro_History"), // PRD-001 (V017): ProductType es temporal — history no puede deletearse directo. new Respawn.Graph.Table("dbo", "ProductType_History"), // PRD-002 (V018): Product es temporal — history no puede deletearse directo. new Respawn.Graph.Table("dbo", "Product_History"), // PRD-003 (V019): ProductPrices es temporal — history protegida por SYSTEM_VERSIONING. new Respawn.Graph.Table("dbo", "ProductPrices_History"), // PRC-001 (V021): ChargeableCharConfig es temporal — history protegida por SYSTEM_VERSIONING. new Respawn.Graph.Table("dbo", "ChargeableCharConfig_History"), ] }); await ResetAndSeedAsync(); } /// /// Exposes the open SqlConnection for tests that need to run ad-hoc queries /// (e.g. seed extra rows, assert history tables). Connection is opened during /// InitializeAsync and closed in DisposeAsync. /// public SqlConnection Connection => _connection; public async Task ResetAndSeedAsync() { await _respawner.ResetAsync(_connection); await SeedRolCanonicalAsync(); await SeedPermisosCanonicalAsync(); await SeedRolPermisosCanonicalAsync(); await SeedAdminAsync(); await SeedMediosCanonicalAsync(); // PRC-001 scope delta: ChargeableCharConfig re-seeded with ProductTypeId-based canonical seed. await SeedChargeableCharConfigCanonicalAsync(); } private async Task SeedRolCanonicalAsync() { const string sql = """ SET QUOTED_IDENTIFIER ON; MERGE dbo.Rol AS t USING (VALUES ('admin', N'Administrador', N'Supervisor total'), ('cajero', N'Cajero', N'Mostrador contado'), ('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'), ('picadora', N'Picadora/Correctora', N'Edición de textos'), ('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta'), ('productor', N'Productor', N'Carga restringida'), ('diagramacion', N'Diagramación/Taller', N'Solo lectura pauta'), ('reportes', N'Reportes', N'Solo lectura reportes') ) AS s (Codigo, Nombre, Descripcion) ON t.Codigo = s.Codigo WHEN NOT MATCHED BY TARGET THEN INSERT (Codigo, Nombre, Descripcion, Activo) VALUES (s.Codigo, s.Nombre, s.Descripcion, 1); """; await _connection.ExecuteAsync(sql); } public async Task DisposeAsync() { if (_connection is not null) { await _connection.CloseAsync(); await _connection.DisposeAsync(); } } /// /// Applies V008 schema changes idempotently to the test database. /// Mirrors V008__add_mustchangepassword_and_indexes.sql. /// private async Task EnsureV008SchemaAsync() { const string addColumn = """ IF COL_LENGTH('dbo.Usuario', 'MustChangePassword') IS NULL BEGIN ALTER TABLE dbo.Usuario ADD MustChangePassword BIT NOT NULL CONSTRAINT DF_Usuario_MustChangePassword DEFAULT(0); END """; const string addIndex = """ IF NOT EXISTS ( SELECT 1 FROM sys.indexes WHERE name = 'IX_Usuario_Activo_Rol' AND object_id = OBJECT_ID('dbo.Usuario') ) BEGIN CREATE INDEX IX_Usuario_Activo_Rol ON dbo.Usuario(Activo, Rol) INCLUDE (Id, Username, Email, UltimoLogin, FechaModificacion); END """; await _connection.ExecuteAsync(addColumn); await _connection.ExecuteAsync(addIndex); } private async Task SeedPermisosCanonicalAsync() { const string sql = """ SET QUOTED_IDENTIFIER ON; MERGE dbo.Permiso AS t USING (VALUES ('ventas:contado:crear', N'Cargar orden contado', NULL, 'ventas'), ('ventas:contado:modificar', N'Modificar orden contado', NULL, 'ventas'), ('ventas:contado:cobrar', N'Cobrar orden contado', NULL, 'ventas'), ('ventas:contado:facturar', N'Facturar orden contado', NULL, 'ventas'), ('ventas:ctacte:crear', N'Cargar orden cuenta corriente', NULL, 'ventas'), ('ventas:ctacte:facturar', N'Facturar lote cuenta corriente', NULL, 'ventas'), ('textos:editar', N'Editar textos', NULL, 'textos'), ('textos:reclamos:ver', N'Ver reclamos de textos', NULL, 'textos'), ('pauta:azanu:ver', N'Ver AZANU en pauta', NULL, 'pauta'), ('pauta:limpiar', N'Limpieza de pauta', NULL, 'pauta'), ('pauta:recursos:fueradehora', N'Recursos fuera de hora', NULL, 'pauta'), ('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'), ('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'), ('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'), ('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'), ('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'), ('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'), ('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion'), -- V007 (UDT-006): permisos administrativos RBAC ('administracion:roles:gestionar', N'Gestionar roles del sistema', N'Crear, editar y desactivar roles RBAC', 'administracion'), ('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'), -- 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 AFIP','administracion'), -- V014 (ADM-009): permiso para tablas fiscales ('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion'), -- V016 (CAT-001): permiso para gestionar árbol de rubros ('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo'), -- V017 (PRD-001): permiso para gestionar tipos de producto ('catalogo:tipos:gestionar', N'Gestionar tipos de producto', N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)', 'catalogo'), -- V018 (PRD-002): permiso para gestionar productos del catálogo ('catalogo:productos:gestionar', N'Gestionar productos del catálogo', N'Crear, editar y desactivar productos del catálogo comercial', 'catalogo'), -- V020 (PRC-001): permiso para gestionar caracteres tasables ('tasacion:caracteres_especiales:gestionar', N'Gestionar caracteres tasables', N'Crear, editar precio y desactivar la configuracion de caracteres especiales para tasacion.', 'tasacion') ) 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); """; await _connection.ExecuteAsync(sql); } private async Task SeedRolPermisosCanonicalAsync() { const string sql = """ SET QUOTED_IDENTIFIER ON; MERGE dbo.RolPermiso AS t USING ( SELECT r.Id AS RolId, p.Id AS PermisoId FROM (VALUES ('admin', 'ventas:contado:crear'), ('admin', 'ventas:contado:modificar'), ('admin', 'ventas:contado:cobrar'), ('admin', 'ventas:contado:facturar'), ('admin', 'ventas:ctacte:crear'), ('admin', 'ventas:ctacte:facturar'), ('admin', 'textos:editar'), ('admin', 'textos:reclamos:ver'), ('admin', 'pauta:azanu:ver'), ('admin', 'pauta:limpiar'), ('admin', 'pauta:recursos:fueradehora'), ('admin', 'productores:deuda:ver'), ('admin', 'productores:pendientes:crear'), ('admin', 'productores:deuda:bypass'), ('admin', 'administracion:usuarios:gestionar'), ('admin', 'administracion:tarifarios:gestionar'), ('admin', 'administracion:medios:gestionar'), ('admin', 'administracion:auditoria:ver'), -- V007 (UDT-006): permisos administrativos RBAC para admin ('admin', 'administracion:roles:gestionar'), ('admin', 'administracion:roles_permisos:gestionar'), ('admin', 'administracion:permisos:ver'), -- V011 (ADM-001) ('admin', 'administracion:secciones:gestionar'), -- V013 (ADM-008) ('admin', 'administracion:puntos_de_venta:gestionar'), -- V014 (ADM-009) ('admin', 'administracion:fiscal:gestionar'), -- V016 (CAT-001) ('admin', 'catalogo:rubros:gestionar'), -- V017 (PRD-001) ('admin', 'catalogo:tipos:gestionar'), -- V018 (PRD-002) ('admin', 'catalogo:productos:gestionar'), -- V020 (PRC-001) ('admin', 'tasacion:caracteres_especiales:gestionar'), ('cajero', 'ventas:contado:crear'), ('cajero', 'ventas:contado:modificar'), ('cajero', 'ventas:contado:cobrar'), ('cajero', 'ventas:contado:facturar'), ('operador_ctacte', 'ventas:ctacte:crear'), ('operador_ctacte', 'ventas:ctacte:facturar'), ('picadora', 'textos:editar'), ('picadora', 'textos:reclamos:ver'), ('jefe_publicidad', 'textos:editar'), ('jefe_publicidad', 'textos:reclamos:ver'), ('jefe_publicidad', 'pauta:azanu:ver'), ('jefe_publicidad', 'pauta:limpiar'), ('jefe_publicidad', 'pauta:recursos:fueradehora'), ('jefe_publicidad', 'productores:deuda:ver'), ('jefe_publicidad', 'productores:deuda:bypass'), ('productor', 'productores:deuda:ver'), ('productor', 'productores:pendientes:crear'), ('diagramacion', 'pauta:azanu:ver') ) 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); """; await _connection.ExecuteAsync(sql); } private async Task SeedAdminAsync() { // V009: PermisosJson uses new canonical shape {"grant":[],"deny":[]} — NOT legacy '["*"]' const string sql = """ SET QUOTED_IDENTIFIER ON; IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin') INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) VALUES ( 'admin', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', 'Administrador', 'Sistema', 'admin', '{"grant":[],"deny":[]}', 1, 0 ); """; await _connection.ExecuteAsync(sql); } /// /// ADM-001 (V011): applies Medio/Seccion schema + temporal + permiso 'administracion:secciones:gestionar' /// idempotently to the test database. Mirrors V011__create_medio_seccion.sql. /// Canonical seed (ELDIA, ELPLATA) vive en SeedMediosCanonicalAsync — se reaplica tras cada Respawn. /// private async Task EnsureV011SchemaAsync() { const string createMedio = """ IF OBJECT_ID(N'dbo.Medio', N'U') IS NULL BEGIN CREATE TABLE dbo.Medio ( Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Medio PRIMARY KEY, Codigo VARCHAR(30) NOT NULL, Nombre NVARCHAR(100) NOT NULL, Tipo TINYINT NOT NULL, PlataformaEmpresaId INT NULL, Activo BIT NOT NULL CONSTRAINT DF_Medio_Activo DEFAULT(1), FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Medio_FechaCreacion DEFAULT(SYSUTCDATETIME()), FechaModificacion DATETIME2(3) NULL, CONSTRAINT UQ_Medio_Codigo UNIQUE (Codigo), CONSTRAINT CK_Medio_Tipo CHECK (Tipo BETWEEN 1 AND 4) ); END """; const string createMedioIndex = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Medio_Activo_Tipo' AND object_id = OBJECT_ID('dbo.Medio')) BEGIN CREATE INDEX IX_Medio_Activo_Tipo ON dbo.Medio(Activo, Tipo) INCLUDE (Codigo, Nombre, PlataformaEmpresaId); END """; const string createSeccion = """ IF OBJECT_ID(N'dbo.Seccion', N'U') IS NULL BEGIN CREATE TABLE dbo.Seccion ( Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Seccion PRIMARY KEY, MedioId INT NOT NULL, Codigo VARCHAR(30) NOT NULL, Nombre NVARCHAR(100) NOT NULL, Tipo VARCHAR(20) NOT NULL, Activo BIT NOT NULL CONSTRAINT DF_Seccion_Activo DEFAULT(1), FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Seccion_FechaCreacion DEFAULT(SYSUTCDATETIME()), FechaModificacion DATETIME2(3) NULL, CONSTRAINT FK_Seccion_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, CONSTRAINT UQ_Seccion_MedioId_Codigo UNIQUE (MedioId, Codigo), CONSTRAINT CK_Seccion_Tipo CHECK (Tipo IN ('clasificados','notables','suplementos')) ); END """; const string createSeccionIndex = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Seccion_MedioId_Activo' AND object_id = OBJECT_ID('dbo.Seccion')) BEGIN CREATE INDEX IX_Seccion_MedioId_Activo ON dbo.Seccion(MedioId, Activo) INCLUDE (Codigo, Nombre, Tipo); END """; const string addMedioPeriod = """ IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NULL BEGIN ALTER TABLE dbo.Medio ADD ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_Medio_ValidFrom DEFAULT(SYSUTCDATETIME()), ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_Medio_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); END """; const string setMedioVersioning = """ IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2) BEGIN ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.Medio_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); END """; const string addSeccionPeriod = """ IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NULL BEGIN ALTER TABLE dbo.Seccion ADD ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_Seccion_ValidFrom DEFAULT(SYSUTCDATETIME()), ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_Seccion_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); END """; const string setSeccionVersioning = """ IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2) BEGIN ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.Seccion_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); END """; await _connection.ExecuteAsync(createMedio); await _connection.ExecuteAsync(createMedioIndex); await _connection.ExecuteAsync(createSeccion); await _connection.ExecuteAsync(createSeccionIndex); await _connection.ExecuteAsync(addMedioPeriod); await _connection.ExecuteAsync(setMedioVersioning); await _connection.ExecuteAsync(addSeccionPeriod); await _connection.ExecuteAsync(setSeccionVersioning); // Permiso 'administracion:secciones:gestionar' + asignación a admin 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 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 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 """; // Drops primero (limpieza de versión previa) await _connection.ExecuteAsync(dropSp); await _connection.ExecuteAsync(disableSecuenciaVersioning); await _connection.ExecuteAsync(dropSecuenciaHistory); 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); } /// /// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset. /// private async Task SeedMediosCanonicalAsync() { const string sql = """ SET QUOTED_IDENTIFIER ON; MERGE dbo.Medio AS t USING (VALUES ('ELDIA', N'El Día', 1), ('ELPLATA', N'El Plata', 1) ) AS s (Codigo, Nombre, Tipo) ON t.Codigo = s.Codigo WHEN NOT MATCHED BY TARGET THEN INSERT (Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo) VALUES (s.Codigo, s.Nombre, s.Tipo, NULL, 1); """; await _connection.ExecuteAsync(sql); } /// /// PRC-001 scope delta (V022+V024): re-seeds the 4 global ChargeableCharConfig defaults after each Respawn. /// Uses ProductTypeId NULL (global fallback) and PricePerUnit = 0.0000 (opt-in billing — V024 decision). /// Mirrors V022 MERGE pattern, adapted for ProductTypeId column (V023 refactor). /// The table itself is never added to TablesToIgnore because per-productType test rows /// must be reset between test classes — only the 4 global defaults are reseeded. /// NOTE: seed price is 0.0000 (not 1.0000). Tests asserting price must use 0.0000 unless /// they have explicitly seeded their own rows at a different price. /// private async Task SeedChargeableCharConfigCanonicalAsync() { const string sql = """ SET QUOTED_IDENTIFIER ON; IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND name = 'ProductTypeId') BEGIN MERGE dbo.ChargeableCharConfig AS t USING (VALUES (NULL, N'$', N'Currency', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), (NULL, N'%', N'Percentage', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), (NULL, N'!', N'Exclamation', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), (NULL, N'¡', N'Exclamation', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)) ) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom) ON (t.ProductTypeId IS NULL AND s.ProductTypeId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL) WHEN NOT MATCHED THEN INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1); END """; await _connection.ExecuteAsync(sql); } /// /// UDT-010 (V010): verifies that the audit infrastructure is present. /// Does NOT re-apply the migration (the ALTER DATABASE ADD FILEGROUP/FILE + partition /// function/scheme creation requires the full script). If missing, fails with a clear /// message pointing to the migration script. /// private async Task EnsureV010SchemaAsync() { const string check = """ SELECT CAST(CASE WHEN OBJECT_ID('dbo.AuditEvent','U') IS NULL THEN 0 ELSE 1 END AS BIT) AS HasAuditEvent, CAST(CASE WHEN OBJECT_ID('dbo.SecurityEvent','U') IS NULL THEN 0 ELSE 1 END AS BIT) AS HasSecurityEvent, CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Usuario') AND temporal_type = 2) THEN 1 ELSE 0 END AS BIT) AS UsuarioVersioned """; var result = await _connection.QuerySingleAsync<(bool HasAuditEvent, bool HasSecurityEvent, bool UsuarioVersioned)>(check); if (!result.HasAuditEvent || !result.HasSecurityEvent || !result.UsuarioVersioned) { throw new InvalidOperationException( "V010 audit infrastructure is not applied in the test database. " + "Run: sqlcmd -S -d SIGCM2_Test -U -P -i database/migrations/V010__audit_infrastructure.sql"); } } /// /// Applies V009 schema changes idempotently to the test database. /// Mirrors V009__activate_permisos_overrides.sql. /// Drops and re-adds DF_Usuario_Permisos with the new shape, then migrates legacy rows. /// private async Task EnsureV009SchemaAsync() { const string dropConstraint = """ IF EXISTS ( SELECT 1 FROM sys.default_constraints WHERE name = 'DF_Usuario_Permisos' AND parent_object_id = OBJECT_ID('dbo.Usuario') ) BEGIN ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos; END """; const string addConstraint = """ IF NOT EXISTS ( SELECT 1 FROM sys.default_constraints WHERE name = 'DF_Usuario_Permisos' AND parent_object_id = OBJECT_ID('dbo.Usuario') ) BEGIN ALTER TABLE dbo.Usuario ADD CONSTRAINT DF_Usuario_Permisos DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson; END """; const string migrateRows = """ UPDATE dbo.Usuario SET PermisosJson = '{"grant":[],"deny":[]}' WHERE PermisosJson IN ('[]', '["*"]', '') OR PermisosJson IS NULL OR LTRIM(RTRIM(PermisosJson)) = '' """; await _connection.ExecuteAsync(dropConstraint); await _connection.ExecuteAsync(addConstraint); await _connection.ExecuteAsync(migrateRows); } /// /// ADM-009 (V014): applies dbo.TipoDeIva + dbo.IngresosBrutos schema + temporal tables + seed + permiso fiscal. /// Mirrors V014__create_tablas_fiscales.sql (idempotente). /// Permiso y asignacion se siembran desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync. /// Seed de TipoDeIva e IngresosBrutos se aplica aqui (datos de referencia estables). /// private async Task EnsureV014SchemaAsync() { // ── 1. dbo.TipoDeIva ───────────────────────────────────────────────── const string createTipoDeIva = """ IF OBJECT_ID(N'dbo.TipoDeIva', N'U') IS NULL BEGIN CREATE TABLE dbo.TipoDeIva ( Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_TipoDeIva PRIMARY KEY, Codigo VARCHAR(32) NOT NULL, Descripcion NVARCHAR(100) NOT NULL, Porcentaje DECIMAL(5,2) NOT NULL, AplicaIVA BIT NOT NULL, Activo BIT NOT NULL CONSTRAINT DF_TipoDeIva_Activo DEFAULT(1), VigenciaDesde DATE NOT NULL, VigenciaHasta DATE NULL, PredecesorId INT NULL, FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_TipoDeIva_FechaCreacion DEFAULT(SYSUTCDATETIME()), FechaModificacion DATETIME2(3) NULL, CONSTRAINT CK_TipoDeIva_Porcentaje CHECK (Porcentaje >= 0 AND Porcentaje <= 100), CONSTRAINT CK_TipoDeIva_Vigencia CHECK (VigenciaHasta IS NULL OR VigenciaHasta >= VigenciaDesde), CONSTRAINT UQ_TipoDeIva_Codigo_Vigencia UNIQUE (Codigo, VigenciaDesde), CONSTRAINT FK_TipoDeIva_Predecesor FOREIGN KEY (PredecesorId) REFERENCES dbo.TipoDeIva(Id) ); END """; const string createTipoDeIvaIdx1 = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_TipoDeIva_Codigo_VigenciaDesde' AND object_id = OBJECT_ID('dbo.TipoDeIva')) CREATE INDEX IX_TipoDeIva_Codigo_VigenciaDesde ON dbo.TipoDeIva(Codigo, VigenciaDesde DESC); """; const string createTipoDeIvaIdx2 = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_TipoDeIva_PredecesorId' AND object_id = OBJECT_ID('dbo.TipoDeIva')) CREATE INDEX IX_TipoDeIva_PredecesorId ON dbo.TipoDeIva(PredecesorId) WHERE PredecesorId IS NOT NULL; """; const string addTipoDeIvaPeriod = """ IF COL_LENGTH('dbo.TipoDeIva', 'ValidFrom') IS NULL BEGIN ALTER TABLE dbo.TipoDeIva ADD ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_TipoDeIva_ValidFrom DEFAULT(SYSUTCDATETIME()), ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_TipoDeIva_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); END """; const string setTipoDeIvaVersioning = """ IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.TipoDeIva') AND temporal_type = 2) BEGIN ALTER TABLE dbo.TipoDeIva SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.TipoDeIva_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); END """; // ── 2. dbo.IngresosBrutos ──────────────────────────────────────────── const string createIIBB = """ IF OBJECT_ID(N'dbo.IngresosBrutos', N'U') IS NULL BEGIN CREATE TABLE dbo.IngresosBrutos ( Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_IngresosBrutos PRIMARY KEY, Provincia VARCHAR(50) NOT NULL, Descripcion NVARCHAR(100) NOT NULL, Alicuota DECIMAL(5,2) NOT NULL, Activo BIT NOT NULL CONSTRAINT DF_IIBB_Activo DEFAULT(1), VigenciaDesde DATE NOT NULL, VigenciaHasta DATE NULL, PredecesorId INT NULL, FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_IIBB_FechaCreacion DEFAULT(SYSUTCDATETIME()), FechaModificacion DATETIME2(3) NULL, CONSTRAINT CK_IIBB_Alicuota CHECK (Alicuota >= 0 AND Alicuota <= 100), CONSTRAINT CK_IIBB_Vigencia CHECK (VigenciaHasta IS NULL OR VigenciaHasta >= VigenciaDesde), CONSTRAINT UQ_IIBB_Provincia_Vigencia UNIQUE (Provincia, VigenciaDesde), CONSTRAINT FK_IIBB_Predecesor FOREIGN KEY (PredecesorId) REFERENCES dbo.IngresosBrutos(Id) ); END """; const string createIIBBIdx1 = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_IIBB_Provincia_VigenciaDesde' AND object_id = OBJECT_ID('dbo.IngresosBrutos')) CREATE INDEX IX_IIBB_Provincia_VigenciaDesde ON dbo.IngresosBrutos(Provincia, VigenciaDesde DESC); """; const string createIIBBIdx2 = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_IIBB_PredecesorId' AND object_id = OBJECT_ID('dbo.IngresosBrutos')) CREATE INDEX IX_IIBB_PredecesorId ON dbo.IngresosBrutos(PredecesorId) WHERE PredecesorId IS NOT NULL; """; const string addIIBBPeriod = """ IF COL_LENGTH('dbo.IngresosBrutos', 'ValidFrom') IS NULL BEGIN ALTER TABLE dbo.IngresosBrutos ADD ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_IIBB_ValidFrom DEFAULT(SYSUTCDATETIME()), ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_IIBB_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); END """; const string setIIBBVersioning = """ IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.IngresosBrutos') AND temporal_type = 2) BEGIN ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.IngresosBrutos_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); END """; // ── 3. Seed TipoDeIva ──────────────────────────────────────────────── const string seedTipoDeIva = """ SET QUOTED_IDENTIFIER ON; MERGE dbo.TipoDeIva AS t USING (VALUES ('EXENTO', N'Exento de IVA', CAST(0 AS DECIMAL(5,2)), CAST(0 AS BIT), CAST('2020-01-01' AS DATE)), ('NO_GRAVADO', N'No gravado', CAST(0 AS DECIMAL(5,2)), CAST(0 AS BIT), CAST('2020-01-01' AS DATE)), ('IVA_105', N'IVA alicuota diferencial 10.5%', CAST(10.5 AS DECIMAL(5,2)), CAST(1 AS BIT), CAST('2020-01-01' AS DATE)), ('IVA_21', N'IVA alicuota general 21%', CAST(21 AS DECIMAL(5,2)), CAST(1 AS BIT), CAST('2020-01-01' AS DATE)) ) AS s (Codigo, Descripcion, Porcentaje, AplicaIVA, VigenciaDesde) ON t.Codigo = s.Codigo AND t.PredecesorId IS NULL WHEN NOT MATCHED BY TARGET THEN INSERT (Codigo, Descripcion, Porcentaje, AplicaIVA, Activo, VigenciaDesde, VigenciaHasta, PredecesorId) VALUES (s.Codigo, s.Descripcion, s.Porcentaje, s.AplicaIVA, 1, s.VigenciaDesde, NULL, NULL); """; // ── 4. Seed IngresosBrutos ──────────────────────────────────────────── // 24 filas: 23 provincias INDEC + CABA. Alicuota=0 placeholder. // T700 cleanup: values in PascalCase matching ProvinciaArgentina enum.ToString(). const string seedIIBB = """ SET QUOTED_IDENTIFIER ON; MERGE dbo.IngresosBrutos AS t USING (VALUES ('BuenosAires', N'Ingresos Brutos - Buenos Aires'), ('CiudadAutonomaDeBuenosAires', N'Ingresos Brutos - Ciudad Autonoma de Buenos Aires'), ('Catamarca', N'Ingresos Brutos - Catamarca'), ('Chaco', N'Ingresos Brutos - Chaco'), ('Chubut', N'Ingresos Brutos - Chubut'), ('Cordoba', N'Ingresos Brutos - Cordoba'), ('Corrientes', N'Ingresos Brutos - Corrientes'), ('EntreRios', N'Ingresos Brutos - Entre Rios'), ('Formosa', N'Ingresos Brutos - Formosa'), ('Jujuy', N'Ingresos Brutos - Jujuy'), ('LaPampa', N'Ingresos Brutos - La Pampa'), ('LaRioja', N'Ingresos Brutos - La Rioja'), ('Mendoza', N'Ingresos Brutos - Mendoza'), ('Misiones', N'Ingresos Brutos - Misiones'), ('Neuquen', N'Ingresos Brutos - Neuquen'), ('RioNegro', N'Ingresos Brutos - Rio Negro'), ('Salta', N'Ingresos Brutos - Salta'), ('SanJuan', N'Ingresos Brutos - San Juan'), ('SanLuis', N'Ingresos Brutos - San Luis'), ('SantaCruz', N'Ingresos Brutos - Santa Cruz'), ('SantaFe', N'Ingresos Brutos - Santa Fe'), ('SantiagoDelEstero', N'Ingresos Brutos - Santiago del Estero'), ('TierraDelFuego', N'Ingresos Brutos - Tierra del Fuego'), ('Tucuman', N'Ingresos Brutos - Tucuman') ) AS s (Provincia, Descripcion) ON t.Provincia = s.Provincia AND t.PredecesorId IS NULL WHEN NOT MATCHED BY TARGET THEN INSERT (Provincia, Descripcion, Alicuota, Activo, VigenciaDesde, VigenciaHasta, PredecesorId) VALUES (s.Provincia, s.Descripcion, CAST(0 AS DECIMAL(5,2)), 1, CAST('2020-01-01' AS DATE), NULL, NULL); """; // Apply in order await _connection.ExecuteAsync(createTipoDeIva); await _connection.ExecuteAsync(createTipoDeIvaIdx1); await _connection.ExecuteAsync(createTipoDeIvaIdx2); await _connection.ExecuteAsync(addTipoDeIvaPeriod); await _connection.ExecuteAsync(setTipoDeIvaVersioning); await _connection.ExecuteAsync(createIIBB); await _connection.ExecuteAsync(createIIBBIdx1); await _connection.ExecuteAsync(createIIBBIdx2); await _connection.ExecuteAsync(addIIBBPeriod); await _connection.ExecuteAsync(setIIBBVersioning); await _connection.ExecuteAsync(seedTipoDeIva); await _connection.ExecuteAsync(seedIIBB); // Permiso 'administracion:fiscal:gestionar' y asignacion a admin se siembran // desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). } /// /// UDT-011 (V015): applies dbo.v_AuditEvent_Local + dbo.v_SecurityEvent_Local views /// idempotently to the test database. Mirrors V015__create_local_timezone_views.sql. /// Views expose OccurredAtLocal (DateTimeOffset, offset -03:00 Argentina Standard Time). /// Note: CREATE VIEW cannot be inside IF...BEGIN...END directly — uses EXEC('CREATE VIEW ...'). /// private async Task EnsureV015SchemaAsync() { const string createAuditEventLocal = """ IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NULL BEGIN EXEC(' CREATE VIEW dbo.v_AuditEvent_Local AS SELECT Id, OccurredAt, OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal, ActorUserId, ActorRoleId, Action, TargetType, TargetId, CorrelationId, IpAddress, UserAgent, Metadata FROM dbo.AuditEvent; '); END """; const string createSecurityEventLocal = """ IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NULL BEGIN EXEC(' CREATE VIEW dbo.v_SecurityEvent_Local AS SELECT Id, OccurredAt, OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal, ActorUserId, AttemptedUsername, SessionId, Action, Result, FailureReason, IpAddress, UserAgent, Metadata FROM dbo.SecurityEvent; '); END """; await _connection.ExecuteAsync(createAuditEventLocal); await _connection.ExecuteAsync(createSecurityEventLocal); } /// /// CAT-001 (V016): applies dbo.Rubro schema + temporal + filtered unique index + covering index /// idempotentemente. Mirrors V016__create_rubro.sql. /// Nota: COLLATE debe ir ANTES de NOT NULL — parser de SQL Server 2019 es estricto con ese orden. /// Permiso 'catalogo:rubros:gestionar' y asignación a admin se siembran /// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). /// private async Task EnsureV016SchemaAsync() { const string createRubro = """ IF OBJECT_ID(N'dbo.Rubro', N'U') IS NULL BEGIN CREATE TABLE dbo.Rubro ( Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Rubro PRIMARY KEY, ParentId INT NULL, Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL, Orden INT NOT NULL CONSTRAINT DF_Rubro_Orden DEFAULT(0), Activo BIT NOT NULL CONSTRAINT DF_Rubro_Activo DEFAULT(1), TarifarioBaseId INT NULL, FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Rubro_FechaCreacion DEFAULT(SYSUTCDATETIME()), FechaModificacion DATETIME2(3) NULL, CONSTRAINT FK_Rubro_Parent FOREIGN KEY (ParentId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION ); END """; const string addRubroPeriod = """ IF COL_LENGTH('dbo.Rubro', 'ValidFrom') IS NULL BEGIN ALTER TABLE dbo.Rubro ADD ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_Rubro_ValidFrom DEFAULT(SYSUTCDATETIME()), ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_Rubro_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); END """; const string setRubroVersioning = """ IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rubro') AND temporal_type = 2) BEGIN ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.Rubro_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); END """; const string createUqIndex = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Rubro_ParentId_Nombre_Activo' AND object_id = OBJECT_ID('dbo.Rubro')) BEGIN CREATE UNIQUE INDEX UQ_Rubro_ParentId_Nombre_Activo ON dbo.Rubro(ParentId, Nombre) WHERE Activo = 1 AND ParentId IS NOT NULL; END """; const string createCoveringIndex = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Rubro_ParentId_Activo' AND object_id = OBJECT_ID('dbo.Rubro')) BEGIN CREATE INDEX IX_Rubro_ParentId_Activo ON dbo.Rubro(ParentId, Activo) INCLUDE (Nombre, Orden); END """; await _connection.ExecuteAsync(createRubro); await _connection.ExecuteAsync(addRubroPeriod); await _connection.ExecuteAsync(setRubroVersioning); await _connection.ExecuteAsync(createUqIndex); await _connection.ExecuteAsync(createCoveringIndex); } private async Task EnsureV017SchemaAsync() { const string createProductType = """ IF OBJECT_ID(N'dbo.ProductType', N'U') IS NULL BEGIN CREATE TABLE dbo.ProductType ( Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProductType PRIMARY KEY, Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL, HasDuration BIT NOT NULL CONSTRAINT DF_ProductType_HasDuration DEFAULT(0), RequiresText BIT NOT NULL CONSTRAINT DF_ProductType_RequiresText DEFAULT(0), RequiresCategory BIT NOT NULL CONSTRAINT DF_ProductType_RequiresCategory DEFAULT(0), IsBundle BIT NOT NULL CONSTRAINT DF_ProductType_IsBundle DEFAULT(0), AllowImages BIT NOT NULL CONSTRAINT DF_ProductType_AllowImages DEFAULT(0), MaxImages INT NULL, MaxImageSizeMB DECIMAL(10,2) NULL, MaxImageWidth INT NULL, MaxImageHeight INT NULL, IsActive BIT NOT NULL CONSTRAINT DF_ProductType_IsActive DEFAULT(1), FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_ProductType_FechaCreacion DEFAULT(SYSUTCDATETIME()), FechaModificacion DATETIME2(3) NULL ); END """; const string addProductTypePeriod = """ IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NULL BEGIN ALTER TABLE dbo.ProductType ADD ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_ProductType_ValidFrom DEFAULT(SYSUTCDATETIME()), ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_ProductType_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); END """; const string setProductTypeVersioning = """ IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2) BEGIN ALTER TABLE dbo.ProductType SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.ProductType_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); END """; const string createUqIndex = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_ProductType_Nombre_Activo' AND object_id = OBJECT_ID('dbo.ProductType')) BEGIN CREATE UNIQUE INDEX UQ_ProductType_Nombre_Activo ON dbo.ProductType(Nombre) WHERE IsActive = 1; END """; const string createCoveringIndex = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductType_IsActive_Cover' AND object_id = OBJECT_ID('dbo.ProductType')) BEGIN CREATE INDEX IX_ProductType_IsActive_Cover ON dbo.ProductType(IsActive) INCLUDE (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages); END """; await _connection.ExecuteAsync(createProductType); await _connection.ExecuteAsync(addProductTypePeriod); await _connection.ExecuteAsync(setProductTypeVersioning); await _connection.ExecuteAsync(createUqIndex); await _connection.ExecuteAsync(createCoveringIndex); } /// /// PRD-002 (V018): applies dbo.Product schema + temporal + filtered UQ + covering indexes /// idempotentemente. Mirrors V018__create_product.sql. /// Permiso 'catalogo:productos:gestionar' y asignación a admin se siembran /// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). /// public async Task EnsureV018SchemaAsync() { const string createProduct = """ IF OBJECT_ID(N'dbo.Product', N'U') IS NULL BEGIN CREATE TABLE dbo.Product ( Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY, Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL, MedioId INT NOT NULL, ProductTypeId INT NOT NULL, RubroId INT NULL, BasePrice DECIMAL(18,4) NOT NULL, PriceDurationDays INT NULL, IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1), FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()), FechaModificacion DATETIME2(3) NULL, CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION, CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION, CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0), CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1) ); END """; const string addProductPeriod = """ IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL BEGIN ALTER TABLE dbo.Product ADD ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()), ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); END """; const string setProductVersioning = """ IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2) BEGIN ALTER TABLE dbo.Product SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.Product_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); END """; const string createUqIndex = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product')) BEGIN CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active ON dbo.Product (MedioId, ProductTypeId, Nombre) WHERE IsActive = 1; END """; const string createProductTypeIdx = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) BEGIN CREATE INDEX IX_Product_ProductTypeId_IsActive ON dbo.Product (ProductTypeId, IsActive); END """; const string createMedioIdx = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) BEGIN CREATE INDEX IX_Product_MedioId_IsActive ON dbo.Product (MedioId, IsActive); END """; const string createRubroIdx = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) BEGIN CREATE INDEX IX_Product_RubroId_IsActive ON dbo.Product (RubroId, IsActive) WHERE RubroId IS NOT NULL; END """; await _connection.ExecuteAsync(createProduct); await _connection.ExecuteAsync(addProductPeriod); await _connection.ExecuteAsync(setProductVersioning); await _connection.ExecuteAsync(createUqIndex); await _connection.ExecuteAsync(createProductTypeIdx); await _connection.ExecuteAsync(createMedioIdx); await _connection.ExecuteAsync(createRubroIdx); } /// /// PRD-003 (V019): applies dbo.ProductPrices + SYSTEM_VERSIONING + indexes + SP usp_AddProductPrice /// idempotently to the test database. Mirrors V019__create_product_prices.sql. /// public async Task EnsureV019SchemaAsync() { const string createTable = """ IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NULL BEGIN CREATE TABLE dbo.ProductPrices ( Id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProductPrices PRIMARY KEY, ProductId INT NOT NULL, Price DECIMAL(12,2) NOT NULL, PriceValidFrom DATE NOT NULL, PriceValidTo DATE NULL, FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_ProductPrices_FechaCreacion DEFAULT(SYSUTCDATETIME()), CONSTRAINT FK_ProductPrices_Product FOREIGN KEY (ProductId) REFERENCES dbo.Product(Id) ON DELETE NO ACTION, CONSTRAINT CK_ProductPrices_Price_Positive CHECK (Price > 0), CONSTRAINT CK_ProductPrices_ValidRange CHECK (PriceValidTo IS NULL OR PriceValidTo >= PriceValidFrom) ); END """; const string addPeriod = """ IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NULL BEGIN ALTER TABLE dbo.ProductPrices ADD SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_ProductPrices_SysStartTime DEFAULT(SYSUTCDATETIME()), SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_ProductPrices_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime); END """; const string setVersioning = """ IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2) BEGIN ALTER TABLE dbo.ProductPrices SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.ProductPrices_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); END """; const string createActiveIndex = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ProductPrices_Active' AND object_id = OBJECT_ID('dbo.ProductPrices')) BEGIN CREATE UNIQUE INDEX UX_ProductPrices_Active ON dbo.ProductPrices (ProductId) WHERE PriceValidTo IS NULL; END """; const string createLookupIndex = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductPrices_Lookup' AND object_id = OBJECT_ID('dbo.ProductPrices')) BEGIN CREATE INDEX IX_ProductPrices_Lookup ON dbo.ProductPrices (ProductId, PriceValidFrom DESC) INCLUDE (Price, PriceValidTo); END """; const string createSp = """ IF OBJECT_ID(N'dbo.usp_AddProductPrice', N'P') IS NULL EXEC('CREATE PROCEDURE dbo.usp_AddProductPrice AS RETURN 0'); """; const string alterSp = """ ALTER PROCEDURE dbo.usp_AddProductPrice @ProductId INT, @Price DECIMAL(12,2), @PriceValidFrom DATE, @NewId BIGINT OUTPUT, @ClosedId BIGINT OUTPUT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; BEGIN TRY BEGIN TRANSACTION; IF NOT EXISTS (SELECT 1 FROM dbo.Product WITH (NOLOCK) WHERE Id=@ProductId AND IsActive=1) BEGIN ROLLBACK; THROW 50404, 'Product not found or inactive', 1; END DECLARE @ActiveId BIGINT, @ActivePVF DATE; SELECT TOP 1 @ActiveId = Id, @ActivePVF = PriceValidFrom FROM dbo.ProductPrices WITH (UPDLOCK, HOLDLOCK, ROWLOCK) WHERE ProductId = @ProductId AND PriceValidTo IS NULL; IF @ActiveId IS NOT NULL AND @PriceValidFrom <= @ActivePVF BEGIN ROLLBACK; THROW 50409, 'ProductPriceForwardOnly: new PriceValidFrom must be > active.PriceValidFrom', 1; END IF @ActiveId IS NOT NULL BEGIN UPDATE dbo.ProductPrices SET PriceValidTo = DATEADD(DAY, -1, @PriceValidFrom) WHERE Id = @ActiveId; SET @ClosedId = @ActiveId; END ELSE SET @ClosedId = NULL; INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo) VALUES (@ProductId, @Price, @PriceValidFrom, NULL); SET @NewId = SCOPE_IDENTITY(); COMMIT TRANSACTION; END TRY BEGIN CATCH IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; THROW; END CATCH END """; await _connection.ExecuteAsync(createTable); await _connection.ExecuteAsync(addPeriod); await _connection.ExecuteAsync(setVersioning); await _connection.ExecuteAsync(createActiveIndex); await _connection.ExecuteAsync(createLookupIndex); await _connection.ExecuteAsync(createSp); await _connection.ExecuteAsync(alterSp); } /// /// PRC-001 (V020/V021/V022): applies dbo.ChargeableCharConfig schema + SYSTEM_VERSIONING /// + filtered UX + SPs + permission 'tasacion:caracteres_especiales:gestionar' + seed data. /// Mirrors V020+V021+V022 migrations (idempotente). /// Permission y asignación a admin se siembran desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync. /// IMPORTANT: dbo.ChargeableCharConfig_History must be in TablesToIgnore — SYSTEM_VERSIONING /// prevents Respawn from directly truncating history tables (engine rejects). /// private async Task EnsureV021SchemaAsync() { const string createTable = """ IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NULL BEGIN CREATE TABLE dbo.ChargeableCharConfig ( Id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_ChargeableCharConfig PRIMARY KEY, MedioId INT NULL, Symbol NVARCHAR(4) NOT NULL, Category NVARCHAR(32) NOT NULL, PricePerUnit DECIMAL(18,4) NOT NULL, ValidFrom DATE NOT NULL, ValidTo DATE NULL, IsActive BIT NOT NULL CONSTRAINT DF_ChargeableCharConfig_IsActive DEFAULT(1), FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_ChargeableCharConfig_FechaCreacion DEFAULT(SYSUTCDATETIME()), CONSTRAINT FK_ChargeableCharConfig_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, CONSTRAINT CK_ChargeableCharConfig_Price_Positive CHECK (PricePerUnit > 0), CONSTRAINT CK_ChargeableCharConfig_Symbol_NotEmpty CHECK (LEN(Symbol) > 0), CONSTRAINT CK_ChargeableCharConfig_ValidRange CHECK (ValidTo IS NULL OR ValidTo >= ValidFrom) ); END """; const string addPeriod = """ IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NULL BEGIN ALTER TABLE dbo.ChargeableCharConfig ADD SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_ChargeableCharConfig_SysStartTime DEFAULT(SYSUTCDATETIME()), SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_ChargeableCharConfig_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime); END """; const string setVersioning = """ IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2) BEGIN ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.ChargeableCharConfig_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); END """; const string createVigenteIndex = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente' AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) BEGIN CREATE UNIQUE INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig (MedioId, Symbol) WHERE ValidTo IS NULL; END """; const string createQueryIndex = """ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query' AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) BEGIN CREATE INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo) INCLUDE (PricePerUnit, IsActive, Category); END """; const string createInsertSp = """ IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NULL EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0'); """; const string alterInsertSp = """ ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose @MedioId INT = NULL, @Symbol NVARCHAR(4), @Category NVARCHAR(32), @PricePerUnit DECIMAL(18,4), @ValidFrom DATE, @NewId BIGINT OUTPUT, @ClosedId BIGINT OUTPUT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; BEGIN TRY BEGIN TRANSACTION; IF @MedioId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId) BEGIN ROLLBACK; THROW 50404, 'Medio not found', 1; END DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE; SELECT TOP 1 @ActiveId = Id, @ActiveValidFrom = ValidFrom FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK) WHERE ((@MedioId IS NULL AND MedioId IS NULL) OR (@MedioId IS NOT NULL AND MedioId = @MedioId)) AND Symbol = @Symbol AND ValidTo IS NULL; IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom BEGIN ROLLBACK; THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1; END IF @ActiveId IS NOT NULL BEGIN UPDATE dbo.ChargeableCharConfig SET ValidTo = DATEADD(DAY, -1, @ValidFrom) WHERE Id = @ActiveId; SET @ClosedId = @ActiveId; END ELSE SET @ClosedId = NULL; INSERT INTO dbo.ChargeableCharConfig (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) VALUES (@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1); SET @NewId = SCOPE_IDENTITY(); COMMIT TRANSACTION; END TRY BEGIN CATCH IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; THROW; END CATCH END """; const string createGetActiveSp = """ IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NULL EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio AS RETURN 0'); """; const string alterGetActiveSp = """ ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio @MedioId INT, @AsOfDate DATE AS BEGIN SET NOCOUNT ON; WITH Candidates AS ( SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, ROW_NUMBER() OVER ( PARTITION BY Symbol ORDER BY CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END, ValidFrom DESC ) AS rn FROM dbo.ChargeableCharConfig WHERE IsActive = 1 AND ValidFrom <= @AsOfDate AND (ValidTo IS NULL OR ValidTo >= @AsOfDate) AND (MedioId = @MedioId OR MedioId IS NULL) ) SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive FROM Candidates WHERE rn = 1; END """; const string seedV022 = """ MERGE dbo.ChargeableCharConfig AS t USING (VALUES (NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), (NULL, N'%', N'Percentage', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), (NULL, N'!', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), (NULL, N'¡', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)) ) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom) ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL) WHEN NOT MATCHED THEN INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1); """; await _connection.ExecuteAsync(createTable); await _connection.ExecuteAsync(addPeriod); await _connection.ExecuteAsync(setVersioning); await _connection.ExecuteAsync(createVigenteIndex); await _connection.ExecuteAsync(createQueryIndex); // Guard: only ALTER the V021-era SPs + seed with MedioId if the column still exists. // If V023 already refactored the table in a prior run of the fixture on the same DB, // MedioId is gone and these ALTERs would fail with "Invalid column name 'MedioId'". // EnsureV023SchemaAsync (called right after) will re-install the SPs with @ProductTypeId. const string hasMedioIdCheck = """ SELECT CAST( CASE WHEN EXISTS ( SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND name = 'MedioId' ) THEN 1 ELSE 0 END AS BIT) """; var hasMedioId = await _connection.ExecuteScalarAsync(hasMedioIdCheck); if (hasMedioId) { await _connection.ExecuteAsync(createInsertSp); await _connection.ExecuteAsync(alterInsertSp); await _connection.ExecuteAsync(createGetActiveSp); await _connection.ExecuteAsync(alterGetActiveSp); await _connection.ExecuteAsync(seedV022); } // Permission 'tasacion:caracteres_especiales:gestionar' and admin assignment // are seeded from SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). } /// /// PRC-001 scope delta (V023): refactors dbo.ChargeableCharConfig from MedioId to ProductTypeId. /// Mirrors V023__refactor_chargeable_char_config_to_product_type.sql (idempotente). /// /// Steps (only run if MedioId column still exists — guard for idempotence): /// 1. SYSTEM_VERSIONING OFF /// 2. Drop UX_Vigente + IX_Query (MedioId-based) /// 3. Drop FK_ChargeableCharConfig_Medio /// 4. Drop MedioId column from main + history /// 5. Drop CK_Price_Positive; add CK_Price_NonNegative (>= 0 for opt-in billing) /// 6. Add ProductTypeId column (nullable) to main + history /// 7. Add FK_ChargeableCharConfig_ProductType /// 8. Recreate UX_Vigente + IX_Query (ProductTypeId-based) /// 9. SYSTEM_VERSIONING ON /// 10. Drop+Create usp_ChargeableCharConfig_InsertWithClose (@ProductTypeId) /// 11. Drop usp_ChargeableCharConfig_GetActiveForMedio /// 12. Create usp_ChargeableCharConfig_GetActiveForProductType /// 13. Create usp_ChargeableCharConfig_ReactivateWithGuard (NEW) /// private async Task EnsureV023SchemaAsync() { // ── Guard: only run the ALTER block if MedioId still exists ────────── // SPs are always idempotently recreated (create-if-not-exists + alter pattern). const string checkMedioId = """ SELECT CAST( CASE WHEN EXISTS ( SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND name = 'MedioId' ) THEN 1 ELSE 0 END AS BIT) """; var hasMedioId = await _connection.ExecuteScalarAsync(checkMedioId); if (hasMedioId) { // ── 1. SYSTEM_VERSIONING OFF ────────────────────────────────────── await _connection.ExecuteAsync( "ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF)"); // ── 2. Drop MedioId-based indexes ───────────────────────────────── const string dropIndexes = """ IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente' AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig; IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query' AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig; """; await _connection.ExecuteAsync(dropIndexes); // ── 3. Drop FK to Medio ──────────────────────────────────────────── const string dropFkMedio = """ DECLARE @fk_name sysname; SELECT @fk_name = name FROM sys.foreign_keys WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND referenced_object_id = OBJECT_ID('dbo.Medio'); IF @fk_name IS NOT NULL EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_name); """; await _connection.ExecuteAsync(dropFkMedio); // ── 4. Drop MedioId from main + history ──────────────────────────── const string dropMedioIdMain = """ DECLARE @df_medio sysname; SELECT @df_medio = dc.name FROM sys.default_constraints dc JOIN sys.columns c ON c.default_object_id = dc.object_id WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND c.name = 'MedioId'; IF @df_medio IS NOT NULL EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @df_medio); ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN MedioId; """; await _connection.ExecuteAsync(dropMedioIdMain); const string dropMedioIdHistory = """ IF EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History') AND name = 'MedioId') BEGIN DECLARE @df_hist sysname; SELECT @df_hist = dc.name FROM sys.default_constraints dc JOIN sys.columns c ON c.default_object_id = dc.object_id WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig_History') AND c.name = 'MedioId'; IF @df_hist IS NOT NULL EXEC('ALTER TABLE dbo.ChargeableCharConfig_History DROP CONSTRAINT ' + @df_hist); ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN MedioId; END """; await _connection.ExecuteAsync(dropMedioIdHistory); // ── 5. Replace price check constraint ──────────────────────────────── const string replacePriceCheck = """ IF EXISTS (SELECT 1 FROM sys.check_constraints WHERE name = 'CK_ChargeableCharConfig_Price_Positive' AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_Positive; IF NOT EXISTS (SELECT 1 FROM sys.check_constraints WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative' AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) ALTER TABLE dbo.ChargeableCharConfig ADD CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative CHECK (PricePerUnit >= 0); """; await _connection.ExecuteAsync(replacePriceCheck); // ── 6. Add ProductTypeId to main + history ───────────────────────── await _connection.ExecuteAsync( "ALTER TABLE dbo.ChargeableCharConfig ADD ProductTypeId INT NULL"); await _connection.ExecuteAsync( "ALTER TABLE dbo.ChargeableCharConfig_History ADD ProductTypeId INT NULL"); // ── 7. Add FK to ProductType ─────────────────────────────────────── await _connection.ExecuteAsync(""" ALTER TABLE dbo.ChargeableCharConfig ADD CONSTRAINT FK_ChargeableCharConfig_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION """); // ── 8. Recreate ProductTypeId-based indexes ──────────────────────── await _connection.ExecuteAsync(""" CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig (ProductTypeId, Symbol) WHERE ValidTo IS NULL """); await _connection.ExecuteAsync(""" CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig (ProductTypeId, Symbol, ValidFrom, ValidTo) INCLUDE (PricePerUnit, IsActive, Category) """); // ── 9. SYSTEM_VERSIONING ON ──────────────────────────────────────── await _connection.ExecuteAsync(""" ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.ChargeableCharConfig_History, HISTORY_RETENTION_PERIOD = 10 YEARS )) """); } // ── 10. Recreate InsertWithClose SP (always: idempotent via drop+create) ── const string createInsertSp = """ IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NULL EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0'); """; // Only ALTER if ProductTypeId column exists (meaning table was already refactored or we just did it) const string alterInsertSp = """ ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose @ProductTypeId INT = NULL, @Symbol NVARCHAR(4), @Category NVARCHAR(32), @PricePerUnit DECIMAL(18,4), @ValidFrom DATE, @NewId BIGINT OUTPUT, @ClosedId BIGINT OUTPUT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; BEGIN TRY BEGIN TRANSACTION; IF @ProductTypeId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM dbo.ProductType WITH (NOLOCK) WHERE Id = @ProductTypeId) BEGIN ROLLBACK; THROW 50404, 'ProductType not found', 1; END DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE; SELECT TOP 1 @ActiveId = Id, @ActiveValidFrom = ValidFrom FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK) WHERE ((@ProductTypeId IS NULL AND ProductTypeId IS NULL) OR (@ProductTypeId IS NOT NULL AND ProductTypeId = @ProductTypeId)) AND Symbol = @Symbol AND ValidTo IS NULL; IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom BEGIN ROLLBACK; THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1; END IF @ActiveId IS NOT NULL BEGIN UPDATE dbo.ChargeableCharConfig SET ValidTo = DATEADD(DAY, -1, @ValidFrom) WHERE Id = @ActiveId; SET @ClosedId = @ActiveId; END ELSE SET @ClosedId = NULL; INSERT INTO dbo.ChargeableCharConfig (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) VALUES (@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1); SET @NewId = SCOPE_IDENTITY(); COMMIT TRANSACTION; END TRY BEGIN CATCH IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; THROW; END CATCH END """; await _connection.ExecuteAsync(createInsertSp); await _connection.ExecuteAsync(alterInsertSp); // ── 11. Drop GetActiveForMedio SP ────────────────────────────────────── await _connection.ExecuteAsync(""" IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NOT NULL DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio; """); // ── 12. Create GetActiveForProductType SP ────────────────────────────── const string createGetForPtSp = """ IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForProductType', N'P') IS NULL EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType AS RETURN 0'); """; const string alterGetForPtSp = """ ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType @ProductTypeId INT, @AsOfDate DATE AS BEGIN SET NOCOUNT ON; WITH Candidates AS ( SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, ROW_NUMBER() OVER ( PARTITION BY Symbol ORDER BY CASE WHEN ProductTypeId = @ProductTypeId THEN 0 ELSE 1 END, ValidFrom DESC ) AS rn FROM dbo.ChargeableCharConfig WHERE IsActive = 1 AND ValidFrom <= @AsOfDate AND (ValidTo IS NULL OR ValidTo >= @AsOfDate) AND (ProductTypeId = @ProductTypeId OR ProductTypeId IS NULL) ) SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive FROM Candidates WHERE rn = 1; END """; await _connection.ExecuteAsync(createGetForPtSp); await _connection.ExecuteAsync(alterGetForPtSp); // ── 13. Create ReactivateWithGuard SP (NEW) ──────────────────────────── const string createReactivateSp = """ IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_ReactivateWithGuard', N'P') IS NULL EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard AS RETURN 0'); """; const string alterReactivateSp = """ ALTER PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard @Id BIGINT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; BEGIN TRY BEGIN TRANSACTION; DECLARE @ProductTypeId INT, @Symbol NVARCHAR(4), @ValidTo DATE, @IsActive BIT; SELECT @ProductTypeId = ProductTypeId, @Symbol = Symbol, @ValidTo = ValidTo, @IsActive = IsActive FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK) WHERE Id = @Id; IF @@ROWCOUNT = 0 BEGIN ROLLBACK TRANSACTION; THROW 50404, 'ChargeableCharConfig row not found', 1; END IF @ValidTo IS NULL BEGIN ROLLBACK TRANSACTION; THROW 50410, 'Row is already active — reactivation not needed', 1; END IF EXISTS ( SELECT 1 FROM dbo.ChargeableCharConfig WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL)) AND Symbol = @Symbol AND ValidTo IS NULL ) BEGIN ROLLBACK TRANSACTION; THROW 50411, 'A current active row already exists for this ProductType/Symbol — cannot reactivate', 1; END IF EXISTS ( SELECT 1 FROM dbo.ChargeableCharConfig WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL)) AND Symbol = @Symbol AND ValidFrom > @ValidTo AND Id <> @Id ) BEGIN ROLLBACK TRANSACTION; THROW 50412, 'Posterior rows exist for this ProductType/Symbol — reactivation not allowed', 1; END UPDATE dbo.ChargeableCharConfig SET IsActive = 1, ValidTo = NULL WHERE Id = @Id; COMMIT TRANSACTION; END TRY BEGIN CATCH IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; THROW; END CATCH END """; await _connection.ExecuteAsync(createReactivateSp); await _connection.ExecuteAsync(alterReactivateSp); } /// /// PRC-001 scope delta (V024): reseeds global ChargeableCharConfig rows to PricePerUnit = 0.0000. /// Direct UPDATE — V022 seed price 1.0000 was always a placeholder, no business history exists. /// Safe to re-run: already-zero rows are unchanged. /// Requires ProductTypeId column to exist (V023 must have run). /// private async Task EnsureV024SeedAsync() { const string sql = """ IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND name = 'ProductTypeId') BEGIN UPDATE dbo.ChargeableCharConfig SET PricePerUnit = CAST(0.0000 AS DECIMAL(18,4)) WHERE ProductTypeId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') AND ValidTo IS NULL; END """; await _connection.ExecuteAsync(sql); } }