diff --git a/database/migrations/V018_ROLLBACK.sql b/database/migrations/V018_ROLLBACK.sql new file mode 100644 index 0000000..562e157 --- /dev/null +++ b/database/migrations/V018_ROLLBACK.sql @@ -0,0 +1,67 @@ +-- V018_ROLLBACK.sql +-- Reversa de V018__create_product.sql — PRD-002. +-- +-- Idempotente: cada paso usa IF EXISTS guards. +-- ADVERTENCIA: Ejecutar antes de V017_ROLLBACK (FK desde Product hacia ProductType). + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- 1. SYSTEM_VERSIONING OFF +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Product SET (SYSTEM_VERSIONING = OFF); + PRINT 'Product: SYSTEM_VERSIONING = OFF.'; +END +GO + +-- 2. DROP PERIOD +IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Product')) +BEGIN + ALTER TABLE dbo.Product DROP PERIOD FOR SYSTEM_TIME; + PRINT 'Product: PERIOD FOR SYSTEM_TIME dropped.'; +END +GO + +-- 3. Drop HIDDEN columns + default constraints +IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NOT NULL +BEGIN + ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidFrom; + ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidTo; + ALTER TABLE dbo.Product DROP COLUMN ValidFrom, ValidTo; + PRINT 'Product: ValidFrom/ValidTo columns dropped.'; +END +GO + +-- 4. Drop history +IF OBJECT_ID(N'dbo.Product_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Product_History; + PRINT 'Table dbo.Product_History dropped.'; +END +GO + +-- 5. Drop main +IF OBJECT_ID(N'dbo.Product', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Product; + PRINT 'Table dbo.Product dropped.'; +END +GO + +-- 6. Remove RolPermiso / Permiso +DELETE rp FROM dbo.RolPermiso rp + JOIN dbo.Permiso p ON p.Id = rp.PermisoId + WHERE p.Codigo = 'catalogo:productos:gestionar'; +PRINT 'RolPermiso rows for catalogo:productos:gestionar deleted.'; +GO + +DELETE FROM dbo.Permiso WHERE Codigo = 'catalogo:productos:gestionar'; +PRINT 'Permiso catalogo:productos:gestionar deleted.'; +GO + +PRINT ''; +PRINT 'V018 rolled back successfully.'; +GO diff --git a/database/migrations/V018__create_product.sql b/database/migrations/V018__create_product.sql new file mode 100644 index 0000000..08bbfb7 --- /dev/null +++ b/database/migrations/V018__create_product.sql @@ -0,0 +1,172 @@ +-- V018__create_product.sql +-- PRD-002: Product — entidad vendible concreta del catálogo comercial. +-- +-- Cambios: +-- 1. dbo.Product (FK Medio/ProductType/Rubro, SYSTEM_VERSIONING ON, retention 10 años). +-- 2. Índices: filtered UQ por (MedioId, ProductTypeId, Nombre) activos; cover por ProductTypeId +-- (para IProductQueryRepository); cover por MedioId; cover filtrado por RubroId. +-- 3. Permiso 'catalogo:productos:gestionar' + asignación a rol 'admin'. +-- +-- Patrón: V017 (dbo.ProductType con SYSTEM_VERSIONING + PAGE compression + MERGE permisos). +-- Idempotente: seguro para re-ejecutar. +-- Reversa: V018_ROLLBACK.sql. +-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests). +-- +-- Notas: +-- - SIN seed de datos — PRD-008 (V019) seedea los 12 productos legacy. +-- - Validación de flags (RequiresCategory, HasDuration) vive en Application layer: +-- un ProductType puede cambiar flags; la Product queda en estado snapshot. +-- - UQ filtered WHERE IsActive=1: permite reusar nombres tras soft-delete. +-- +-- SDD Design: engram sdd/prd-002-product-crud/design + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. dbo.Product +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.Product', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Product ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY, + Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL, + MedioId INT NOT NULL, + ProductTypeId INT NOT NULL, + RubroId INT NULL, + BasePrice DECIMAL(18,4) NOT NULL, + PriceDurationDays INT NULL, + IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, + CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION, + CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION, + CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0), + CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1) + ); + PRINT 'Table dbo.Product created.'; +END +ELSE + PRINT 'Table dbo.Product already exists — skip.'; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 2. SYSTEM_VERSIONING — Product +-- ═══════════════════════════════════════════════════════════════════════ + +IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL +BEGIN + ALTER TABLE dbo.Product + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + PRINT 'Product: PERIOD FOR SYSTEM_TIME added.'; +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Product + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.Product_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'Product: SYSTEM_VERSIONING = ON (history: dbo.Product_History, retention: 10 years).'; +END +ELSE + PRINT 'Product: SYSTEM_VERSIONING already ON — skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Product_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 = 'Product_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.Product_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'Product_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 3. Índices +-- ═══════════════════════════════════════════════════════════════════════ + +-- Filtered UQ: unicidad activa por (Medio, Tipo, Nombre). Permite reusar nombres tras soft-delete. +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product')) +BEGIN + CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active + ON dbo.Product (MedioId, ProductTypeId, Nombre) + WHERE IsActive = 1; + PRINT 'Index UQ_Product_MedioId_ProductTypeId_Nombre_Active created.'; +END +GO + +-- Cover para IProductQueryRepository.ExistsActiveByProductTypeAsync +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) +BEGIN + CREATE INDEX IX_Product_ProductTypeId_IsActive + ON dbo.Product (ProductTypeId, IsActive); + PRINT 'Index IX_Product_ProductTypeId_IsActive created.'; +END +GO + +-- Cover para list filtered by MedioId +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) +BEGIN + CREATE INDEX IX_Product_MedioId_IsActive + ON dbo.Product (MedioId, IsActive); + PRINT 'Index IX_Product_MedioId_IsActive created.'; +END +GO + +-- Cover para list filtered by RubroId +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) +BEGIN + CREATE INDEX IX_Product_RubroId_IsActive + ON dbo.Product (RubroId, IsActive) + WHERE RubroId IS NOT NULL; + PRINT 'Index IX_Product_RubroId_IsActive created.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 4. Permiso: catalogo:productos:gestionar + asignación a rol 'admin' +-- ═══════════════════════════════════════════════════════════════════════ + +MERGE dbo.Permiso AS t +USING (VALUES + ('catalogo:productos:gestionar', + N'Gestionar productos del catálogo', + N'Crear, editar y desactivar productos del catálogo comercial', + 'catalogo') +) AS s (Codigo, Nombre, Descripcion, Modulo) +ON t.Codigo = s.Codigo +WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Modulo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo); +GO + +MERGE dbo.RolPermiso AS t +USING ( + SELECT r.Id AS RolId, p.Id AS PermisoId + FROM (VALUES ('admin', 'catalogo:productos:gestionar')) AS x (RolCodigo, PermisoCodigo) + JOIN dbo.Rol r ON r.Codigo = x.RolCodigo + JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo +) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId +WHEN NOT MATCHED BY TARGET THEN + INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId); +GO + +PRINT ''; +PRINT 'V018 applied — dbo.Product (temporal, retention 10y) + permiso catalogo:productos:gestionar.'; +PRINT 'Next: V019 (PRD-008 — seed 12 productos legacy).'; +GO diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index f2737da..821813a 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -63,6 +63,9 @@ public sealed class SqlTestFixture : IAsyncLifetime // V017 (PRD-001): ensure dbo.ProductType + temporal + permiso 'catalogo:tipos:gestionar'. await EnsureV017SchemaAsync(); + // V018 (PRD-002): ensure dbo.Product + temporal + permiso 'catalogo:productos:gestionar'. + await EnsureV018SchemaAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, @@ -91,6 +94,8 @@ public sealed class SqlTestFixture : IAsyncLifetime new Respawn.Graph.Table("dbo", "Rubro_History"), // PRD-001 (V017): ProductType es temporal — history no puede deletearse directo. new Respawn.Graph.Table("dbo", "ProductType_History"), + // PRD-002 (V018): Product es temporal — history no puede deletearse directo. + new Respawn.Graph.Table("dbo", "Product_History"), ] }); @@ -213,7 +218,11 @@ public sealed class SqlTestFixture : IAsyncLifetime -- V014 (ADM-009): permiso para tablas fiscales ('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion'), -- V016 (CAT-001): permiso para gestionar árbol de rubros - ('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo') + ('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo'), + -- V017 (PRD-001): permiso para gestionar tipos de producto + ('catalogo:tipos:gestionar', N'Gestionar tipos de producto', N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)', 'catalogo'), + -- V018 (PRD-002): permiso para gestionar productos del catálogo + ('catalogo:productos:gestionar', N'Gestionar productos del catálogo', N'Crear, editar y desactivar productos del catálogo comercial', 'catalogo') ) AS s (Codigo, Nombre, Descripcion, Modulo) ON t.Codigo = s.Codigo WHEN NOT MATCHED BY TARGET THEN @@ -261,6 +270,10 @@ public sealed class SqlTestFixture : IAsyncLifetime ('admin', 'administracion:fiscal:gestionar'), -- V016 (CAT-001) ('admin', 'catalogo:rubros:gestionar'), + -- V017 (PRD-001) + ('admin', 'catalogo:tipos:gestionar'), + -- V018 (PRD-002) + ('admin', 'catalogo:productos:gestionar'), ('cajero', 'ventas:contado:crear'), ('cajero', 'ventas:contado:modificar'), ('cajero', 'ventas:contado:cobrar'), @@ -1011,4 +1024,102 @@ public sealed class SqlTestFixture : IAsyncLifetime await _connection.ExecuteAsync(createUqIndex); await _connection.ExecuteAsync(createCoveringIndex); } + + /// + /// PRD-002 (V018): applies dbo.Product schema + temporal + filtered UQ + covering indexes + /// idempotentemente. Mirrors V018__create_product.sql. + /// Permiso 'catalogo:productos:gestionar' y asignación a admin se siembran + /// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). + /// + public async Task EnsureV018SchemaAsync() + { + const string createProduct = """ + IF OBJECT_ID(N'dbo.Product', N'U') IS NULL + BEGIN + CREATE TABLE dbo.Product ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY, + Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL, + MedioId INT NOT NULL, + ProductTypeId INT NOT NULL, + RubroId INT NULL, + BasePrice DECIMAL(18,4) NOT NULL, + PriceDurationDays INT NULL, + IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, + CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION, + CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION, + CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0), + CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1) + ); + END + """; + + const string addProductPeriod = """ + IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL + BEGIN + ALTER TABLE dbo.Product + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + END + """; + + const string setProductVersioning = """ + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2) + BEGIN + ALTER TABLE dbo.Product + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.Product_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + END + """; + + const string createUqIndex = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product')) + BEGIN + CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active + ON dbo.Product (MedioId, ProductTypeId, Nombre) + WHERE IsActive = 1; + END + """; + + const string createProductTypeIdx = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) + BEGIN + CREATE INDEX IX_Product_ProductTypeId_IsActive + ON dbo.Product (ProductTypeId, IsActive); + END + """; + + const string createMedioIdx = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) + BEGIN + CREATE INDEX IX_Product_MedioId_IsActive + ON dbo.Product (MedioId, IsActive); + END + """; + + const string createRubroIdx = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) + BEGIN + CREATE INDEX IX_Product_RubroId_IsActive + ON dbo.Product (RubroId, IsActive) + WHERE RubroId IS NOT NULL; + END + """; + + await _connection.ExecuteAsync(createProduct); + await _connection.ExecuteAsync(addProductPeriod); + await _connection.ExecuteAsync(setProductVersioning); + await _connection.ExecuteAsync(createUqIndex); + await _connection.ExecuteAsync(createProductTypeIdx); + await _connection.ExecuteAsync(createMedioIdx); + await _connection.ExecuteAsync(createRubroIdx); + } }