diff --git a/database/README.md b/database/README.md index 2b3f549..872d526 100644 --- a/database/README.md +++ b/database/README.md @@ -29,6 +29,10 @@ database/ | **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 | +| V013 | `V013__create_puntos_de_venta.sql` | ADM-008 | PuntosDeVenta (temporal, retention 10y) + permiso `administracion:puntos_de_venta:gestionar` | +| V014 | `V014__create_tablas_fiscales.sql` | ADM-009 | TiposDeIva + IngresosBrutos (versioning por cadena) + permisos fiscales | +| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina | +| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** | ## Convenciones diff --git a/database/migrations/V016_ROLLBACK.sql b/database/migrations/V016_ROLLBACK.sql new file mode 100644 index 0000000..7c13637 --- /dev/null +++ b/database/migrations/V016_ROLLBACK.sql @@ -0,0 +1,82 @@ +-- V016_ROLLBACK.sql +-- Reversa de V016__create_rubro.sql. +-- +-- ⚠️ ADVERTENCIA: ejecutar ELIMINA dbo.Rubro, dbo.Rubro_History, +-- el permiso 'catalogo:rubros:gestionar' y sus asignaciones. +-- +-- Uso intended: ROLLBACK en entornos NO-productivos. +-- Prerequisito: no deben existir FKs vivas apuntando a Rubro (p.ej., Producto, Tarifario). +-- Si CAT-002..006 o PRC-001 ya están aplicados, agregar TarifarioBaseId FK, +-- este rollback fallará — usar backup. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD en Rubro +-- ═══════════════════════════════════════════════════════════════════════ + +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rubro') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = OFF); + PRINT 'Rubro: SYSTEM_VERSIONING OFF.'; +END +GO + +IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Rubro')) +BEGIN + ALTER TABLE dbo.Rubro DROP PERIOD FOR SYSTEM_TIME; + PRINT 'Rubro: PERIOD FOR SYSTEM_TIME dropped.'; +END +GO + +IF COL_LENGTH('dbo.Rubro', 'ValidFrom') IS NOT NULL +BEGIN + ALTER TABLE dbo.Rubro DROP CONSTRAINT IF EXISTS DF_Rubro_ValidFrom; + ALTER TABLE dbo.Rubro DROP CONSTRAINT IF EXISTS DF_Rubro_ValidTo; + ALTER TABLE dbo.Rubro DROP COLUMN ValidFrom, ValidTo; + PRINT 'Rubro: ValidFrom/ValidTo dropped.'; +END +GO + +IF OBJECT_ID(N'dbo.Rubro_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Rubro_History; + PRINT 'Rubro_History dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 2. Drop índices + tabla Rubro +-- ═══════════════════════════════════════════════════════════════════════ + +-- Self-FK must be dropped before dropping the table (SQL Server handles it +-- automatically when the table is dropped, but explicit is safer). + +IF OBJECT_ID(N'dbo.Rubro', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Rubro; + PRINT 'Table dbo.Rubro dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 3. Remover permiso 'catalogo:rubros:gestionar' + RolPermiso +-- ═══════════════════════════════════════════════════════════════════════ + +DELETE rp +FROM dbo.RolPermiso rp +JOIN dbo.Permiso p ON p.Id = rp.PermisoId +WHERE p.Codigo = 'catalogo:rubros:gestionar'; +GO + +DELETE FROM dbo.Permiso +WHERE Codigo = 'catalogo:rubros:gestionar'; +GO + +PRINT ''; +PRINT 'V016 rolled back. dbo.Rubro and dbo.Rubro_History removed.'; +PRINT 'catalogo:rubros:gestionar permission and role assignment removed.'; +GO diff --git a/database/migrations/V016__create_rubro.sql b/database/migrations/V016__create_rubro.sql new file mode 100644 index 0000000..853ac22 --- /dev/null +++ b/database/migrations/V016__create_rubro.sql @@ -0,0 +1,152 @@ +-- V016__create_rubro.sql +-- CAT-001: Árbol N-ario de Rubros — tabla fundacional del catálogo comercial. +-- +-- Cambios: +-- 1. dbo.Rubro (adjacency list, self-FK, soft-delete, SYSTEM_VERSIONING ON, retention 10 años). +-- 2. Índice filtrado unique UQ_Rubro_ParentId_Nombre_Activo (unicidad CI por padre en activos). +-- 3. Índice cubriente IX_Rubro_ParentId_Activo (child lookups ordenados). +-- 4. Permiso 'catalogo:rubros:gestionar' + asignación a rol 'admin'. +-- +-- Patrón: V011 (dbo.Medio con SYSTEM_VERSIONING + PAGE compression + MERGE permisos). +-- Idempotente: seguro para re-ejecutar. +-- Reversa: V016_ROLLBACK.sql. +-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests). +-- +-- Notas: +-- - TarifarioBaseId es INT NULL SIN FK — la FK se agrega en PRC-001. +-- - UQ_Rubro_ParentId_Nombre_Activo cubre solo ParentId IS NOT NULL; +-- para roots (ParentId IS NULL) la unicidad CI la garantiza Application +-- via ExistsByNombreUnderParentAsync(null, ...) — SQL Server trata NULLs +-- como distintos en índices únicos. Ver Design §9 Risk 1. +-- - FechaCreacion / FechaModificacion: DATETIME2(3) alineado con Medio/Seccion. +-- - ValidFrom / ValidTo: DATETIME2(3) GENERATED ALWAYS HIDDEN (idéntico a V011). +-- +-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md +-- SDD Design: engram sdd/cat-001-arbol-nario-rubros/design + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. dbo.Rubro +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.Rubro', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Rubro ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Rubro PRIMARY KEY, + ParentId INT NULL, + Nombre NVARCHAR(200) NOT NULL COLLATE SQL_Latin1_General_CP1_CI_AI, + Orden INT NOT NULL CONSTRAINT DF_Rubro_Orden DEFAULT(0), + Activo BIT NOT NULL CONSTRAINT DF_Rubro_Activo DEFAULT(1), + TarifarioBaseId INT NULL, -- FK reservada para PRC-001 (sin constraint por ahora) + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Rubro_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT FK_Rubro_Parent FOREIGN KEY (ParentId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION + ); + PRINT 'Table dbo.Rubro created.'; +END +ELSE + PRINT 'Table dbo.Rubro already exists — skip.'; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 2. SYSTEM_VERSIONING — Rubro +-- ═══════════════════════════════════════════════════════════════════════ + +IF COL_LENGTH('dbo.Rubro', 'ValidFrom') IS NULL +BEGIN + ALTER TABLE dbo.Rubro + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_Rubro_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_Rubro_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + PRINT 'Rubro: PERIOD FOR SYSTEM_TIME added.'; +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rubro') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Rubro + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.Rubro_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'Rubro: SYSTEM_VERSIONING = ON (history: dbo.Rubro_History, retention: 10 years).'; +END +ELSE + PRINT 'Rubro: SYSTEM_VERSIONING already ON — skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Rubro_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 = 'Rubro_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.Rubro_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'Rubro_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 3. Índices +-- ═══════════════════════════════════════════════════════════════════════ + +-- Unicidad CI por nombre bajo el mismo padre (solo filas activas + ParentId NOT NULL). +-- Para roots (ParentId IS NULL) la unicidad la garantiza Application layer. +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Rubro_ParentId_Nombre_Activo' AND object_id = OBJECT_ID('dbo.Rubro')) +BEGIN + CREATE UNIQUE INDEX UQ_Rubro_ParentId_Nombre_Activo + ON dbo.Rubro(ParentId, Nombre) + WHERE Activo = 1 AND ParentId IS NOT NULL; + PRINT 'Index UQ_Rubro_ParentId_Nombre_Activo created.'; +END +GO + +-- Cubriente para child lookups ordenados por Orden. +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Rubro_ParentId_Activo' AND object_id = OBJECT_ID('dbo.Rubro')) +BEGIN + CREATE INDEX IX_Rubro_ParentId_Activo + ON dbo.Rubro(ParentId, Activo) + INCLUDE (Nombre, Orden); + PRINT 'Index IX_Rubro_ParentId_Activo created.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 4. Permiso: catalogo:rubros:gestionar + asignación a rol 'admin' +-- ═══════════════════════════════════════════════════════════════════════ + +MERGE dbo.Permiso AS t +USING (VALUES + ('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de 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:rubros: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 'V016 applied successfully — dbo.Rubro (temporal, retention 10y) + permiso catalogo:rubros:gestionar.'; +PRINT 'Next: V017 (future — TBD by next UDT).'; +GO diff --git a/src/api/SIGCM2.Api/appsettings.json b/src/api/SIGCM2.Api/appsettings.json index aeecbd0..23f17f1 100644 --- a/src/api/SIGCM2.Api/appsettings.json +++ b/src/api/SIGCM2.Api/appsettings.json @@ -32,5 +32,8 @@ ], "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ] }, + "Rubros": { + "MaxDepth": 10 + }, "AllowedHosts": "*" } diff --git a/src/api/SIGCM2.Application/Rubros/RubrosOptions.cs b/src/api/SIGCM2.Application/Rubros/RubrosOptions.cs new file mode 100644 index 0000000..e9e631b --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/RubrosOptions.cs @@ -0,0 +1,13 @@ +namespace SIGCM2.Application.Rubros; + +/// Bound from appsettings section "Rubros". +/// Controls the maximum allowed depth of the N-ary rubro tree. +/// Resolvable via IOptions in any handler that enforces the depth rule. +public sealed class RubrosOptions +{ + public const string SectionName = "Rubros"; + + /// Maximum tree depth (0 = root level). Default: 10. + /// Depth-10 means a root + 9 levels of children. + public int MaxDepth { get; set; } = 10; +} diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 0931998..f940494 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -10,6 +10,7 @@ using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; using SIGCM2.Application.Audit; using SIGCM2.Application.Auth; +using SIGCM2.Application.Rubros; using SIGCM2.Infrastructure.Http; using SIGCM2.Infrastructure.Messaging; using SIGCM2.Infrastructure.Persistence; @@ -77,6 +78,9 @@ public static class DependencyInjection services.AddHttpContextAccessor(); services.AddScoped(); + // CAT-001: Rubros options (MaxDepth) — overridable via appsettings "Rubros". + services.Configure(configuration.GetSection(RubrosOptions.SectionName)); + // UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit". services.Configure(configuration.GetSection(AuditOptions.SectionName)); services.AddScoped();