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