refactor(bd): V023+V024 ChargeableCharConfig por ProductType + SP ReactivateWithGuard (PRC-001)

BREAKING: schema refactor pre-merge. Backend+frontend do not compile yet;
subsequent commits in this PR restore compilation. Acceptable only because
feature/PRC-001 is not yet merged to main.

- V023: drop MedioId + FK_Medio, add ProductTypeId + FK_ProductType, rename
  indexes, drop+create SPs InsertWithClose (now @ProductTypeId) and
  GetActiveForProductType (renamed from GetActiveForMedio). NEW SP
  ReactivateWithGuard (A+guard pattern for feature 3 of scope delta).
  Drop CK_Price_Positive, add CK_Price_NonNegative (>= 0 for opt-in billing).
- V024: reseed global rows with PricePerUnit = 0.0000 (opt-in billing).
- V023_ROLLBACK + V024_ROLLBACK scripts.
- SqlTestFixture: EnsureV023SchemaAsync, EnsureV024SeedAsync, renamed seed
  method signature (ProductTypeId=NULL + PricePerUnit=0), history table
  TablesToIgnore preserved. HardeningTests seeds dbo.ProductType (not Medio).
- MigrationTests: updated SP existence + column + FK + price assertions.
- RepositoryIntegrationTests + HardeningTests: SQL-level assertions updated;
  C# method/property renames deferred to Agent 2 (backend refactor).
This commit is contained in:
2026-04-21 10:35:38 -03:00
parent 5175cc1ece
commit 5c1675e59a
8 changed files with 1390 additions and 160 deletions

View File

@@ -0,0 +1,246 @@
-- V023_ROLLBACK.sql
-- PRC-001: Reversa de V023__refactor_chargeable_char_config_to_product_type.sql.
--
-- ADVERTENCIA: rollback destructivo — elimina ProductTypeId y restaura MedioId.
-- - Todos los datos de ProductTypeId se pierden.
-- - Las filas globales (ProductTypeId NULL) se preservan como globales (MedioId NULL).
-- - El historial temporal puede quedar inconsistente si la tabla fue modificada después.
--
-- Solo para uso en DEV/TEST. No ejecutar en producción si hay datos de ProductTypeId.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ─── 1. Drop new SPs ────────────────────────────────────────────────────────
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard;
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_ReactivateWithGuard dropped.';
END
GO
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForProductType', 'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType;
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_GetActiveForProductType dropped.';
END
GO
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_InsertWithClose dropped.';
END
GO
-- ─── 2. Reverse table alterations if ProductTypeId column exists ─────────────
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
AND EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'ProductTypeId')
BEGIN
-- 2a. Turn off SYSTEM_VERSIONING
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = OFF.';
-- 2b. Drop indexes on ProductTypeId
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente dropped.';
END
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query dropped.';
END
-- 2c. Drop FK to ProductType
DECLARE @fk_pt sysname;
SELECT @fk_pt = name
FROM sys.foreign_keys
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND referenced_object_id = OBJECT_ID('dbo.ProductType');
IF @fk_pt IS NOT NULL
BEGIN
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_pt);
PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_ProductType dropped.';
END
-- 2d. Drop NonNegative price check; restore Positive check
IF EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative;
PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_NonNegative dropped.';
END
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT CK_ChargeableCharConfig_Price_Positive CHECK (PricePerUnit > 0);
PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_Positive restored.';
END
-- 2e. Drop ProductTypeId column from main + history
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN ProductTypeId;
PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig.';
IF EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
AND name = 'ProductTypeId')
BEGIN
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN ProductTypeId;
PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig_History.';
END
-- 2f. Restore MedioId column
ALTER TABLE dbo.ChargeableCharConfig ADD MedioId INT NULL;
ALTER TABLE dbo.ChargeableCharConfig_History ADD MedioId INT NULL;
PRINT 'V023 ROLLBACK: MedioId restored.';
-- 2g. Restore FK to Medio
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT FK_ChargeableCharConfig_Medio
FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION;
PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_Medio restored.';
-- 2h. Restore indexes on MedioId
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
ON dbo.ChargeableCharConfig (MedioId, Symbol)
WHERE ValidTo IS NULL;
PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente restored (MedioId).';
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
INCLUDE (PricePerUnit, IsActive, Category);
PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query restored (MedioId).';
-- 2i. Restore SYSTEM_VERSIONING
ALTER TABLE dbo.ChargeableCharConfig
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = ON restored.';
END
ELSE
PRINT 'V023 ROLLBACK: ProductTypeId column not found — table already in MedioId state or missing, skipping.';
GO
-- ─── 3. Restore original SPs ────────────────────────────────────────────────
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NULL
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0');
GO
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
GO
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NULL
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio AS RETURN 0');
GO
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
GO
PRINT '';
PRINT 'V023 ROLLBACK complete — ChargeableCharConfig restored to MedioId model.';
GO

View File

@@ -0,0 +1,406 @@
-- V023__refactor_chargeable_char_config_to_product_type.sql
-- PRC-001 scope delta: ChargeableCharConfig per ProductType (reemplaza per-Medio).
--
-- Cambios:
-- 1. DROP MedioId + FK_ChargeableCharConfig_Medio + índices que lo referencian.
-- 2. ADD ProductTypeId (nullable = global fallback) + FK_ChargeableCharConfig_ProductType.
-- 3. Recrea índices con ProductTypeId (UX_Vigente + IX_Query).
-- 4. DROP+CREATE usp_ChargeableCharConfig_InsertWithClose (@MedioId → @ProductTypeId).
-- 5. DROP usp_ChargeableCharConfig_GetActiveForMedio + CREATE usp_ChargeableCharConfig_GetActiveForProductType.
-- 6. NEW SP usp_ChargeableCharConfig_ReactivateWithGuard (opción A+guard para feature 3).
-- 7. DROP CK_ChargeableCharConfig_Price_Positive (se permite 0.0000 para opt-in billing).
-- Reemplaza con CK_ChargeableCharConfig_Price_NonNegative (>= 0).
--
-- Patrón: idempotente con IF EXISTS guards. Bloque principal protegido por la presencia
-- de la columna MedioId — si no existe ya fue refactorizada, el bloque no ejecuta.
-- SYSTEM_VERSIONING: OFF al inicio del ALTER block, ON al final (con history table + retention).
-- Depende de: V017 (dbo.ProductType debe existir).
-- Reversa: V023_ROLLBACK.sql.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
--
-- SDD Design: engram sdd/prc-001-word-counter-spike/design
-- Scope delta: engram sdd/prc-001-word-counter-spike/scope-delta-1
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- Bloque principal: solo ejecuta si la tabla existe Y todavía tiene MedioId
-- (guard idempotente: si ya fue refactorizada, el bloque se saltea completo).
-- ═══════════════════════════════════════════════════════════════════════
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
AND EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'MedioId')
BEGIN
PRINT 'V023: MedioId column found — proceeding with refactor.';
-- ─── 1. Turn OFF SYSTEM_VERSIONING ─────────────────────────────────
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
PRINT 'V023: SYSTEM_VERSIONING = OFF.';
-- ─── 2. Drop indexes that reference MedioId ────────────────────────
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
PRINT 'V023: UX_ChargeableCharConfig_Vigente dropped.';
END
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
PRINT 'V023: IX_ChargeableCharConfig_Query dropped.';
END
-- ─── 3. Drop FK to Medio ────────────────────────────────────────────
DECLARE @fk_name sysname;
SELECT @fk_name = name
FROM sys.foreign_keys
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND referenced_object_id = OBJECT_ID('dbo.Medio');
IF @fk_name IS NOT NULL
BEGIN
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_name);
PRINT 'V023: FK_ChargeableCharConfig_Medio dropped.';
END
-- ─── 4. Drop MedioId column (drop DF constraint first if present) ───
DECLARE @df_medio sysname;
SELECT @df_medio = dc.name
FROM sys.default_constraints dc
JOIN sys.columns c ON c.default_object_id = dc.object_id
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND c.name = 'MedioId';
IF @df_medio IS NOT NULL
BEGIN
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @df_medio);
PRINT 'V023: Default constraint on MedioId dropped.';
END
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN MedioId;
PRINT 'V023: MedioId column dropped from ChargeableCharConfig.';
-- Drop MedioId from history table if present
IF EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
AND name = 'MedioId')
BEGIN
-- Drop default constraint on history MedioId if any
DECLARE @df_hist_medio sysname;
SELECT @df_hist_medio = dc.name
FROM sys.default_constraints dc
JOIN sys.columns c ON c.default_object_id = dc.object_id
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
AND c.name = 'MedioId';
IF @df_hist_medio IS NOT NULL
EXEC('ALTER TABLE dbo.ChargeableCharConfig_History DROP CONSTRAINT ' + @df_hist_medio);
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN MedioId;
PRINT 'V023: MedioId column dropped from ChargeableCharConfig_History.';
END
-- ─── 5. Drop CK_Price_Positive, replace with CK_Price_NonNegative ──
-- V024 seeds PricePerUnit = 0.0000 (opt-in billing). Old check (> 0) would block it.
IF EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_Positive;
PRINT 'V023: CK_ChargeableCharConfig_Price_Positive dropped.';
END
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative
CHECK (PricePerUnit >= 0);
PRINT 'V023: CK_ChargeableCharConfig_Price_NonNegative added (>= 0, opt-in billing).';
END
-- ─── 6. Add ProductTypeId column ────────────────────────────────────
ALTER TABLE dbo.ChargeableCharConfig
ADD ProductTypeId INT NULL; -- NULL = global fallback
PRINT 'V023: ProductTypeId column added to ChargeableCharConfig.';
ALTER TABLE dbo.ChargeableCharConfig_History
ADD ProductTypeId INT NULL;
PRINT 'V023: ProductTypeId column added to ChargeableCharConfig_History.';
-- ─── 7. Add FK to ProductType ────────────────────────────────────────
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT FK_ChargeableCharConfig_ProductType
FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION;
PRINT 'V023: FK_ChargeableCharConfig_ProductType added.';
-- ─── 8. Recreate filtered unique index with ProductTypeId ────────────
-- 1 vigente per (ProductTypeId, Symbol). NULL ProductTypeId = global fallback.
-- SQL Server trata NULL como "distinto" en unique indexes → enforza 1 vigente global.
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol)
WHERE ValidTo IS NULL;
PRINT 'V023: UX_ChargeableCharConfig_Vigente recreated (ProductTypeId, Symbol).';
-- ─── 9. Recreate cover index with ProductTypeId ──────────────────────
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol, ValidFrom, ValidTo)
INCLUDE (PricePerUnit, IsActive, Category);
PRINT 'V023: IX_ChargeableCharConfig_Query recreated (ProductTypeId).';
-- ─── 10. Turn SYSTEM_VERSIONING back ON ──────────────────────────────
ALTER TABLE dbo.ChargeableCharConfig
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'V023: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).';
END
ELSE
BEGIN
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
PRINT 'V023: dbo.ChargeableCharConfig does not exist — skipping table refactor.';
ELSE
PRINT 'V023: MedioId column not found — table already refactored, skipping.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- SP: usp_ChargeableCharConfig_InsertWithClose (@ProductTypeId replaces @MedioId)
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
-- @ProductTypeId NULL = global; FK validada solo cuando NOT NULL (via referential integrity).
-- THROW 50404: ProductType not found.
-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom.
-- Output: @NewId (BIGINT), @ClosedId (BIGINT — NULL if first price for symbol).
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
GO
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
@ProductTypeId INT = NULL,
@Symbol NVARCHAR(4),
@Category NVARCHAR(32),
@PricePerUnit DECIMAL(18,4),
@ValidFrom DATE,
@NewId BIGINT OUTPUT,
@ClosedId BIGINT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
-- Validate ProductTypeId only when provided (NULL = global fallback, always valid).
-- FK constraint handles referential integrity; we throw 50404 explicitly for better UX.
IF @ProductTypeId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM dbo.ProductType WITH (NOLOCK) WHERE Id = @ProductTypeId)
BEGIN
ROLLBACK;
THROW 50404, 'ProductType not found', 1;
END
-- Read current vigente with range lock for serialization.
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
SELECT TOP 1
@ActiveId = Id,
@ActiveValidFrom = ValidFrom
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
WHERE ((@ProductTypeId IS NULL AND ProductTypeId IS NULL)
OR (@ProductTypeId IS NOT NULL AND ProductTypeId = @ProductTypeId))
AND Symbol = @Symbol
AND ValidTo IS NULL;
-- Forward-only strict: new ValidFrom must be STRICTLY greater than active.ValidFrom.
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
BEGIN
ROLLBACK;
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
END
-- Close the current vigente: ValidTo = new ValidFrom - 1 day.
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 the new vigente.
INSERT INTO dbo.ChargeableCharConfig
(ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES
(@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
SET @NewId = SCOPE_IDENTITY();
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- SP: drop old GetActiveForMedio (renamed to GetActiveForProductType)
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
PRINT 'V023: usp_ChargeableCharConfig_GetActiveForMedio dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- SP: usp_ChargeableCharConfig_GetActiveForProductType
-- Resolución per-ProductType + global fallback: 1 fila por Symbol.
-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-PT(0) vs global(1).
-- @ProductTypeId: the specific product type to resolve for.
-- @AsOfDate: resolve active rows as of this date (for pricing snapshot).
-- ═══════════════════════════════════════════════════════════════════════
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType
@ProductTypeId INT,
@AsOfDate DATE
AS
BEGIN
SET NOCOUNT ON;
WITH Candidates AS (
SELECT
Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
ROW_NUMBER() OVER (
PARTITION BY Symbol
ORDER BY
CASE WHEN ProductTypeId = @ProductTypeId THEN 0 ELSE 1 END, -- 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 (ProductTypeId = @ProductTypeId OR ProductTypeId IS NULL)
)
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM Candidates
WHERE rn = 1;
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- SP: usp_ChargeableCharConfig_ReactivateWithGuard (NEW — feature 3 of scope delta)
-- Opción A+guard: literal undo of the last close for (ProductTypeId, Symbol).
-- Guards:
-- - Row must exist → THROW 50404
-- - Row must be closed (ValidTo IS NOT NULL, IsActive = 0) → THROW 50410 if already active
-- - No vigente currently exists for (ProductTypeId, Symbol) → THROW 50411
-- - No posterior rows exist for (ProductTypeId, Symbol) → THROW 50412
-- On success: UPDATE IsActive = 1, ValidTo = NULL (literal undo).
-- Preserves forward-only invariant and maintains clean history.
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard;
GO
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard
@Id BIGINT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
-- Step 1: Lock + load target row.
DECLARE @ProductTypeId INT, @Symbol NVARCHAR(4), @ValidTo DATE, @IsActive BIT;
SELECT @ProductTypeId = ProductTypeId,
@Symbol = Symbol,
@ValidTo = ValidTo,
@IsActive = IsActive
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK)
WHERE Id = @Id;
IF @@ROWCOUNT = 0
BEGIN
ROLLBACK TRANSACTION;
THROW 50404, 'ChargeableCharConfig row not found', 1;
END
-- Step 2: Row must be closed (ValidTo IS NOT NULL and IsActive = 0).
-- If it is currently active (ValidTo IS NULL), reactivation is nonsensical.
IF @ValidTo IS NULL
BEGIN
ROLLBACK TRANSACTION;
THROW 50410, 'Row is already active — reactivation not needed', 1;
END
-- Step 3: GUARD — no vigente currently for (ProductTypeId, Symbol).
-- Prevents re-opening a row while another is already vigente.
IF EXISTS (
SELECT 1 FROM dbo.ChargeableCharConfig
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
AND Symbol = @Symbol
AND ValidTo IS NULL
)
BEGIN
ROLLBACK TRANSACTION;
THROW 50411, 'A current active row already exists for this ProductType/Symbol — cannot reactivate', 1;
END
-- Step 4: GUARD — no posterior rows exist for (ProductTypeId, Symbol) after @ValidTo.
-- Ensures this is the LAST closed row; reactivating an older row would violate
-- forward-only ordering of the temporal chain.
IF EXISTS (
SELECT 1 FROM dbo.ChargeableCharConfig
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
AND Symbol = @Symbol
AND ValidFrom > @ValidTo
AND Id <> @Id
)
BEGIN
ROLLBACK TRANSACTION;
THROW 50412, 'Posterior rows exist for this ProductType/Symbol — reactivation not allowed', 1;
END
-- Step 5: Literal undo — re-open the row.
UPDATE dbo.ChargeableCharConfig
SET IsActive = 1,
ValidTo = NULL
WHERE Id = @Id;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
GO
PRINT '';
PRINT 'V023 applied — ChargeableCharConfig refactored to ProductType model:';
PRINT ' - MedioId dropped, ProductTypeId added (FK to dbo.ProductType)';
PRINT ' - UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query recreated';
PRINT ' - usp_ChargeableCharConfig_InsertWithClose: @MedioId → @ProductTypeId';
PRINT ' - usp_ChargeableCharConfig_GetActiveForMedio dropped';
PRINT ' - usp_ChargeableCharConfig_GetActiveForProductType created';
PRINT ' - usp_ChargeableCharConfig_ReactivateWithGuard created (NEW)';
PRINT ' - CK_Price_Positive replaced by CK_Price_NonNegative (>= 0 for opt-in billing)';
PRINT 'Next migration: V024 (reseed global rows with PricePerUnit = 0.0000).';
GO

View File

@@ -0,0 +1,22 @@
-- V024_ROLLBACK.sql
-- PRC-001: Reversa de V024__reseed_global_with_zero_price.sql.
--
-- Restaura las 4 filas globales de seed a PricePerUnit = 1.0000 (valor original de V022).
-- Solo ejecutar si V024 fue aplicado y se desea volver al estado previo.
--
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
UPDATE dbo.ChargeableCharConfig
SET PricePerUnit = CAST(1.0000 AS DECIMAL(18,4))
WHERE ProductTypeId IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ValidTo IS NULL;
PRINT 'V024 ROLLBACK complete — global ChargeableCharConfig prices restored to 1.0000.';
PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10));
GO

View File

@@ -0,0 +1,34 @@
-- V024__reseed_global_with_zero_price.sql
-- PRC-001 scope delta: actualiza las 4 filas globales de seed a PricePerUnit = 0.0000.
--
-- Cambios:
-- 1. UPDATE directo de las 4 filas globales vigentes ($, %, !, ¡) a PricePerUnit = 0.0000.
--
-- Decisión: UPDATE directo (no forward-only close+insert) porque:
-- - V022 seed price 1.0000 era siempre un placeholder nunca usado en lógica de negocio.
-- - No existe historial de facturación con el valor 1.0000.
-- - La semántica correcta es "opt-in billing": por defecto ningún tipo cobra especiales.
-- - La forward-only invariante aplica a cambios de precio en producción; este es un fix
-- de seed pre-go-live dentro de la misma branch feature (no mergeada a main aún).
-- See: scope-delta-1 en engram sdd/prc-001-word-counter-spike/scope-delta-1.
--
-- Patrón: UPDATE simple WHERE ProductTypeId IS NULL AND Symbol IN (...) AND ValidTo IS NULL.
-- Idempotente: UPDATE idempotente (re-ejecutar no cambia el resultado).
-- Reversa: V024_ROLLBACK.sql.
-- Depends on: V023 (ProductTypeId column must exist; CK_Price_NonNegative >= 0 required).
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
UPDATE dbo.ChargeableCharConfig
SET PricePerUnit = CAST(0.0000 AS DECIMAL(18,4))
WHERE ProductTypeId IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ValidTo IS NULL;
PRINT 'V024 applied — global ChargeableCharConfig prices reset to 0.0000 (opt-in billing).';
PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10));
GO

View File

@@ -8,7 +8,7 @@ using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Pricing; namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// <summary> /// <summary>
/// PRC-001 Batch 7 — Integration hardening tests for ChargeableCharConfig. /// PRC-001 — Integration hardening tests for ChargeableCharConfig.
/// Covers cross-cutting concerns not addressed by individual layer batches: /// Covers cross-cutting concerns not addressed by individual layer batches:
/// ///
/// T7.1 Concurrency: SemaphoreSlim barrier forces genuine parallel race on /// T7.1 Concurrency: SemaphoreSlim barrier forces genuine parallel race on
@@ -18,9 +18,11 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// ///
/// T7.3 FOR SYSTEM_TIME AS OF: temporal snapshot query at T0 returns pre-close state. /// T7.3 FOR SYSTEM_TIME AS OF: temporal snapshot query at T0 returns pre-close state.
/// ///
/// T7.4 Per-medio + global fallback resolution via GetActiveForMedioAsync: /// T7.4 Per-ProductType + global fallback resolution via GetActiveForProductTypeAsync:
/// - ELDIA override for '$' → per-medio row returned at priority /// - ProductType1 override for '$' → per-PT row returned at priority
/// - ELPLATA (no override) → global fallback returned /// - ProductType2 (no override) → global fallback returned
///
/// V023 scope delta: MedioId → ProductTypeId. Seeds use dbo.ProductType rows.
/// ///
/// All tests run against SIGCM2_Test_App (Database collection + SqlTestFixture). /// All tests run against SIGCM2_Test_App (Database collection + SqlTestFixture).
/// Each test seeds its own unique symbols to avoid cross-test interference. /// Each test seeds its own unique symbols to avoid cross-test interference.
@@ -29,8 +31,10 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
{ {
private readonly SqlTestFixture _db; private readonly SqlTestFixture _db;
private int _eldiaId; // V023 scope delta: renamed from _eldiaId/_elplataId to ProductType-based IDs.
private int _elplataId; // These are ProductType IDs (FK to dbo.ProductType), not Medio IDs.
private int _productType1Id; // has per-PT override (was ELDIA)
private int _productType2Id; // no override, falls back to global (was ELPLATA)
public ChargeableCharConfigHardeningTests(SqlTestFixture db) public ChargeableCharConfigHardeningTests(SqlTestFixture db)
{ {
@@ -44,17 +48,18 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync(); await conn.OpenAsync();
// Seed two dedicated medios: ELDIA (has per-medio override) and ELPLATA (no override). // Seed two dedicated ProductTypes for override/fallback resolution tests.
_eldiaId = await conn.ExecuteScalarAsync<int>(""" // V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id).
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) _productType1Id = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, IsActive)
OUTPUT INSERTED.Id OUTPUT INSERTED.Id
VALUES ('HARD_ELDIA', 'ELDIA Hardening', 1, 1) VALUES ('Hardening PT1 (override)', 1)
"""); """);
_elplataId = await conn.ExecuteScalarAsync<int>(""" _productType2Id = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) INSERT INTO dbo.ProductType (Nombre, IsActive)
OUTPUT INSERTED.Id OUTPUT INSERTED.Id
VALUES ('HARD_ELPLA', 'ELPLATA Hardening', 1, 1) VALUES ('Hardening PT2 (fallback)', 1)
"""); """);
} }
@@ -126,27 +131,28 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
successes.Should().Be(1, "exactly one concurrent InsertWithClose must succeed"); successes.Should().Be(1, "exactly one concurrent InsertWithClose must succeed");
failures.Should().Be(2, "the other two concurrent inserts must fail with SqlException"); failures.Should().Be(2, "the other two concurrent inserts must fail with SqlException");
// Verify post-race state: exactly 1 vigente row for (NULL, '¢') // Verify post-race state: exactly 1 vigente row for (ProductTypeId=NULL, '¢')
// V023: MedioId → ProductTypeId; global fallback = ProductTypeId IS NULL
await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb); await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await verifyConn.OpenAsync(); await verifyConn.OpenAsync();
var vigente = await verifyConn.ExecuteScalarAsync<int>(""" var vigente = await verifyConn.ExecuteScalarAsync<int>("""
SELECT COUNT(1) SELECT COUNT(1)
FROM dbo.ChargeableCharConfig FROM dbo.ChargeableCharConfig
WHERE MedioId IS NULL WHERE ProductTypeId IS NULL
AND Symbol = @Symbol AND Symbol = @Symbol
AND ValidTo IS NULL AND ValidTo IS NULL
AND IsActive = 1 AND IsActive = 1
""", new { Symbol = symbol }); """, new { Symbol = symbol });
vigente.Should().Be(1, vigente.Should().Be(1,
"filtered unique index UX_ChargeableCharConfig_Vigente must prevent more than 1 vigente row per (MedioId, Symbol)"); "filtered unique index UX_ChargeableCharConfig_Vigente must prevent more than 1 vigente row per (ProductTypeId, Symbol)");
// No duplicates: total row count must also be 1 (only the winner was inserted) // No duplicates: total row count must also be 1 (only the winner was inserted)
var total = await verifyConn.ExecuteScalarAsync<int>(""" var total = await verifyConn.ExecuteScalarAsync<int>("""
SELECT COUNT(1) SELECT COUNT(1)
FROM dbo.ChargeableCharConfig FROM dbo.ChargeableCharConfig
WHERE MedioId IS NULL WHERE ProductTypeId IS NULL
AND Symbol = @Symbol AND Symbol = @Symbol
""", new { Symbol = symbol }); """, new { Symbol = symbol });
@@ -260,109 +266,121 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
} }
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// T7.4 — Per-medio + global fallback resolution via GetActiveForMedioAsync // T7.4 — Per-ProductType + global fallback resolution via GetActiveForProductTypeAsync
//
// V023 scope delta: MedioId → ProductTypeId in table + SP.
// //
// Scenario: // Scenario:
// - Global '$' at price 1.00 (seed row from ResetAndSeedAsync / canonical V022 seed) // - Global '$' at price 0.00 (seed row from ResetAndSeedAsync / canonical V022+V024 seed)
// - ELDIA-specific '$' at price 5.00 effective from today (per-medio override) // - ProductType1-specific '$' at price 5.00 effective from 2026-01-01 (per-PT override)
// - ELPLATA has no override for '$' // - ProductType2 has no override for '$'
// //
// GetActiveConfigForMedioAsync(ELDIA, today) → '$' = 5.00 (per-medio override wins) // GetActiveConfigForProductTypeAsync(PT1, today) → '$' = 5.00 (per-PT override wins)
// GetActiveConfigForMedioAsync(ELPLATA, today) → '$' = 1.00 (global fallback) // GetActiveConfigForProductTypeAsync(PT2, today) → '$' = 0.00 (global fallback)
//
// NOTE: C# method calls (GetActiveForMedioAsync, GetActiveConfigForMedioAsync) will be
// renamed in Agent 2 (Backend refactor). These tests will FAIL COMPILATION until Agent 2.
// SQL-level assertions in this test (the ExecInsertWithCloseAsync helper) are already
// updated for V023 (@ProductTypeId param). The C# repo/service method calls are left as-is.
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
[Fact] [Fact]
public async Task GetActiveConfigForMedio_EldiaOverride_WinsOverGlobal() public async Task GetActiveConfigForProductType_Override_WinsOverGlobal()
{ {
var asOf = new DateOnly(2026, 6, 1); var asOf = new DateOnly(2026, 6, 1);
// Seed per-medio override for ELDIA: '$' at 5.00 effective from 2026-01-01 // Seed per-PT override for ProductType1: '$' at 5.00 effective from 2026-01-01
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync(); await seedConn.OpenAsync();
await ExecInsertWithCloseAsync(seedConn, _eldiaId, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1)); await ExecInsertWithCloseAsync(seedConn, _productType1Id, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1));
// Build the repository + service (same as application layer usage) // Build the repository + service (C# method will be renamed in Agent 2)
var repo = BuildRepository(); var repo = BuildRepository();
var rows = await repo.GetActiveForMedioAsync((long)_eldiaId, asOf); var rows = await repo.GetActiveForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync
// The per-medio '$' must be returned // The per-PT '$' must be returned
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
dollarRow.Should().NotBeNull("ELDIA has a per-medio '$' override — SP must return it"); dollarRow.Should().NotBeNull("ProductType1 has a per-PT '$' override — SP must return it");
dollarRow!.MedioId.Should().Be(_eldiaId, dollarRow!.MedioId.Should().Be(_productType1Id, // TODO Agent 2: rename to ProductTypeId
"the per-medio row (MedioId = ELDIA) must take priority over the global row"); "the per-PT row (ProductTypeId = PT1) must take priority over the global row");
dollarRow.PricePerUnit.Should().Be(5.0000m, dollarRow.PricePerUnit.Should().Be(5.0000m,
"ELDIA override has price 5.00, not the global 1.00"); "ProductType1 override has price 5.00, not the global 0.00");
} }
[Fact] [Fact]
public async Task GetActiveConfigForMedio_ElplataNoOverride_FallsBackToGlobal() public async Task GetActiveConfigForProductType_NoOverride_FallsBackToGlobal()
{ {
var asOf = new DateOnly(2026, 6, 1); var asOf = new DateOnly(2026, 6, 1);
// ELPLATA has no per-medio rows — the canonical global seed from ResetAndSeedAsync // ProductType2 has no per-PT rows — the canonical global seed from ResetAndSeedAsync
// provides '$' at global price. // provides '$' at global price (0.0000 after V024).
var repo = BuildRepository(); var repo = BuildRepository();
var rows = await repo.GetActiveForMedioAsync((long)_elplataId, asOf); var rows = await repo.GetActiveForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync
// Must have at least the global '$' from seed // Must have at least the global '$' from seed
rows.Should().NotBeEmpty("canonical seed provides global rows active as of 2026-06-01"); rows.Should().NotBeEmpty("canonical seed provides global rows active as of 2026-06-01");
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
dollarRow.Should().NotBeNull("global '$' must be returned for ELPLATA (no override exists)"); dollarRow.Should().NotBeNull("global '$' must be returned for ProductType2 (no override exists)");
dollarRow!.MedioId.Should().BeNull( dollarRow!.MedioId.Should().BeNull( // TODO Agent 2: rename to ProductTypeId
"ELPLATA has no override — the returned row must be the global row (MedioId = NULL)"); "ProductType2 has no override — the returned row must be the global row (ProductTypeId = NULL)");
} }
[Fact] [Fact]
public async Task GetActiveConfigForMedio_ServiceLayer_AppliesPriorityCorrectly() public async Task GetActiveConfigForProductType_ServiceLayer_AppliesPriorityCorrectly()
{ {
// End-to-end: IChargeableCharConfigService resolves the final dictionary. // End-to-end: IChargeableCharConfigService resolves the final dictionary.
// Seed ELDIA override for '%' (percentage) at 3.00; global '%' at 2.00 (from V022 seed). // Seed ProductType1 override for '%' (percentage) at 3.00; global '%' at 0.00 (from V024 seed).
var asOf = new DateOnly(2026, 6, 1); var asOf = new DateOnly(2026, 6, 1);
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync(); await seedConn.OpenAsync();
await ExecInsertWithCloseAsync(seedConn, _eldiaId, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1)); await ExecInsertWithCloseAsync(seedConn, _productType1Id, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1));
// Build the service (wraps repo with priority resolution) // Build the service (wraps repo with priority resolution)
// TODO Agent 2: rename GetActiveConfigForMedioAsync → GetActiveConfigForProductTypeAsync
var service = BuildService(); var service = BuildService();
var eldiaConfig = await service.GetActiveConfigForMedioAsync((long)_eldiaId, asOf); var pt1Config = await service.GetActiveConfigForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2
var elplataConfig = await service.GetActiveConfigForMedioAsync((long)_elplataId, asOf); var pt2Config = await service.GetActiveConfigForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2
// ELDIA: '%' must come from per-medio override at 3.00 // ProductType1: '%' must come from per-PT override at 3.00
eldiaConfig.Should().ContainKey("%", pt1Config.Should().ContainKey("%",
"ELDIA has a per-medio override for '%'"); "ProductType1 has a per-PT override for '%'");
eldiaConfig["%"].PricePerUnit.Should().Be(3.0000m, pt1Config["%"].PricePerUnit.Should().Be(3.0000m,
"per-medio '%' at 3.00 must override the global 2.00 for ELDIA"); "per-PT '%' at 3.00 must override the global 0.00 for ProductType1");
// ELPLATA: '%' must come from global fallback // ProductType2: '%' must come from global fallback
// Note: ChargeableCharSnapshot only exposes Category + PricePerUnit (not MedioId). // Note: ChargeableCharSnapshot only exposes Category + PricePerUnit (not MedioId/ProductTypeId).
// We verify via price: global '%' is seeded at 2.00 by V022. // We verify via price: global '%' is seeded at 0.0000 by V024 (opt-in billing, was 1.0000 in V022).
elplataConfig.Should().ContainKey("%", pt2Config.Should().ContainKey("%",
"global '%' from canonical seed must appear in ELPLATA's resolved config"); "global '%' from canonical seed must appear in ProductType2's resolved config");
// V022 seeds global '%' at 1.0000 (placeholder — see V022__seed_chargeable_char_config.sql) // V024 resets global '%' to 0.0000 (opt-in billing — V022 placeholder 1.0000 replaced)
elplataConfig["%"].PricePerUnit.Should().Be(1.0000m, pt2Config["%"].PricePerUnit.Should().Be(0.0000m,
"ELPLATA falls back to the global '%' at 1.00 (V022 seed placeholder price)"); "ProductType2 falls back to the global '%' at 0.00 (V024 seed opt-in billing price)");
} }
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>
/// Helper: calls usp_ChargeableCharConfig_InsertWithClose directly via SQL.
/// V023 scope delta: parameter renamed from @MedioId to @ProductTypeId.
/// </summary>
private static async Task<long> ExecInsertWithCloseAsync( private static async Task<long> ExecInsertWithCloseAsync(
SqlConnection conn, SqlConnection conn,
int? medioId, int? productTypeId,
string symbol, string symbol,
string category, string category,
decimal pricePerUnit, decimal pricePerUnit,
DateTime validFrom) DateTime validFrom)
{ {
var p = new DynamicParameters(); var p = new DynamicParameters();
p.Add("@MedioId", medioId, System.Data.DbType.Int32); p.Add("@ProductTypeId", productTypeId, System.Data.DbType.Int32); // V023: was @MedioId
p.Add("@Symbol", symbol, System.Data.DbType.String); p.Add("@Symbol", symbol, System.Data.DbType.String);
p.Add("@Category", category, System.Data.DbType.String); p.Add("@Category", category, System.Data.DbType.String);
p.Add("@PricePerUnit", pricePerUnit, System.Data.DbType.Decimal, precision: 18, scale: 4); p.Add("@PricePerUnit", pricePerUnit, System.Data.DbType.Decimal, precision: 18, scale: 4);

View File

@@ -6,14 +6,15 @@ using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Pricing; namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// <summary> /// <summary>
/// PRC-001 Batch 1 (RED) — Integration tests for V020/V021/V022 migrations. /// PRC-001 — Integration tests for V020/V021/V022/V023/V024 migrations.
/// These tests verify the BD schema applied against SIGCM2_Test_App: /// These tests verify the BD schema applied against SIGCM2_Test_App:
/// - V020: permission 'tasacion:caracteres_especiales:gestionar' exists. /// - V020: permission 'tasacion:caracteres_especiales:gestionar' exists.
/// - V021: dbo.ChargeableCharConfig table + SYSTEM_VERSIONING + filtered UX + SPs. /// - V021: dbo.ChargeableCharConfig table + SYSTEM_VERSIONING + SPs (initial MedioId shape).
/// - V022: 4 global seed rows ($, %, !, ¡) exist and are active. /// - V022: 4 global seed rows ($, %, !, ¡) exist and are active.
/// - V023 (scope delta): MedioId → ProductTypeId refactor + ReactivateWithGuard SP.
/// - V024 (scope delta): global seed PricePerUnit reset to 0.0000 (opt-in billing).
/// ///
/// Tests are tagged [RED] until V020+V021+V022 are applied (Batch 1 GREEN step). /// After GREEN all tests pass. SqlTestFixture applies V023+V024 during initialization.
/// After GREEN, all tests in this class should pass.
/// </summary> /// </summary>
[Collection("Database")] [Collection("Database")]
public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
@@ -104,12 +105,12 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
[Fact] [Fact]
public async Task usp_InsertWithClose_FirstPrice_ClosedIsNull() public async Task usp_InsertWithClose_FirstPrice_ClosedIsNull()
{ {
// Cleanup // Cleanup (use ProductTypeId IS NULL after V023 refactor)
await _connection.ExecuteAsync( await _connection.ExecuteAsync(
"DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'@'"); "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'@'");
var p = new DynamicParameters(); var p = new DynamicParameters();
p.Add("@MedioId", null, System.Data.DbType.Int32); p.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId
p.Add("@Symbol", "@", System.Data.DbType.String); p.Add("@Symbol", "@", System.Data.DbType.String);
p.Add("@Category", "Other",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("@PricePerUnit", 1.5m, System.Data.DbType.Decimal, precision: 18, scale: 4);
@@ -132,7 +133,7 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
// Cleanup // Cleanup
await _connection.ExecuteAsync( await _connection.ExecuteAsync(
"DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'@' AND MedioId IS NULL"); "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'@' AND ProductTypeId IS NULL");
} }
[Fact] [Fact]
@@ -140,10 +141,10 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
{ {
// Seed primer activo // Seed primer activo
await _connection.ExecuteAsync( await _connection.ExecuteAsync(
"DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'€'"); "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'€'");
var p1 = new DynamicParameters(); var p1 = new DynamicParameters();
p1.Add("@MedioId", null, System.Data.DbType.Int32); p1.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId
p1.Add("@Symbol", "€", System.Data.DbType.String); p1.Add("@Symbol", "€", System.Data.DbType.String);
p1.Add("@Category", "Currency", 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("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4);
@@ -159,7 +160,7 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
// Insertar segundo (debe cerrar el primero) // Insertar segundo (debe cerrar el primero)
var p2 = new DynamicParameters(); var p2 = new DynamicParameters();
p2.Add("@MedioId", null, System.Data.DbType.Int32); p2.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId
p2.Add("@Symbol", "€", System.Data.DbType.String); p2.Add("@Symbol", "€", System.Data.DbType.String);
p2.Add("@Category", "Currency", 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("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4);
@@ -187,17 +188,17 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
// Cleanup // Cleanup
await _connection.ExecuteAsync( await _connection.ExecuteAsync(
"DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'€' AND MedioId IS NULL"); "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'€' AND ProductTypeId IS NULL");
} }
[Fact] [Fact]
public async Task usp_InsertWithClose_ForwardOnlyViolation_Throws50409() public async Task usp_InsertWithClose_ForwardOnlyViolation_Throws50409()
{ {
await _connection.ExecuteAsync( await _connection.ExecuteAsync(
"DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'£'"); "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'£'");
var p1 = new DynamicParameters(); var p1 = new DynamicParameters();
p1.Add("@MedioId", null, System.Data.DbType.Int32); p1.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId
p1.Add("@Symbol", "£", System.Data.DbType.String); p1.Add("@Symbol", "£", System.Data.DbType.String);
p1.Add("@Category", "Currency", 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("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4);
@@ -212,7 +213,7 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
// Intento con ValidFrom <= activo.ValidFrom → debe lanzar 50409 // Intento con ValidFrom <= activo.ValidFrom → debe lanzar 50409
var p2 = new DynamicParameters(); var p2 = new DynamicParameters();
p2.Add("@MedioId", null, System.Data.DbType.Int32); p2.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId
p2.Add("@Symbol", "£", System.Data.DbType.String); p2.Add("@Symbol", "£", System.Data.DbType.String);
p2.Add("@Category", "Currency", 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("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4);
@@ -232,17 +233,18 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
// Cleanup // Cleanup
await _connection.ExecuteAsync( await _connection.ExecuteAsync(
"DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'£' AND MedioId IS NULL"); "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'£' AND ProductTypeId IS NULL");
} }
[Fact] [Fact]
public async Task usp_InsertWithClose_MedioNull_GlobalFallback_Works() public async Task usp_InsertWithClose_ProductTypeNull_GlobalFallback_Works()
{ {
// V023: global fallback is now ProductTypeId IS NULL (was MedioId IS NULL)
await _connection.ExecuteAsync( await _connection.ExecuteAsync(
"DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'¥'"); "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'¥'");
var p = new DynamicParameters(); var p = new DynamicParameters();
p.Add("@MedioId", null, System.Data.DbType.Int32); p.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId
p.Add("@Symbol", "¥", System.Data.DbType.String); p.Add("@Symbol", "¥", System.Data.DbType.String);
p.Add("@Category", "Currency", 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("@PricePerUnit", 1.25m, System.Data.DbType.Decimal, precision: 18, scale: 4);
@@ -256,21 +258,21 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
commandType: System.Data.CommandType.StoredProcedure); commandType: System.Data.CommandType.StoredProcedure);
var newId = p.Get<long?>("@NewId"); var newId = p.Get<long?>("@NewId");
newId.Should().BeGreaterThan(0, "global insert (MedioId NULL) debe funcionar"); newId.Should().BeGreaterThan(0, "global insert (ProductTypeId NULL) debe funcionar");
// Cleanup // Cleanup
await _connection.ExecuteAsync( await _connection.ExecuteAsync(
"DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'¥' AND MedioId IS NULL"); "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'¥' AND ProductTypeId IS NULL");
} }
[Fact] [Fact]
public async Task SystemVersioning_UpdateOnClose_ProducesHistoryRow() public async Task SystemVersioning_UpdateOnClose_ProducesHistoryRow()
{ {
await _connection.ExecuteAsync( await _connection.ExecuteAsync(
"DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'#'"); "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'#'");
var p1 = new DynamicParameters(); var p1 = new DynamicParameters();
p1.Add("@MedioId", null, System.Data.DbType.Int32); p1.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId
p1.Add("@Symbol", "#", System.Data.DbType.String); p1.Add("@Symbol", "#", System.Data.DbType.String);
p1.Add("@Category", "Other", 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("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4);
@@ -286,7 +288,7 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
// Close it by inserting a newer version // Close it by inserting a newer version
var p2 = new DynamicParameters(); var p2 = new DynamicParameters();
p2.Add("@MedioId", null, System.Data.DbType.Int32); p2.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId
p2.Add("@Symbol", "#", System.Data.DbType.String); p2.Add("@Symbol", "#", System.Data.DbType.String);
p2.Add("@Category", "Other", 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("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4);
@@ -309,13 +311,25 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
// Cleanup // Cleanup
await _connection.ExecuteAsync( await _connection.ExecuteAsync(
"DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'#' AND MedioId IS NULL"); "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'#' AND ProductTypeId IS NULL");
} }
// ── V021: SP — usp_ChargeableCharConfig_GetActiveForMedio ──────────── // ── V023 scope delta: SP — usp_ChargeableCharConfig_GetActiveForProductType ────────────
[Fact] [Fact]
public async Task V021_SP_GetActiveForMedio_Exists() public async Task V023_SP_GetActiveForProductType_Exists()
{
var exists = await _connection.ExecuteScalarAsync<int>(@"
SELECT COUNT(*)
FROM sys.objects
WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForProductType')
AND type = 'P'");
exists.Should().Be(1, "V023 debe crear usp_ChargeableCharConfig_GetActiveForProductType (renamed from GetActiveForMedio)");
}
[Fact]
public async Task V023_SP_GetActiveForMedio_NoLongerExists()
{ {
var exists = await _connection.ExecuteScalarAsync<int>(@" var exists = await _connection.ExecuteScalarAsync<int>(@"
SELECT COUNT(*) SELECT COUNT(*)
@@ -323,7 +337,81 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio') WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio')
AND type = 'P'"); AND type = 'P'");
exists.Should().Be(1, "usp_ChargeableCharConfig_GetActiveForMedio debe existir"); exists.Should().Be(0, "V023 debe eliminar usp_ChargeableCharConfig_GetActiveForMedio (renamed to GetActiveForProductType)");
}
[Fact]
public async Task V023_SP_ReactivateWithGuard_Exists()
{
var exists = await _connection.ExecuteScalarAsync<int>(@"
SELECT COUNT(*)
FROM sys.objects
WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard')
AND type = 'P'");
exists.Should().Be(1, "V023 debe crear usp_ChargeableCharConfig_ReactivateWithGuard (new SP for feature 3)");
}
// ── V023 scope delta: column ProductTypeId replaces MedioId ─────────
[Fact]
public async Task V023_Column_ProductTypeId_Exists()
{
var exists = await _connection.ExecuteScalarAsync<int>(@"
SELECT COUNT(*)
FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'ProductTypeId'");
exists.Should().Be(1, "V023 debe agregar columna ProductTypeId a dbo.ChargeableCharConfig");
}
[Fact]
public async Task V023_Column_MedioId_NoLongerExists()
{
var exists = await _connection.ExecuteScalarAsync<int>(@"
SELECT COUNT(*)
FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'MedioId'");
exists.Should().Be(0, "V023 debe eliminar columna MedioId de dbo.ChargeableCharConfig");
}
[Fact]
public async Task V023_FK_ProductType_Exists()
{
var exists = await _connection.ExecuteScalarAsync<int>(@"
SELECT COUNT(*)
FROM sys.foreign_keys
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND referenced_object_id = OBJECT_ID('dbo.ProductType')");
exists.Should().Be(1, "V023 debe crear FK de ChargeableCharConfig a ProductType");
}
[Fact]
public async Task V023_Check_Price_NonNegative_Exists()
{
var exists = await _connection.ExecuteScalarAsync<int>(@"
SELECT COUNT(*)
FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')");
exists.Should().Be(1, "V023 debe crear CK_ChargeableCharConfig_Price_NonNegative (>= 0 para opt-in billing)");
}
[Fact]
public async Task V023_Check_Price_Positive_NoLongerExists()
{
var exists = await _connection.ExecuteScalarAsync<int>(@"
SELECT COUNT(*)
FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')");
exists.Should().Be(0, "V023 debe eliminar CK_ChargeableCharConfig_Price_Positive (reemplazado por NonNegative)");
} }
// ── V020: Permission existence ──────────────────────────────────────── // ── V020: Permission existence ────────────────────────────────────────
@@ -339,17 +427,17 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
"V020 debe insertar el permiso 'tasacion:caracteres_especiales:gestionar'"); "V020 debe insertar el permiso 'tasacion:caracteres_especiales:gestionar'");
} }
// ── V022: Seed rows ─────────────────────────────────────────────────── // ── V022: Seed rows (after V023 refactor: use ProductTypeId IS NULL) ─────
[Fact] [Fact]
public async Task V022_Seeds_AtLeastFourGlobalRows() public async Task V022_Seeds_AtLeastFourGlobalRows()
{ {
var count = await _connection.ExecuteScalarAsync<int>(@" var count = await _connection.ExecuteScalarAsync<int>(@"
SELECT COUNT(*) FROM dbo.ChargeableCharConfig SELECT COUNT(*) FROM dbo.ChargeableCharConfig
WHERE MedioId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') WHERE ProductTypeId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ValidTo IS NULL AND IsActive = 1"); AND ValidTo IS NULL AND IsActive = 1");
count.Should().Be(4, "V022 debe sembrar exactamente 4 filas globales: $, %, !, ¡"); count.Should().Be(4, "V022 debe sembrar exactamente 4 filas globales: $, %, !, ¡ (global = ProductTypeId IS NULL tras V023)");
} }
[Fact] [Fact]
@@ -357,9 +445,39 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
{ {
var inactiveCount = await _connection.ExecuteScalarAsync<int>(@" var inactiveCount = await _connection.ExecuteScalarAsync<int>(@"
SELECT COUNT(*) FROM dbo.ChargeableCharConfig SELECT COUNT(*) FROM dbo.ChargeableCharConfig
WHERE MedioId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') WHERE ProductTypeId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡')
AND IsActive = 0"); AND IsActive = 0");
inactiveCount.Should().Be(0, "todas las filas de seed deben tener IsActive = 1"); inactiveCount.Should().Be(0, "todas las filas de seed deben tener IsActive = 1");
} }
// ── V024 scope delta: global seed prices = 0.0000 (opt-in billing) ───────
[Fact]
public async Task V024_GlobalSeedRows_HaveZeroPrice()
{
var nonZeroCount = await _connection.ExecuteScalarAsync<int>(@"
SELECT COUNT(*) FROM dbo.ChargeableCharConfig
WHERE ProductTypeId IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ValidTo IS NULL
AND PricePerUnit <> 0.0000");
nonZeroCount.Should().Be(0,
"V024 debe resetear todas las filas de seed global a PricePerUnit = 0.0000 (opt-in billing)");
}
[Fact]
public async Task V024_GlobalSeedRows_AllHaveZeroPriceExact()
{
var rows = await _connection.QueryAsync<decimal>(@"
SELECT PricePerUnit FROM dbo.ChargeableCharConfig
WHERE ProductTypeId IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ValidTo IS NULL");
rows.Should().AllSatisfy(price =>
price.Should().Be(0.0000m,
"V024 seed: cada fila global debe tener PricePerUnit = 0.0000 exacto"));
}
} }

View File

@@ -14,15 +14,19 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// ///
/// All tests run against the real DB via SqlTestFixture (Database collection). /// All tests run against the real DB via SqlTestFixture (Database collection).
/// Canonical seed (4 global symbols: $, %, !, ¡) is loaded by ResetAndSeedAsync(). /// Canonical seed (4 global symbols: $, %, !, ¡) is loaded by ResetAndSeedAsync().
/// Tests that mutate specific (MedioId, Symbol) pairs clean their own state before mutating. /// Tests that mutate specific (ProductTypeId, Symbol) pairs clean their own state before mutating.
///
/// V023 scope delta: MedioId → ProductTypeId. C# method/property renames (InsertWithCloseAsync
/// medioId: param, GetActiveForMedioAsync, entity.MedioId) are deferred to Agent 2 (Backend refactor).
/// This class will FAIL COMPILATION after Agent 2 renames the domain layer — expected.
/// ///
/// Spec coverage: /// Spec coverage:
/// T4.1 InsertWithCloseAsync — first insert for symbol → new row, returns Id /// T4.1 InsertWithCloseAsync — first insert for symbol → new row, returns Id
/// T4.2 InsertWithCloseAsync — with existing vigente → closes previous, inserts new /// T4.2 InsertWithCloseAsync — with existing vigente → closes previous, inserts new
/// T4.3 InsertWithCloseAsync — backdate attempt → ThrowsForwardOnlyException /// T4.3 InsertWithCloseAsync — backdate attempt → ThrowsForwardOnlyException
/// T4.4 InsertWithCloseAsync — system versioning captures history row after mutation /// T4.4 InsertWithCloseAsync — system versioning captures history row after mutation
/// T4.5 GetActiveForMedioAsync — medio has override → returns both medio and global rows /// T4.5 GetActiveForProductTypeAsync — PT has override → returns both PT and global rows
/// T4.6 GetActiveForMedioAsync — no medio override → returns only global rows /// T4.6 GetActiveForProductTypeAsync — no PT override → returns only global rows
/// T4.7 ListAsync — paginates (skip/take) /// T4.7 ListAsync — paginates (skip/take)
/// T4.8 CountAsync — filters by activeOnly /// T4.8 CountAsync — filters by activeOnly
/// T4.9 GetByIdAsync — missing → returns null /// T4.9 GetByIdAsync — missing → returns null

View File

@@ -72,6 +72,12 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V020/V021/V022 (PRC-001): ensure dbo.ChargeableCharConfig + temporal + SPs + permission + seed. // V020/V021/V022 (PRC-001): ensure dbo.ChargeableCharConfig + temporal + SPs + permission + seed.
await EnsureV021SchemaAsync(); await EnsureV021SchemaAsync();
// V023 (PRC-001 scope delta): refactor ChargeableCharConfig — MedioId → ProductTypeId + ReactivateWithGuard SP.
await EnsureV023SchemaAsync();
// V024 (PRC-001 scope delta): reseed global rows with PricePerUnit = 0.0000 (opt-in billing).
await EnsureV024SeedAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{ {
DbAdapter = DbAdapter.SqlServer, DbAdapter = DbAdapter.SqlServer,
@@ -127,6 +133,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
await SeedRolPermisosCanonicalAsync(); await SeedRolPermisosCanonicalAsync();
await SeedAdminAsync(); await SeedAdminAsync();
await SeedMediosCanonicalAsync(); await SeedMediosCanonicalAsync();
// PRC-001 scope delta: ChargeableCharConfig re-seeded with ProductTypeId-based canonical seed.
await SeedChargeableCharConfigCanonicalAsync(); await SeedChargeableCharConfigCanonicalAsync();
} }
@@ -574,28 +581,34 @@ public sealed class SqlTestFixture : IAsyncLifetime
} }
/// <summary> /// <summary>
/// PRC-001 (V022): re-seeds the 4 global ChargeableCharConfig defaults after each Respawn. /// PRC-001 scope delta (V022+V024): re-seeds the 4 global ChargeableCharConfig defaults after each Respawn.
/// Mirrors V022__seed_chargeable_char_config.sql (MERGE idempotente). /// Uses ProductTypeId NULL (global fallback) and PricePerUnit = 0.0000 (opt-in billing — V024 decision).
/// The table itself is never added to TablesToIgnore because per-medio test rows /// Mirrors V022 MERGE pattern, adapted for ProductTypeId column (V023 refactor).
/// The table itself is never added to TablesToIgnore because per-productType test rows
/// must be reset between test classes — only the 4 global defaults are reseeded. /// must be reset between test classes — only the 4 global defaults are reseeded.
/// NOTE: seed price is 0.0000 (not 1.0000). Tests asserting price must use 0.0000 unless
/// they have explicitly seeded their own rows at a different price.
/// </summary> /// </summary>
private async Task SeedChargeableCharConfigCanonicalAsync() private async Task SeedChargeableCharConfigCanonicalAsync()
{ {
const string sql = """ const string sql = """
SET QUOTED_IDENTIFIER ON; SET QUOTED_IDENTIFIER ON;
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL
AND EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'ProductTypeId')
BEGIN BEGIN
MERGE dbo.ChargeableCharConfig AS t MERGE dbo.ChargeableCharConfig AS t
USING (VALUES USING (VALUES
(NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), (NULL, N'$', N'Currency', CAST(0.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'Percentage', CAST(0.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(0.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(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE))
) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom) ) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL) ON (t.ProductTypeId IS NULL AND s.ProductTypeId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
WHEN NOT MATCHED THEN WHEN NOT MATCHED THEN
INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1); VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
END END
"""; """;
await _connection.ExecuteAsync(sql); await _connection.ExecuteAsync(sql);
@@ -1517,4 +1530,373 @@ public sealed class SqlTestFixture : IAsyncLifetime
// Permission 'tasacion:caracteres_especiales:gestionar' and admin assignment // Permission 'tasacion:caracteres_especiales:gestionar' and admin assignment
// are seeded from SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). // are seeded from SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
} }
/// <summary>
/// PRC-001 scope delta (V023): refactors dbo.ChargeableCharConfig from MedioId to ProductTypeId.
/// Mirrors V023__refactor_chargeable_char_config_to_product_type.sql (idempotente).
///
/// Steps (only run if MedioId column still exists — guard for idempotence):
/// 1. SYSTEM_VERSIONING OFF
/// 2. Drop UX_Vigente + IX_Query (MedioId-based)
/// 3. Drop FK_ChargeableCharConfig_Medio
/// 4. Drop MedioId column from main + history
/// 5. Drop CK_Price_Positive; add CK_Price_NonNegative (>= 0 for opt-in billing)
/// 6. Add ProductTypeId column (nullable) to main + history
/// 7. Add FK_ChargeableCharConfig_ProductType
/// 8. Recreate UX_Vigente + IX_Query (ProductTypeId-based)
/// 9. SYSTEM_VERSIONING ON
/// 10. Drop+Create usp_ChargeableCharConfig_InsertWithClose (@ProductTypeId)
/// 11. Drop usp_ChargeableCharConfig_GetActiveForMedio
/// 12. Create usp_ChargeableCharConfig_GetActiveForProductType
/// 13. Create usp_ChargeableCharConfig_ReactivateWithGuard (NEW)
/// </summary>
private async Task EnsureV023SchemaAsync()
{
// ── Guard: only run the ALTER block if MedioId still exists ──────────
// SPs are always idempotently recreated (create-if-not-exists + alter pattern).
const string checkMedioId = """
SELECT CAST(
CASE WHEN EXISTS (
SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'MedioId'
) THEN 1 ELSE 0 END
AS BIT)
""";
var hasMedioId = await _connection.ExecuteScalarAsync<bool>(checkMedioId);
if (hasMedioId)
{
// ── 1. SYSTEM_VERSIONING OFF ──────────────────────────────────────
await _connection.ExecuteAsync(
"ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF)");
// ── 2. Drop MedioId-based indexes ─────────────────────────────────
const string dropIndexes = """
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
""";
await _connection.ExecuteAsync(dropIndexes);
// ── 3. Drop FK to Medio ────────────────────────────────────────────
const string dropFkMedio = """
DECLARE @fk_name sysname;
SELECT @fk_name = name FROM sys.foreign_keys
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND referenced_object_id = OBJECT_ID('dbo.Medio');
IF @fk_name IS NOT NULL
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_name);
""";
await _connection.ExecuteAsync(dropFkMedio);
// ── 4. Drop MedioId from main + history ────────────────────────────
const string dropMedioIdMain = """
DECLARE @df_medio sysname;
SELECT @df_medio = dc.name
FROM sys.default_constraints dc
JOIN sys.columns c ON c.default_object_id = dc.object_id
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND c.name = 'MedioId';
IF @df_medio IS NOT NULL
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @df_medio);
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN MedioId;
""";
await _connection.ExecuteAsync(dropMedioIdMain);
const string dropMedioIdHistory = """
IF EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
AND name = 'MedioId')
BEGIN
DECLARE @df_hist sysname;
SELECT @df_hist = dc.name
FROM sys.default_constraints dc
JOIN sys.columns c ON c.default_object_id = dc.object_id
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
AND c.name = 'MedioId';
IF @df_hist IS NOT NULL
EXEC('ALTER TABLE dbo.ChargeableCharConfig_History DROP CONSTRAINT ' + @df_hist);
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN MedioId;
END
""";
await _connection.ExecuteAsync(dropMedioIdHistory);
// ── 5. Replace price check constraint ────────────────────────────────
const string replacePriceCheck = """
IF EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_Positive;
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative CHECK (PricePerUnit >= 0);
""";
await _connection.ExecuteAsync(replacePriceCheck);
// ── 6. Add ProductTypeId to main + history ─────────────────────────
await _connection.ExecuteAsync(
"ALTER TABLE dbo.ChargeableCharConfig ADD ProductTypeId INT NULL");
await _connection.ExecuteAsync(
"ALTER TABLE dbo.ChargeableCharConfig_History ADD ProductTypeId INT NULL");
// ── 7. Add FK to ProductType ───────────────────────────────────────
await _connection.ExecuteAsync("""
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT FK_ChargeableCharConfig_ProductType
FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION
""");
// ── 8. Recreate ProductTypeId-based indexes ────────────────────────
await _connection.ExecuteAsync("""
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol)
WHERE ValidTo IS NULL
""");
await _connection.ExecuteAsync("""
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol, ValidFrom, ValidTo)
INCLUDE (PricePerUnit, IsActive, Category)
""");
// ── 9. SYSTEM_VERSIONING ON ────────────────────────────────────────
await _connection.ExecuteAsync("""
ALTER TABLE dbo.ChargeableCharConfig
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
))
""");
}
// ── 10. Recreate InsertWithClose SP (always: idempotent via drop+create) ──
const string createInsertSp = """
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NULL
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0');
""";
// Only ALTER if ProductTypeId column exists (meaning table was already refactored or we just did it)
const string alterInsertSp = """
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
@ProductTypeId INT = NULL,
@Symbol NVARCHAR(4),
@Category NVARCHAR(32),
@PricePerUnit DECIMAL(18,4),
@ValidFrom DATE,
@NewId BIGINT OUTPUT,
@ClosedId BIGINT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
IF @ProductTypeId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM dbo.ProductType WITH (NOLOCK) WHERE Id = @ProductTypeId)
BEGIN
ROLLBACK;
THROW 50404, 'ProductType not found', 1;
END
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
SELECT TOP 1
@ActiveId = Id,
@ActiveValidFrom = ValidFrom
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
WHERE ((@ProductTypeId IS NULL AND ProductTypeId IS NULL)
OR (@ProductTypeId IS NOT NULL AND ProductTypeId = @ProductTypeId))
AND Symbol = @Symbol
AND ValidTo IS NULL;
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
BEGIN
ROLLBACK;
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
END
IF @ActiveId IS NOT NULL
BEGIN
UPDATE dbo.ChargeableCharConfig
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
WHERE Id = @ActiveId;
SET @ClosedId = @ActiveId;
END
ELSE
SET @ClosedId = NULL;
INSERT INTO dbo.ChargeableCharConfig
(ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES
(@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
SET @NewId = SCOPE_IDENTITY();
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
""";
await _connection.ExecuteAsync(createInsertSp);
await _connection.ExecuteAsync(alterInsertSp);
// ── 11. Drop GetActiveForMedio SP ──────────────────────────────────────
await _connection.ExecuteAsync("""
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
""");
// ── 12. Create GetActiveForProductType SP ──────────────────────────────
const string createGetForPtSp = """
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForProductType', N'P') IS NULL
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType AS RETURN 0');
""";
const string alterGetForPtSp = """
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType
@ProductTypeId INT,
@AsOfDate DATE
AS
BEGIN
SET NOCOUNT ON;
WITH Candidates AS (
SELECT
Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
ROW_NUMBER() OVER (
PARTITION BY Symbol
ORDER BY
CASE WHEN ProductTypeId = @ProductTypeId THEN 0 ELSE 1 END,
ValidFrom DESC
) AS rn
FROM dbo.ChargeableCharConfig
WHERE IsActive = 1
AND ValidFrom <= @AsOfDate
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
AND (ProductTypeId = @ProductTypeId OR ProductTypeId IS NULL)
)
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM Candidates
WHERE rn = 1;
END
""";
await _connection.ExecuteAsync(createGetForPtSp);
await _connection.ExecuteAsync(alterGetForPtSp);
// ── 13. Create ReactivateWithGuard SP (NEW) ────────────────────────────
const string createReactivateSp = """
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_ReactivateWithGuard', N'P') IS NULL
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard AS RETURN 0');
""";
const string alterReactivateSp = """
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard
@Id BIGINT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProductTypeId INT, @Symbol NVARCHAR(4), @ValidTo DATE, @IsActive BIT;
SELECT @ProductTypeId = ProductTypeId,
@Symbol = Symbol,
@ValidTo = ValidTo,
@IsActive = IsActive
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK)
WHERE Id = @Id;
IF @@ROWCOUNT = 0
BEGIN
ROLLBACK TRANSACTION;
THROW 50404, 'ChargeableCharConfig row not found', 1;
END
IF @ValidTo IS NULL
BEGIN
ROLLBACK TRANSACTION;
THROW 50410, 'Row is already active reactivation not needed', 1;
END
IF EXISTS (
SELECT 1 FROM dbo.ChargeableCharConfig
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
AND Symbol = @Symbol
AND ValidTo IS NULL
)
BEGIN
ROLLBACK TRANSACTION;
THROW 50411, 'A current active row already exists for this ProductType/Symbol cannot reactivate', 1;
END
IF EXISTS (
SELECT 1 FROM dbo.ChargeableCharConfig
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
AND Symbol = @Symbol
AND ValidFrom > @ValidTo
AND Id <> @Id
)
BEGIN
ROLLBACK TRANSACTION;
THROW 50412, 'Posterior rows exist for this ProductType/Symbol reactivation not allowed', 1;
END
UPDATE dbo.ChargeableCharConfig
SET IsActive = 1,
ValidTo = NULL
WHERE Id = @Id;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
""";
await _connection.ExecuteAsync(createReactivateSp);
await _connection.ExecuteAsync(alterReactivateSp);
}
/// <summary>
/// PRC-001 scope delta (V024): reseeds global ChargeableCharConfig rows to PricePerUnit = 0.0000.
/// Direct UPDATE — V022 seed price 1.0000 was always a placeholder, no business history exists.
/// Safe to re-run: already-zero rows are unchanged.
/// Requires ProductTypeId column to exist (V023 must have run).
/// </summary>
private async Task EnsureV024SeedAsync()
{
const string sql = """
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL
AND EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'ProductTypeId')
BEGIN
UPDATE dbo.ChargeableCharConfig
SET PricePerUnit = CAST(0.0000 AS DECIMAL(18,4))
WHERE ProductTypeId IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ValidTo IS NULL;
END
""";
await _connection.ExecuteAsync(sql);
}
} }