feat(bd): V021 crea dbo.ChargeableCharConfig + SPs + índices (PRC-001)

This commit is contained in:
2026-04-20 12:01:49 -03:00
parent dd4d4a1673
commit 9144c2e89e
8 changed files with 966 additions and 11 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -0,0 +1,365 @@
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// <summary>
/// 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.
/// </summary>
[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<int>(
"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<int?>(@"
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<int>(
"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<int>(@"
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<int>(@"
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<int>(@"
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<long?>("@NewId");
var closedId = p.Get<long?>("@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<long?>("@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<long?>("@NewId")!.Value;
var closedId = p2.Get<long?>("@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<DateTime?>(
"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<SqlException>()
.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<long?>("@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<long?>("@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<int>(
"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<int>(@"
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<int>(@"
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<int>(@"
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<int>(@"
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");
}
}

View File

@@ -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]

View File

@@ -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]

View File

@@ -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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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);
}
/// <summary>
/// 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).
/// </summary>
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).
}
}