-- V019__create_product_prices.sql -- PRD-003: ProductPrices — historial de precios por Producto con vigencia civil (Cat2). -- -- Cambios: -- 1. dbo.ProductPrices (FK Product, SYSTEM_VERSIONING ON, retention 10 años). -- 2. Índices: filtered UQ un único activo; cover compuesto para GetPriceAt. -- 3. SP dbo.usp_AddProductPrice (SERIALIZABLE + UPDLOCK, cierre atómico forward-only). -- -- Patrón: V018 (SYSTEM_VERSIONING + PAGE compression). -- Idempotente: seguro para re-ejecutar. -- Reversa: V019_ROLLBACK.sql. -- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. -- -- Notas: -- - SysStartTime/SysEndTime como nombres de cols HIDDEN (no ValidFrom/ValidTo): -- evita colisión con las business cols PriceValidFrom/PriceValidTo (D1). -- - DECIMAL(12,2) para Price (distinto de Product.BasePrice DECIMAL(18,4)) — precios retail -- en pesos con 2 decimales; la diferencia es intencional (D6). -- - Sin seed inicial — Product.BasePrice queda ortogonal como fallback (OQ-B, D8). -- - Forward-only estricto en SP: THROW 50409 si new PVF <= active PVF (no solo <). -- -- SDD Design: engram sdd/prd-003-product-prices-historicos/design SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO -- ═══════════════════════════════════════════════════════════════════════ -- 1. dbo.ProductPrices -- ═══════════════════════════════════════════════════════════════════════ IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NULL BEGIN CREATE TABLE dbo.ProductPrices ( Id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProductPrices PRIMARY KEY, ProductId INT NOT NULL, Price DECIMAL(12,2) NOT NULL, PriceValidFrom DATE NOT NULL, PriceValidTo DATE NULL, FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_ProductPrices_FechaCreacion DEFAULT(SYSUTCDATETIME()), CONSTRAINT FK_ProductPrices_Product FOREIGN KEY (ProductId) REFERENCES dbo.Product(Id) ON DELETE NO ACTION, CONSTRAINT CK_ProductPrices_Price_Positive CHECK (Price > 0), CONSTRAINT CK_ProductPrices_ValidRange CHECK (PriceValidTo IS NULL OR PriceValidTo >= PriceValidFrom) ); PRINT 'Table dbo.ProductPrices created.'; END ELSE PRINT 'Table dbo.ProductPrices already exists — skip.'; GO -- ═══════════════════════════════════════════════════════════════════════ -- 2. SYSTEM_VERSIONING — ProductPrices -- Las hidden cols se llaman SysStartTime/SysEndTime para evitar -- colisión con las business cols PriceValidFrom/PriceValidTo (D1). -- ═══════════════════════════════════════════════════════════════════════ IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NULL BEGIN ALTER TABLE dbo.ProductPrices ADD SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_ProductPrices_SysStartTime DEFAULT(SYSUTCDATETIME()), SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_ProductPrices_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime); PRINT 'ProductPrices: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).'; END GO IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2) BEGIN ALTER TABLE dbo.ProductPrices SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.ProductPrices_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); PRINT 'ProductPrices: SYSTEM_VERSIONING = ON (history: dbo.ProductPrices_History, retention: 10 years).'; END ELSE PRINT 'ProductPrices: SYSTEM_VERSIONING already ON — skip.'; GO IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ProductPrices_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 = 'ProductPrices_History' AND p.data_compression = 2 ) BEGIN ALTER TABLE dbo.ProductPrices_History REBUILD WITH (DATA_COMPRESSION = PAGE); PRINT 'ProductPrices_History: rebuilt with PAGE compression.'; END GO -- ═══════════════════════════════════════════════════════════════════════ -- 3. Índices -- ═══════════════════════════════════════════════════════════════════════ -- Un único activo por producto (imposibilita violar a nivel BD). IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ProductPrices_Active' AND object_id = OBJECT_ID('dbo.ProductPrices')) BEGIN CREATE UNIQUE INDEX UX_ProductPrices_Active ON dbo.ProductPrices (ProductId) WHERE PriceValidTo IS NULL; PRINT 'Index UX_ProductPrices_Active created.'; END GO -- Cover para GetPriceAt / GetByProductIdAsync (ProductId + PriceValidFrom con INCLUDEs). IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductPrices_Lookup' AND object_id = OBJECT_ID('dbo.ProductPrices')) BEGIN CREATE INDEX IX_ProductPrices_Lookup ON dbo.ProductPrices (ProductId, PriceValidFrom DESC) INCLUDE (Price, PriceValidTo); PRINT 'Index IX_ProductPrices_Lookup created.'; END GO -- ═══════════════════════════════════════════════════════════════════════ -- 4. SP — dbo.usp_AddProductPrice -- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK. -- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio). -- ═══════════════════════════════════════════════════════════════════════ GO CREATE OR ALTER PROCEDURE dbo.usp_AddProductPrice @ProductId INT, @Price DECIMAL(12,2), @PriceValidFrom DATE, @NewId BIGINT OUTPUT, @ClosedId BIGINT OUTPUT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; BEGIN TRY BEGIN TRANSACTION; -- Validación: producto debe existir y estar activo. IF NOT EXISTS (SELECT 1 FROM dbo.Product WITH (NOLOCK) WHERE Id = @ProductId AND IsActive = 1) BEGIN ROLLBACK; THROW 50404, 'Product not found or inactive', 1; END -- Lee activo con UPDLOCK + HOLDLOCK — bloquea el range key del filtered index. DECLARE @ActiveId BIGINT, @ActivePVF DATE; SELECT TOP 1 @ActiveId = Id, @ActivePVF = PriceValidFrom FROM dbo.ProductPrices WITH (UPDLOCK, HOLDLOCK, ROWLOCK) WHERE ProductId = @ProductId AND PriceValidTo IS NULL; -- Forward-only estricto: el nuevo PVF debe ser ESTRICTAMENTE mayor al activo. IF @ActiveId IS NOT NULL AND @PriceValidFrom <= @ActivePVF BEGIN ROLLBACK; THROW 50409, 'ProductPriceForwardOnly: new PriceValidFrom must be > active.PriceValidFrom', 1; END -- Cierra el activo previo: PVT = PVF(nuevo) - 1 día. IF @ActiveId IS NOT NULL BEGIN UPDATE dbo.ProductPrices SET PriceValidTo = DATEADD(DAY, -1, @PriceValidFrom) WHERE Id = @ActiveId; SET @ClosedId = @ActiveId; END ELSE SET @ClosedId = NULL; -- Inserta el nuevo activo. INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo) VALUES (@ProductId, @Price, @PriceValidFrom, NULL); SET @NewId = SCOPE_IDENTITY(); COMMIT TRANSACTION; END TRY BEGIN CATCH IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; THROW; END CATCH END GO PRINT ''; PRINT 'V019 applied — dbo.ProductPrices (temporal, retention 10y) + UX_ProductPrices_Active + IX_ProductPrices_Lookup + usp_AddProductPrice.'; PRINT 'Next migration: V020 (TBD).'; GO