From 9144c2e89e8bdd4487267b406e53800bfc78a779 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 20 Apr 2026 12:01:49 -0300 Subject: [PATCH] =?UTF-8?q?feat(bd):=20V021=20crea=20dbo.ChargeableCharCon?= =?UTF-8?q?fig=20+=20SPs=20+=20=C3=ADndices=20(PRC-001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/migrations/V021_ROLLBACK.sql | 79 ++++ .../V021__create_chargeable_char_config.sql | 256 ++++++++++++ .../Auth/AuthControllerTests.cs | 5 +- .../Permisos/PermisosEndpointTests.cs | 10 +- .../ChargeableCharConfigMigrationTests.cs | 365 ++++++++++++++++++ .../Integration/PermisoRepositoryTests.cs | 5 +- .../Integration/RolPermisoRepositoryTests.cs | 5 +- tests/SIGCM2.TestSupport/SqlTestFixture.cs | 252 +++++++++++- 8 files changed, 966 insertions(+), 11 deletions(-) create mode 100644 database/migrations/V021_ROLLBACK.sql create mode 100644 database/migrations/V021__create_chargeable_char_config.sql create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs diff --git a/database/migrations/V021_ROLLBACK.sql b/database/migrations/V021_ROLLBACK.sql new file mode 100644 index 0000000..28a7370 --- /dev/null +++ b/database/migrations/V021_ROLLBACK.sql @@ -0,0 +1,79 @@ +-- V021_ROLLBACK.sql +-- PRC-001: Reversa de V021__create_chargeable_char_config.sql. +-- +-- Pasos: +-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ChargeableCharConfig (requerido antes de DROP TABLE). +-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime. +-- 3. Drop de dbo.ChargeableCharConfig_History. +-- 4. Drop de dbo.ChargeableCharConfig (constraints + índices en cascada). +-- 5. Drop de dbo.usp_ChargeableCharConfig_InsertWithClose. +-- 6. Drop de dbo.usp_ChargeableCharConfig_GetActiveForMedio. +-- +-- ADVERTENCIA: destruye toda la configuración de caracteres tasables. Solo DEV/TEST. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- 1. Deshabilita SYSTEM_VERSIONING (imprescindible antes de DROP TABLE temporal). +IF 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 = OFF); + PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = OFF.'; +END +GO + +-- 2. Elimina el PERIOD y las hidden cols. +IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NOT NULL +BEGIN + ALTER TABLE dbo.ChargeableCharConfig + DROP PERIOD FOR SYSTEM_TIME; + + IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysStartTime') + ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysStartTime; + IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysEndTime') + ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysEndTime; + + ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysStartTime; + ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysEndTime; + PRINT 'ChargeableCharConfig: PERIOD + hidden cols dropped.'; +END +GO + +-- 3. Drop de la history table. +IF OBJECT_ID(N'dbo.ChargeableCharConfig_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.ChargeableCharConfig_History; + PRINT 'Table dbo.ChargeableCharConfig_History dropped.'; +END +GO + +-- 4. Drop de la tabla principal. +IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.ChargeableCharConfig; + PRINT 'Table dbo.ChargeableCharConfig dropped.'; +END +GO + +-- 5. Drop del SP InsertWithClose. +IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose; + PRINT 'Procedure dbo.usp_ChargeableCharConfig_InsertWithClose dropped.'; +END +GO + +-- 6. Drop del SP GetActiveForMedio. +IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio; + PRINT 'Procedure dbo.usp_ChargeableCharConfig_GetActiveForMedio dropped.'; +END +GO + +PRINT ''; +PRINT 'V021 rollback complete — dbo.ChargeableCharConfig, dbo.ChargeableCharConfig_History, usp_ChargeableCharConfig_InsertWithClose, usp_ChargeableCharConfig_GetActiveForMedio removed.'; +GO diff --git a/database/migrations/V021__create_chargeable_char_config.sql b/database/migrations/V021__create_chargeable_char_config.sql new file mode 100644 index 0000000..867d403 --- /dev/null +++ b/database/migrations/V021__create_chargeable_char_config.sql @@ -0,0 +1,256 @@ +-- V021__create_chargeable_char_config.sql +-- PRC-001: ChargeableCharConfig — configuración de caracteres especiales tasables con vigencia civil. +-- +-- Cambios: +-- 1. dbo.ChargeableCharConfig (FK Medios NULL=global, SYSTEM_VERSIONING ON, retention 10 años). +-- 2. Índices: filtered UX vigente por (MedioId,Symbol); cover IX para GetActiveForMedio. +-- 3. SP dbo.usp_ChargeableCharConfig_InsertWithClose (SERIALIZABLE + UPDLOCK, forward-only). +-- 4. SP dbo.usp_ChargeableCharConfig_GetActiveForMedio (CTE + ROW_NUMBER per-medio/global). +-- +-- Patrón: V019 (SYSTEM_VERSIONING + PAGE compression + SERIALIZABLE SP). +-- Idempotente: seguro para re-ejecutar. +-- Reversa: V021_ROLLBACK.sql. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. +-- +-- Notas: +-- - SysStartTime/SysEndTime como hidden cols: evita colisión con business cols ValidFrom/ValidTo (D4). +-- - DECIMAL(18,4) para PricePerUnit (mayor granularidad que ProductPrices) (D8). +-- - MedioId NULL = global fallback; per-medio overrides global in GetActiveForMedio (D2/D6). +-- - Forward-only estricto: THROW 50409 si new ValidFrom <= activo.ValidFrom (D9). +-- - UX filtered WHERE ValidTo IS NULL: SQL Server trata (NULL,'$') como valor igual → enforza 1 vigente global (D7). +-- - dbo.ChargeableCharConfig_History debe agregarse a TablesToIgnore en SqlTestFixture.cs (Respawn). +-- +-- SDD Design: engram sdd/prc-001-word-counter-spike/design + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. dbo.ChargeableCharConfig +-- ═══════════════════════════════════════════════════════════════════════ + +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, -- NULL = global fallback + Symbol NVARCHAR(4) NOT NULL, + Category NVARCHAR(32) NOT NULL, -- enum-as-string: Currency/Percentage/Exclamation/Question/Other + 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) + ); + PRINT 'Table dbo.ChargeableCharConfig created.'; +END +ELSE + PRINT 'Table dbo.ChargeableCharConfig already exists — skip.'; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 2. SYSTEM_VERSIONING — ChargeableCharConfig +-- SysStartTime/SysEndTime para no colisionar con business cols ValidFrom/ValidTo (D4). +-- ═══════════════════════════════════════════════════════════════════════ + +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); + PRINT 'ChargeableCharConfig: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).'; +END +GO + +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 + )); + PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).'; +END +ELSE + PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING already ON — skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig_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 = 'ChargeableCharConfig_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.ChargeableCharConfig_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'ChargeableCharConfig_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 3. Índices +-- ═══════════════════════════════════════════════════════════════════════ + +-- Un único vigente por (MedioId, Symbol). +-- SQL Server trata NULL como "distinto" en índices únicos: (NULL,'$') colisiona consigo mismo +-- → enforza exactamente 1 vigente global por símbolo (D7). +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; + PRINT 'Index UX_ChargeableCharConfig_Vigente created.'; +END +GO + +-- Cover para GetActiveForMedio y List. +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); + PRINT 'Index IX_ChargeableCharConfig_Query created.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 4. SP — dbo.usp_ChargeableCharConfig_InsertWithClose +-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK. +-- @MedioId NULL = global; existencia validada sólo cuando NOT NULL. +-- THROW 50404: Medio not found. +-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom. +-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio). +-- ═══════════════════════════════════════════════════════════════════════ +GO +CREATE OR 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; + + -- Validar MedioId sólo cuando se proporciona (NULL = global fallback siempre válido). + 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 + + -- Lee el vigente actual con bloqueo de rango para serialización. + 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; + + -- Forward-only estricto: new ValidFrom debe ser ESTRICTAMENTE mayor al activo. + IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom + BEGIN + ROLLBACK; + THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1; + END + + -- Cierra el vigente previo: ValidTo = ValidFrom(nuevo) - 1 día. + 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; + + -- Inserta el nuevo vigente. + 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 +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 5. SP — dbo.usp_ChargeableCharConfig_GetActiveForMedio +-- Resolución per-medio + global fallback: 1 fila por Symbol. +-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-medio(0) vs global(1). +-- ═══════════════════════════════════════════════════════════════════════ +GO +CREATE OR 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, -- prefer specific over global + 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 +GO + +PRINT ''; +PRINT 'V021 applied — dbo.ChargeableCharConfig (temporal, retention 10y) + UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query + usp_ChargeableCharConfig_InsertWithClose + usp_ChargeableCharConfig_GetActiveForMedio.'; +PRINT 'Next migration: V022 (seed ChargeableCharConfig).'; +GO diff --git a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs index 7eeb4b8..a7a644b 100644 --- a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs @@ -52,8 +52,9 @@ public class AuthControllerTests // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 - // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total - Assert.Equal(27, permisos.GetArrayLength()); + // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 + // V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' → 28 total + Assert.Equal(28, permisos.GetArrayLength()); } // Scenario: invalid credentials return 401 with opaque error diff --git a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs index a363606..70d5952 100644 --- a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs @@ -142,8 +142,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 - // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total - Assert.Equal(27, list.GetArrayLength()); + // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 + // V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' → 28 total + Assert.Equal(28, list.GetArrayLength()); } [Fact] @@ -199,8 +200,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 - // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total - Assert.Equal(27, list.GetArrayLength()); + // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 + // V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' → 28 total + Assert.Equal(28, list.GetArrayLength()); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs new file mode 100644 index 0000000..821662e --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs @@ -0,0 +1,365 @@ +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using Xunit; + +namespace SIGCM2.Application.Tests.Infrastructure.Pricing; + +/// +/// PRC-001 Batch 1 (RED) — Integration tests for V020/V021/V022 migrations. +/// These tests verify the BD schema applied against SIGCM2_Test_App: +/// - V020: permission 'tasacion:caracteres_especiales:gestionar' exists. +/// - V021: dbo.ChargeableCharConfig table + SYSTEM_VERSIONING + filtered UX + SPs. +/// - V022: 4 global seed rows ($, %, !, ¡) exist and are active. +/// +/// Tests are tagged [RED] until V020+V021+V022 are applied (Batch 1 GREEN step). +/// After GREEN, all tests in this class should pass. +/// +[Collection("Database")] +public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime +{ + private SqlConnection _connection = null!; + + public async Task InitializeAsync() + { + _connection = new SqlConnection(TestConnectionStrings.AppTestDb); + await _connection.OpenAsync(); + } + + public async Task DisposeAsync() + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + // ── V021: Table existence ───────────────────────────────────────────── + + [Fact] + public async Task V021_Table_ChargeableCharConfig_Exists() + { + var exists = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); + + exists.Should().Be(1, "V021 debe crear dbo.ChargeableCharConfig"); + } + + [Fact] + public async Task V021_Table_ChargeableCharConfig_HasSystemVersioning() + { + var temporalType = await _connection.ExecuteScalarAsync(@" + SELECT temporal_type + FROM sys.tables + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); + + temporalType.Should().Be(2, "SYSTEM_VERSIONING = ON requiere temporal_type = 2"); + } + + [Fact] + public async Task V021_HistoryTable_ChargeableCharConfigHistory_Exists() + { + var exists = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')"); + + exists.Should().Be(1, "SYSTEM_VERSIONING debe crear dbo.ChargeableCharConfig_History"); + } + + [Fact] + public async Task V021_FilteredUniqueIndex_UX_ChargeableCharConfig_Vigente_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.indexes + WHERE name = 'UX_ChargeableCharConfig_Vigente' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); + + exists.Should().Be(1, "UX_ChargeableCharConfig_Vigente filtered index debe existir"); + } + + [Fact] + public async Task V021_CoverIndex_IX_ChargeableCharConfig_Query_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.indexes + WHERE name = 'IX_ChargeableCharConfig_Query' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); + + exists.Should().Be(1, "IX_ChargeableCharConfig_Query covering index debe existir"); + } + + // ── V021: SP — usp_ChargeableCharConfig_InsertWithClose ────────────── + + [Fact] + public async Task V021_SP_InsertWithClose_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.objects + WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose') + AND type = 'P'"); + + exists.Should().Be(1, "usp_ChargeableCharConfig_InsertWithClose debe existir"); + } + + [Fact] + public async Task usp_InsertWithClose_FirstPrice_ClosedIsNull() + { + // Cleanup + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'@'"); + + var p = new DynamicParameters(); + p.Add("@MedioId", null, System.Data.DbType.Int32); + p.Add("@Symbol", "@", System.Data.DbType.String); + p.Add("@Category", "Other",System.Data.DbType.String); + p.Add("@PricePerUnit", 1.5m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync( + "dbo.usp_ChargeableCharConfig_InsertWithClose", + p, + commandType: System.Data.CommandType.StoredProcedure); + + var newId = p.Get("@NewId"); + var closedId = p.Get("@ClosedId"); + + newId.Should().BeGreaterThan(0, "primer insert debe devolver NewId > 0"); + closedId.Should().BeNull("primer insert no cierra nada — ClosedId debe ser NULL"); + + // Cleanup + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'@' AND MedioId IS NULL"); + } + + [Fact] + public async Task usp_InsertWithClose_HappyPath_ClosesAndCreates() + { + // Seed primer activo + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'€'"); + + var p1 = new DynamicParameters(); + p1.Add("@MedioId", null, System.Data.DbType.Int32); + p1.Add("@Symbol", "€", System.Data.DbType.String); + p1.Add("@Category", "Currency", System.Data.DbType.String); + p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p1.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, + commandType: System.Data.CommandType.StoredProcedure); + var firstId = p1.Get("@NewId")!.Value; + + // Insertar segundo (debe cerrar el primero) + var p2 = new DynamicParameters(); + p2.Add("@MedioId", null, System.Data.DbType.Int32); + p2.Add("@Symbol", "€", System.Data.DbType.String); + p2.Add("@Category", "Currency", System.Data.DbType.String); + p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p2.Add("@ValidFrom", new DateTime(2026, 2, 1), System.Data.DbType.Date); + p2.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p2, + commandType: System.Data.CommandType.StoredProcedure); + + var newId = p2.Get("@NewId")!.Value; + var closedId = p2.Get("@ClosedId"); + + newId.Should().BeGreaterThan(firstId); + closedId.Should().Be(firstId, "el primer activo debe ser cerrado"); + + // Verify el activo cerrado tiene ValidTo = 2026-01-31 + var closedValidTo = await _connection.ExecuteScalarAsync( + "SELECT ValidTo FROM dbo.ChargeableCharConfig WHERE Id = @Id", + new { Id = firstId }); + closedValidTo.Should().Be(new DateTime(2026, 1, 31), + "ValidTo del cerrado = ValidFrom(nuevo) - 1 día"); + + // Cleanup + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'€' AND MedioId IS NULL"); + } + + [Fact] + public async Task usp_InsertWithClose_ForwardOnlyViolation_Throws50409() + { + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'£'"); + + var p1 = new DynamicParameters(); + p1.Add("@MedioId", null, System.Data.DbType.Int32); + p1.Add("@Symbol", "£", System.Data.DbType.String); + p1.Add("@Category", "Currency", System.Data.DbType.String); + p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p1.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); + p1.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, + commandType: System.Data.CommandType.StoredProcedure); + + // Intento con ValidFrom <= activo.ValidFrom → debe lanzar 50409 + var p2 = new DynamicParameters(); + p2.Add("@MedioId", null, System.Data.DbType.Int32); + p2.Add("@Symbol", "£", System.Data.DbType.String); + p2.Add("@Category", "Currency", System.Data.DbType.String); + p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p2.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); // igual → viola forward-only + p2.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + var act = async () => await _connection.ExecuteAsync( + "dbo.usp_ChargeableCharConfig_InsertWithClose", p2, + commandType: System.Data.CommandType.StoredProcedure); + + await act.Should().ThrowAsync() + .Where(ex => ex.Number == 50409, + "ValidFrom igual al activo debe generar THROW 50409 (forward-only)"); + + // Cleanup + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'£' AND MedioId IS NULL"); + } + + [Fact] + public async Task usp_InsertWithClose_MedioNull_GlobalFallback_Works() + { + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'¥'"); + + var p = new DynamicParameters(); + p.Add("@MedioId", null, System.Data.DbType.Int32); + p.Add("@Symbol", "¥", System.Data.DbType.String); + p.Add("@Category", "Currency", System.Data.DbType.String); + p.Add("@PricePerUnit", 1.25m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p, + commandType: System.Data.CommandType.StoredProcedure); + + var newId = p.Get("@NewId"); + newId.Should().BeGreaterThan(0, "global insert (MedioId NULL) debe funcionar"); + + // Cleanup + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'¥' AND MedioId IS NULL"); + } + + [Fact] + public async Task SystemVersioning_UpdateOnClose_ProducesHistoryRow() + { + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'#'"); + + var p1 = new DynamicParameters(); + p1.Add("@MedioId", null, System.Data.DbType.Int32); + p1.Add("@Symbol", "#", System.Data.DbType.String); + p1.Add("@Category", "Other", System.Data.DbType.String); + p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p1.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, + commandType: System.Data.CommandType.StoredProcedure); + var firstId = p1.Get("@NewId")!.Value; + + // Close it by inserting a newer version + var p2 = new DynamicParameters(); + p2.Add("@MedioId", null, System.Data.DbType.Int32); + p2.Add("@Symbol", "#", System.Data.DbType.String); + p2.Add("@Category", "Other", System.Data.DbType.String); + p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p2.Add("@ValidFrom", new DateTime(2026, 6, 1), System.Data.DbType.Date); + p2.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p2, + commandType: System.Data.CommandType.StoredProcedure); + + // The UPDATE on the closed row should produce a history row + var historyCount = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id", + new { Id = firstId }); + + historyCount.Should().BeGreaterThanOrEqualTo(1, + "UPDATE en SYSTEM_VERSIONED table debe producir al menos 1 fila en history"); + + // Cleanup + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'#' AND MedioId IS NULL"); + } + + // ── V021: SP — usp_ChargeableCharConfig_GetActiveForMedio ──────────── + + [Fact] + public async Task V021_SP_GetActiveForMedio_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.objects + WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio') + AND type = 'P'"); + + exists.Should().Be(1, "usp_ChargeableCharConfig_GetActiveForMedio debe existir"); + } + + // ── V020: Permission existence ──────────────────────────────────────── + + [Fact] + public async Task V020_Permission_tasacion_caracteres_especiales_gestionar_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) FROM dbo.Permiso + WHERE Codigo = 'tasacion:caracteres_especiales:gestionar'"); + + exists.Should().Be(1, + "V020 debe insertar el permiso 'tasacion:caracteres_especiales:gestionar'"); + } + + // ── V022: Seed rows ─────────────────────────────────────────────────── + + [Fact] + public async Task V022_Seeds_AtLeastFourGlobalRows() + { + var count = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) FROM dbo.ChargeableCharConfig + WHERE MedioId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') + AND ValidTo IS NULL AND IsActive = 1"); + + count.Should().Be(4, "V022 debe sembrar exactamente 4 filas globales: $, %, !, ¡"); + } + + [Fact] + public async Task V022_AllSeedRowsHaveIsActive_True() + { + var inactiveCount = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) FROM dbo.ChargeableCharConfig + WHERE MedioId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') + AND IsActive = 0"); + + inactiveCount.Should().Be(0, "todas las filas de seed deben tener IsActive = 1"); + } +} diff --git a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs index 1f1f9e4..961b8c0 100644 --- a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs @@ -83,8 +83,9 @@ public class PermisoRepositoryTests : IAsyncLifetime // + V014 (ADM-009) adds 'administracion:fiscal:gestionar' // + V016 (CAT-001) adds 'catalogo:rubros:gestionar' // + V017 (PRD-001) adds 'catalogo:tipos:gestionar' - // + V018 (PRD-002) adds 'catalogo:productos:gestionar' = 27 total - Assert.Equal(27, list.Count); + // + V018 (PRD-002) adds 'catalogo:productos:gestionar' + // + V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' = 28 total + Assert.Equal(28, list.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs index dc21349..9a2b887 100644 --- a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs @@ -181,10 +181,11 @@ public class RolPermisoRepositoryTests : IAsyncLifetime // + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar' // + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar' // + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar' - // + 1 from V018 (PRD-002): 'catalogo:productos:gestionar' = 27 total + // + 1 from V018 (PRD-002): 'catalogo:productos:gestionar' + // + 1 from V020 (PRC-001): 'tasacion:caracteres_especiales:gestionar' = 28 total var permisos = await _repository.GetByRolCodigoAsync("admin"); - Assert.Equal(27, permisos.Count); + Assert.Equal(28, permisos.Count); } [Fact] diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index f2bf05a..775cdaa 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -69,6 +69,9 @@ public sealed class SqlTestFixture : IAsyncLifetime // 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(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, @@ -101,6 +104,8 @@ public sealed class SqlTestFixture : IAsyncLifetime 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"), ] }); @@ -122,6 +127,7 @@ public sealed class SqlTestFixture : IAsyncLifetime await SeedRolPermisosCanonicalAsync(); await SeedAdminAsync(); await SeedMediosCanonicalAsync(); + await SeedChargeableCharConfigCanonicalAsync(); } private async Task SeedRolCanonicalAsync() @@ -227,7 +233,9 @@ public sealed class SqlTestFixture : IAsyncLifetime -- 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') + ('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 @@ -279,6 +287,8 @@ public sealed class SqlTestFixture : IAsyncLifetime ('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'), @@ -563,6 +573,34 @@ public sealed class SqlTestFixture : IAsyncLifetime await _connection.ExecuteAsync(sql); } + /// + /// PRC-001 (V022): re-seeds the 4 global ChargeableCharConfig defaults after each Respawn. + /// Mirrors V022__seed_chargeable_char_config.sql (MERGE idempotente). + /// The table itself is never added to TablesToIgnore because per-medio test rows + /// must be reset between test classes — only the 4 global defaults are reseeded. + /// + private async Task SeedChargeableCharConfigCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL + BEGIN + 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); + 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 @@ -1267,4 +1305,216 @@ public sealed class SqlTestFixture : IAsyncLifetime 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); + 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). + } }