diff --git a/database/README.md b/database/README.md index c8d18a7..2b3f549 100644 --- a/database/README.md +++ b/database/README.md @@ -27,6 +27,8 @@ database/ | V008 | `V008__add_mustchangepassword_and_indexes.sql` | UDT-008 | Usuario.MustChangePassword + IX_Usuario_Activo_Rol | | V009 | `V009__activate_permisos_overrides.sql` | UDT-009 | MigraciΓ³n shape `PermisosJson` `{grant, deny}` | | **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditorΓ­a + Temporal Tables. Ver nota abajo.** | +| V011 | `V011__create_medio_seccion.sql` | ADM-001 | Medio + Seccion (temporal, retention 10y) + permiso `administracion:secciones:gestionar` | +| V012 | `V012__seed_medios.sql` | ADM-001 | Seed idempotente de Medios ELDIA y ELPLATA | ## Convenciones @@ -78,6 +80,16 @@ O desde SSMS: abrir el archivo, conectar a cada base, F5. **CatΓ‘logo de entidades auditables** (source of truth): `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 πŸ“‹ AuditorΓ­a.md`. Cada UDT nueva que introduzca entidades de negocio debe agregar esas tablas al catΓ‘logo y activar `SYSTEM_VERSIONING` en su migraciΓ³n. +### V011/V012 β€” ADM-001 Medios y Secciones + +**Alcance**: crea `dbo.Medio` y `dbo.Seccion` con Temporal Tables (retention 10 aΓ±os), el permiso `administracion:secciones:gestionar` (y lo asigna a rol `admin`), y siembra los dos Medios fundacionales `ELDIA` y `ELPLATA`. + +**Notas**: +- `administracion:medios:gestionar` ya existΓ­a desde V005 β€” no se toca. +- `PlataformaEmpresaId` es `INT NULL` sin FK; la FK se agrega en INT-003 cuando se cree la tabla `PlataformaEmpresa`. +- `Seccion.Tipo` acepta `'clasificados' | 'notables' | 'suplementos'` (CHECK constraint). Config avanzada (par/impar, suplementos, cupos) se introduce en ADM-003. +- Rollback: `V012_ROLLBACK.sql` (falla si hay Secciones vivas) β†’ `V011_ROLLBACK.sql`. Rollback en prod NO soportado si ADM-008/009 o PRC-* ya aplicados. + ## Recursos - Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 πŸ“‹ AuditorΓ­a.md` diff --git a/database/migrations/V011_ROLLBACK.sql b/database/migrations/V011_ROLLBACK.sql new file mode 100644 index 0000000..db381d2 --- /dev/null +++ b/database/migrations/V011_ROLLBACK.sql @@ -0,0 +1,118 @@ +-- V011_ROLLBACK.sql +-- Reversa de V011__create_medio_seccion.sql. +-- +-- ⚠️ ADVERTENCIA: ejecutar ELIMINA Medio, Seccion, su historia temporal, +-- el permiso 'administracion:secciones:gestionar' y sus asignaciones. +-- ('administracion:medios:gestionar' NO se toca β€” es pre-existente de V005.) +-- +-- Uso intended: ROLLBACK en entornos NO-productivos. +-- Prerequisito: no deben existir FKs vivas apuntando a Medio (p.ej., Punto de Venta, Tarifario). +-- Si ADM-008, ADM-009 o PRC-* ya estΓ‘n aplicados, este rollback falla β€” usar backup. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD en Seccion y Medio +-- ═══════════════════════════════════════════════════════════════════════ + +-- Seccion primero (FK al Medio) +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = OFF); + PRINT 'Seccion: SYSTEM_VERSIONING OFF.'; +END +GO + +IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Seccion')) +BEGIN + ALTER TABLE dbo.Seccion DROP PERIOD FOR SYSTEM_TIME; + PRINT 'Seccion: PERIOD FOR SYSTEM_TIME dropped.'; +END +GO + +IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NOT NULL +BEGIN + ALTER TABLE dbo.Seccion DROP CONSTRAINT IF EXISTS DF_Seccion_ValidFrom; + ALTER TABLE dbo.Seccion DROP CONSTRAINT IF EXISTS DF_Seccion_ValidTo; + ALTER TABLE dbo.Seccion DROP COLUMN ValidFrom, ValidTo; + PRINT 'Seccion: ValidFrom/ValidTo dropped.'; +END +GO + +IF OBJECT_ID(N'dbo.Seccion_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Seccion_History; + PRINT 'Seccion_History dropped.'; +END +GO + +-- Medio +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = OFF); + PRINT 'Medio: SYSTEM_VERSIONING OFF.'; +END +GO + +IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Medio')) +BEGIN + ALTER TABLE dbo.Medio DROP PERIOD FOR SYSTEM_TIME; + PRINT 'Medio: PERIOD FOR SYSTEM_TIME dropped.'; +END +GO + +IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NOT NULL +BEGIN + ALTER TABLE dbo.Medio DROP CONSTRAINT IF EXISTS DF_Medio_ValidFrom; + ALTER TABLE dbo.Medio DROP CONSTRAINT IF EXISTS DF_Medio_ValidTo; + ALTER TABLE dbo.Medio DROP COLUMN ValidFrom, ValidTo; + PRINT 'Medio: ValidFrom/ValidTo dropped.'; +END +GO + +IF OBJECT_ID(N'dbo.Medio_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Medio_History; + PRINT 'Medio_History dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 2. Drop Seccion y Medio (Seccion primero por FK) +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.Seccion', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Seccion; + PRINT 'Table dbo.Seccion dropped.'; +END +GO + +IF OBJECT_ID(N'dbo.Medio', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Medio; + PRINT 'Table dbo.Medio dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 3. Remover permiso 'administracion:secciones:gestionar' + RolPermiso +-- ═══════════════════════════════════════════════════════════════════════ + +DELETE rp +FROM dbo.RolPermiso rp +JOIN dbo.Permiso p ON p.Id = rp.PermisoId +WHERE p.Codigo = 'administracion:secciones:gestionar'; +GO + +DELETE FROM dbo.Permiso +WHERE Codigo = 'administracion:secciones:gestionar'; +GO + +PRINT ''; +PRINT 'V011 rolled back. dbo.Medio, dbo.Seccion and their history removed.'; +PRINT 'administracion:medios:gestionar preserved (pre-existing from V005).'; +GO diff --git a/database/migrations/V011__create_medio_seccion.sql b/database/migrations/V011__create_medio_seccion.sql new file mode 100644 index 0000000..dfbb8c7 --- /dev/null +++ b/database/migrations/V011__create_medio_seccion.sql @@ -0,0 +1,206 @@ +-- V011__create_medio_seccion.sql +-- ADM-001 (Fase 1 CRITICAL PATH): Medios y Secciones β€” catΓ‘logo fundacional. +-- +-- Cambios: +-- 1. dbo.Medio (Codigo UQ global, TipoMedio enum 1..4, PlataformaEmpresaId NULL, SYSTEM_VERSIONING ON). +-- 2. dbo.Seccion (FK MedioId, Codigo UQ por Medio, Tipo CHECK, SYSTEM_VERSIONING ON). +-- 3. Permiso 'administracion:secciones:gestionar' + asignaciΓ³n a rol 'admin'. +-- El permiso 'administracion:medios:gestionar' ya existΓ­a desde V005. +-- +-- PatrΓ³n: V007 (permisos MERGE) + V010 (Temporal Tables con retention 10 aΓ±os + PAGE compression en history). +-- Idempotente: seguro para re-ejecutar. +-- Reversa: V011_ROLLBACK.sql. +-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests). +-- +-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.10 πŸ“‹ UDTs MΓ³dulo AdministraciΓ³n.md (ADM-001) +-- Entidades: Obsidian/03-MODELO-de-DATOS/3.2 Entidades Core/3.2.1 🏒 Medio.md +-- AuditorΓ­a: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 πŸ“‹ AuditorΓ­a.md + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. dbo.Medio +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.Medio', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Medio ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Medio PRIMARY KEY, + Codigo VARCHAR(30) NOT NULL, + Nombre NVARCHAR(100) NOT NULL, + Tipo TINYINT NOT NULL, -- TipoMedio: 1=Diario, 2=Radio, 3=Web, 4=Poster + PlataformaEmpresaId INT NULL, -- FK futura a INT-003 (IMAC mapping) + Activo BIT NOT NULL CONSTRAINT DF_Medio_Activo DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Medio_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT UQ_Medio_Codigo UNIQUE (Codigo), + CONSTRAINT CK_Medio_Tipo CHECK (Tipo BETWEEN 1 AND 4) + ); + PRINT 'Table dbo.Medio created.'; +END +ELSE + PRINT 'Table dbo.Medio already exists β€” skip.'; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Medio_Activo_Tipo' AND object_id = OBJECT_ID('dbo.Medio')) +BEGIN + CREATE INDEX IX_Medio_Activo_Tipo + ON dbo.Medio(Activo, Tipo) + INCLUDE (Codigo, Nombre, PlataformaEmpresaId); + PRINT 'Index IX_Medio_Activo_Tipo created.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 2. dbo.Seccion +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.Seccion', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Seccion ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Seccion PRIMARY KEY, + MedioId INT NOT NULL, + Codigo VARCHAR(30) NOT NULL, + Nombre NVARCHAR(100) NOT NULL, + Tipo VARCHAR(20) NOT NULL, -- 'clasificados' | 'notables' | 'suplementos' + Activo BIT NOT NULL CONSTRAINT DF_Seccion_Activo DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Seccion_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT FK_Seccion_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, + CONSTRAINT UQ_Seccion_MedioId_Codigo UNIQUE (MedioId, Codigo), + CONSTRAINT CK_Seccion_Tipo CHECK (Tipo IN ('clasificados','notables','suplementos')) + ); + PRINT 'Table dbo.Seccion created.'; +END +ELSE + PRINT 'Table dbo.Seccion already exists β€” skip.'; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Seccion_MedioId_Activo' AND object_id = OBJECT_ID('dbo.Seccion')) +BEGIN + CREATE INDEX IX_Seccion_MedioId_Activo + ON dbo.Seccion(MedioId, Activo) + INCLUDE (Codigo, Nombre, Tipo); + PRINT 'Index IX_Seccion_MedioId_Activo created.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 3. SYSTEM_VERSIONING β€” Medio +-- ═══════════════════════════════════════════════════════════════════════ + +IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NULL +BEGIN + ALTER TABLE dbo.Medio + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_Medio_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_Medio_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + PRINT 'Medio: PERIOD FOR SYSTEM_TIME added.'; +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Medio + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.Medio_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'Medio: SYSTEM_VERSIONING = ON (history: dbo.Medio_History, retention: 10 years).'; +END +ELSE + PRINT 'Medio: SYSTEM_VERSIONING already ON β€” skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Medio_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 = 'Medio_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.Medio_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'Medio_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 4. SYSTEM_VERSIONING β€” Seccion +-- ═══════════════════════════════════════════════════════════════════════ + +IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NULL +BEGIN + ALTER TABLE dbo.Seccion + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_Seccion_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_Seccion_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + PRINT 'Seccion: PERIOD FOR SYSTEM_TIME added.'; +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Seccion + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.Seccion_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'Seccion: SYSTEM_VERSIONING = ON.'; +END +ELSE + PRINT 'Seccion: SYSTEM_VERSIONING already ON β€” skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Seccion_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 = 'Seccion_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.Seccion_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'Seccion_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 5. Permiso nuevo: administracion:secciones:gestionar +-- ('administracion:medios:gestionar' ya fue sembrado en V005 β€” no se toca). +-- ═══════════════════════════════════════════════════════════════════════ + +MERGE dbo.Permiso AS t +USING (VALUES + ('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio', 'administracion') +) 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', 'administracion:secciones: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 'V011 applied successfully β€” dbo.Medio + dbo.Seccion (temporal, retention 10y) + permiso secciones.'; +PRINT 'Next: V012__seed_medios.sql (seed ELDIA, ELPLATA).'; +GO diff --git a/database/migrations/V012_ROLLBACK.sql b/database/migrations/V012_ROLLBACK.sql new file mode 100644 index 0000000..658caf8 --- /dev/null +++ b/database/migrations/V012_ROLLBACK.sql @@ -0,0 +1,30 @@ +-- V012_ROLLBACK.sql +-- Reversa de V012__seed_medios.sql. +-- +-- Elimina los seed rows ELDIA y ELPLATA solo si NO tienen Secciones asociadas. +-- Si alguna secciΓ³n depende de un seed Medio, el DELETE falla por FK ON DELETE NO ACTION. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- Falla temprano si hay secciones vivas apuntando a estos Medios. +IF EXISTS ( + SELECT 1 + FROM dbo.Seccion s + JOIN dbo.Medio m ON m.Id = s.MedioId + WHERE m.Codigo IN ('ELDIA', 'ELPLATA') +) +BEGIN + RAISERROR('Cannot rollback V012: existen Secciones vinculadas a ELDIA/ELPLATA. Rollback ADM-001 completo con V011_ROLLBACK.sql.', 16, 1); + RETURN; +END +GO + +DELETE FROM dbo.Medio +WHERE Codigo IN ('ELDIA', 'ELPLATA'); +GO + +PRINT 'V012 rolled back β€” seed Medios ELDIA y ELPLATA removed.'; +GO diff --git a/database/migrations/V012__seed_medios.sql b/database/migrations/V012__seed_medios.sql new file mode 100644 index 0000000..28a2e6b --- /dev/null +++ b/database/migrations/V012__seed_medios.sql @@ -0,0 +1,27 @@ +-- V012__seed_medios.sql +-- ADM-001: seed inicial de Medios ELDIA y ELPLATA. +-- +-- Idempotente via MERGE por Codigo. +-- Tipo = 1 (Diario) per enum TipoMedio. +-- PlataformaEmpresaId = NULL (INT-003 lo poblarΓ‘ cuando exista el mapeo IMAC). +-- +-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests). + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +MERGE dbo.Medio AS t +USING (VALUES + ('ELDIA', N'El DΓ­a', 1), + ('ELPLATA', N'El Plata', 1) +) AS s (Codigo, Nombre, Tipo) +ON t.Codigo = s.Codigo +WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo) + VALUES (s.Codigo, s.Nombre, s.Tipo, NULL, 1); +GO + +PRINT 'V012 applied β€” Medios ELDIA y ELPLATA seeded (idempotent).'; +GO diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index d09c8c5..5c959e4 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -36,12 +36,15 @@ public sealed class SqlTestFixture : IAsyncLifetime // Applied manually via: sqlcmd ... -i database/migrations/V010__audit_infrastructure.sql await EnsureV010SchemaAsync(); + // V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'. + await EnsureV011SchemaAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, // Rol is a lookup table seeded by migration V003 β€” never wipe or Usuario FK breaks. // Permiso and RolPermiso are seeded by V005/V006 β€” never wipe or integration tests lose the permission catalog. - // *_History tables: UDT-010 system-versioned β€” Respawn cannot DELETE them directly (engine rejects). + // *_History tables: UDT-010/ADM-001 system-versioned β€” Respawn cannot DELETE them directly (engine rejects). TablesToIgnore = [ new Respawn.Graph.Table("dbo", "Rol"), @@ -51,6 +54,8 @@ public sealed class SqlTestFixture : IAsyncLifetime new Respawn.Graph.Table("dbo", "Rol_History"), new Respawn.Graph.Table("dbo", "Permiso_History"), new Respawn.Graph.Table("dbo", "RolPermiso_History"), + new Respawn.Graph.Table("dbo", "Medio_History"), + new Respawn.Graph.Table("dbo", "Seccion_History"), ] }); @@ -64,6 +69,7 @@ public sealed class SqlTestFixture : IAsyncLifetime await SeedPermisosCanonicalAsync(); await SeedRolPermisosCanonicalAsync(); await SeedAdminAsync(); + await SeedMediosCanonicalAsync(); } private async Task SeedRolCanonicalAsync() @@ -157,7 +163,9 @@ public sealed class SqlTestFixture : IAsyncLifetime -- V007 (UDT-006): permisos administrativos RBAC ('administracion:roles:gestionar', N'Gestionar roles del sistema', N'Crear, editar y desactivar roles RBAC', 'administracion'), ('administracion:roles_permisos:gestionar', N'Gestionar asignacion de permisos', N'Asignar y revocar permisos por rol', 'administracion'), - ('administracion:permisos:ver', N'Ver catalogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion') + ('administracion:permisos:ver', N'Ver catalogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion'), + -- V011 (ADM-001): permiso para CRUD de Secciones + ('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion') ) AS s (Codigo, Nombre, Descripcion, Modulo) ON t.Codigo = s.Codigo WHEN NOT MATCHED BY TARGET THEN @@ -197,6 +205,8 @@ public sealed class SqlTestFixture : IAsyncLifetime ('admin', 'administracion:roles:gestionar'), ('admin', 'administracion:roles_permisos:gestionar'), ('admin', 'administracion:permisos:ver'), + -- V011 (ADM-001) + ('admin', 'administracion:secciones:gestionar'), ('cajero', 'ventas:contado:crear'), ('cajero', 'ventas:contado:modificar'), ('cajero', 'ventas:contado:cobrar'), @@ -241,6 +251,148 @@ public sealed class SqlTestFixture : IAsyncLifetime await _connection.ExecuteAsync(sql); } + /// + /// ADM-001 (V011): applies Medio/Seccion schema + temporal + permiso 'administracion:secciones:gestionar' + /// idempotently to the test database. Mirrors V011__create_medio_seccion.sql. + /// Canonical seed (ELDIA, ELPLATA) vive en SeedMediosCanonicalAsync β€” se reaplica tras cada Respawn. + /// + private async Task EnsureV011SchemaAsync() + { + const string createMedio = """ + IF OBJECT_ID(N'dbo.Medio', N'U') IS NULL + BEGIN + CREATE TABLE dbo.Medio ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Medio PRIMARY KEY, + Codigo VARCHAR(30) NOT NULL, + Nombre NVARCHAR(100) NOT NULL, + Tipo TINYINT NOT NULL, + PlataformaEmpresaId INT NULL, + Activo BIT NOT NULL CONSTRAINT DF_Medio_Activo DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Medio_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT UQ_Medio_Codigo UNIQUE (Codigo), + CONSTRAINT CK_Medio_Tipo CHECK (Tipo BETWEEN 1 AND 4) + ); + END + """; + + const string createMedioIndex = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Medio_Activo_Tipo' AND object_id = OBJECT_ID('dbo.Medio')) + BEGIN + CREATE INDEX IX_Medio_Activo_Tipo + ON dbo.Medio(Activo, Tipo) + INCLUDE (Codigo, Nombre, PlataformaEmpresaId); + END + """; + + const string createSeccion = """ + IF OBJECT_ID(N'dbo.Seccion', N'U') IS NULL + BEGIN + CREATE TABLE dbo.Seccion ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Seccion PRIMARY KEY, + MedioId INT NOT NULL, + Codigo VARCHAR(30) NOT NULL, + Nombre NVARCHAR(100) NOT NULL, + Tipo VARCHAR(20) NOT NULL, + Activo BIT NOT NULL CONSTRAINT DF_Seccion_Activo DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Seccion_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT FK_Seccion_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, + CONSTRAINT UQ_Seccion_MedioId_Codigo UNIQUE (MedioId, Codigo), + CONSTRAINT CK_Seccion_Tipo CHECK (Tipo IN ('clasificados','notables','suplementos')) + ); + END + """; + + const string createSeccionIndex = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Seccion_MedioId_Activo' AND object_id = OBJECT_ID('dbo.Seccion')) + BEGIN + CREATE INDEX IX_Seccion_MedioId_Activo + ON dbo.Seccion(MedioId, Activo) + INCLUDE (Codigo, Nombre, Tipo); + END + """; + + const string addMedioPeriod = """ + IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NULL + BEGIN + ALTER TABLE dbo.Medio + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_Medio_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_Medio_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + END + """; + + const string setMedioVersioning = """ + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2) + BEGIN + ALTER TABLE dbo.Medio + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.Medio_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + END + """; + + const string addSeccionPeriod = """ + IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NULL + BEGIN + ALTER TABLE dbo.Seccion + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_Seccion_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_Seccion_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + END + """; + + const string setSeccionVersioning = """ + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2) + BEGIN + ALTER TABLE dbo.Seccion + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.Seccion_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + END + """; + + await _connection.ExecuteAsync(createMedio); + await _connection.ExecuteAsync(createMedioIndex); + await _connection.ExecuteAsync(createSeccion); + await _connection.ExecuteAsync(createSeccionIndex); + await _connection.ExecuteAsync(addMedioPeriod); + await _connection.ExecuteAsync(setMedioVersioning); + await _connection.ExecuteAsync(addSeccionPeriod); + await _connection.ExecuteAsync(setSeccionVersioning); + // Permiso 'administracion:secciones:gestionar' + asignaciΓ³n a admin se siembran + // desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). + } + + /// + /// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset. + /// + private async Task SeedMediosCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + MERGE dbo.Medio AS t + USING (VALUES + ('ELDIA', N'El DΓ­a', 1), + ('ELPLATA', N'El Plata', 1) + ) AS s (Codigo, Nombre, Tipo) + ON t.Codigo = s.Codigo + WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo) + VALUES (s.Codigo, s.Nombre, s.Tipo, NULL, 1); + """; + await _connection.ExecuteAsync(sql); + } + /// /// UDT-010 (V010): verifies that the audit infrastructure is present. /// Does NOT re-apply the migration (the ALTER DATABASE ADD FILEGROUP/FILE + partition