feat: CAT-001 Árbol N-ario de Rubros #30

Merged
dmolinari merged 12 commits from feature/CAT-001 into main 2026-04-19 10:49:37 +00:00
Owner

feat: CAT-001 Árbol N-ario de Rubros

Resumen

Implementa el árbol N-ario de rubros (taxonomía jerárquica) que sustenta el catálogo comercial de SIG-CM 2.0. Cubre la pila completa: migración de BD, dominio, application, infraestructura, API REST y frontend React.

Cambios por capa

Base de datos (d3ed830, 4a88cb4)

  • V016__create_rubro.sql: tabla dbo.Rubro con Adjacency List (ParentId INT NULL FK auto-referencial), SYSTEM_VERSIONING ON (history dbo.Rubro_History, retention 10 años), índice único filtrado UQ_Rubro_ParentId_Nombre_Activo WHERE ParentId IS NOT NULL AND Activo=1, índice cubriente IX_Rubro_ParentId_Activo, permiso catalogo:rubros:gestionar + RolPermiso admin.
  • Fix: COLLATE SQL_Latin1_General_CP1_CI_AI debe ir ANTES de NOT NULL (SQL Server 2019 parser).
  • V016_ROLLBACK.sql incluido. Aplicada a SIGCM2 y SIGCM2_Test.

Domain + Application (4c9b7ea, d4c05cc)

  • Rubro entity: sealed class, factory methods ForCreation, WithRenamed, WithMoved, WithActivo; validación ValidateNombre.
  • 6 domain exceptions: RubroNotFoundException (404), RubroNombreDuplicadoEnPadreException (409), RubroTieneHijosActivosException (409), RubroPadreInactivoException (400), RubroMaxDepthExceededException (422), RubroCycleDetectedException (400) — todas wired en ExceptionFilter.
  • IRubroRepository: 9 métodos incluyendo GetDepthAsync, GetMaxOrdenAsync, ExistsByNombreUnderParentAsync, GetDescendantIdsAsync.
  • RubroTreeBuilder: O(n) con ToLookup (no ToDictionary — maneja claves int? sin DuplicateKeyException).
  • Commands: CreateRubro, UpdateRubro, DeactivateRubro, MoveRubro + handlers.
  • Queries: GetRubroTree, GetRubroById + handlers.

Infrastructure (cc3108d)

  • RubroRepository (Dapper): recursive CTEs para árbol, ExistsByNombreUnderParentAsync con UPPER() CI, GetMaxOrdenAsync con MAX+1 semántico.
  • DI: IRubroRepository → RubroRepository registrado.

API (ff7c287)

  • RubrosController (6 endpoints): GET /rubros/tree, GET /rubros/{id}, POST /admin/rubros, PUT /admin/rubros/{id}, DELETE /admin/rubros/{id}, PATCH /admin/rubros/{id}/mover.
  • Todos con auditoría rubro.* vía IAuditLogger.

Frontend (f6733ac, f8d861a)

  • Feature src/web/src/features/rubros/ — types, api (6 fns), hooks (5), components (CategoryTree, CategoryTreeNode, RubroFormDialog, DeleteRubroDialog), pages (RubrosPage).
  • CategoryTreeNode: Radix Collapsible, depth guard > 10, badge inactivo, botones condicionales.
  • RubrosPage: loading skeleton, error Alert, CanPerform gate, Switch incluir-inactivos, dialogs wired.
  • Route /admin/rubros (ProtectedPage, catalogo:rubros:gestionar) + sidebar entry "Rubros".
  • Fix build (f8d861a): zodResolver type inference con schema z.string().transform+pipe en lugar de z.union([z.coerce.number(), z.literal('')]).

Tests (b1be4a5)

  • Propagación Rubro_History a TablesToIgnore + counts de permisos 24 → 25 en 5 archivos.

Commits

Hash Mensaje
d3ed830 feat(bd): V016 create Rubro table con SYSTEM_VERSIONING (CAT-001)
4a88cb4 fix(bd): V016 COLLATE order — SQL Server requiere COLLATE antes de NOT NULL (CAT-001)
4c9b7ea feat(domain): Rubro entity + domain exceptions (CAT-001)
d4c05cc feat(application): Rubros commands/queries + RubroTreeBuilder + audit (CAT-001)
b1be4a5 fix(tests): propagar Rubro_History + permisos 25 a integration tests (CAT-001)
cc3108d feat(infrastructure): RubroRepository Dapper + DI + integration tests (CAT-001)
ff7c287 feat(api): RubrosController + integration tests e2e + audit verification (CAT-001)
f6733ac feat(frontend): rubros feature + CategoryTree + CRUD dialogs (CAT-001)
f8d861a fix(frontend): corregir tipos zodResolver en RubroFormDialog (CAT-001)

Decisiones clave

  • Adjacency List: operaciones de movimiento O(1), queries de árbol completo vía CTE recursiva — adecuado para catálogos < 10k nodos.
  • NO FK a Seccion: Rubro independiente del módulo de medios; asociación diferida a CAT-002+.
  • Soft-delete bloqueante: rechaza si existen hijos activos — integridad referencial en dominio.
  • Unicidad CI vía índice filtrado: WHERE ParentId IS NOT NULL AND Activo=1; raíces enforced por ExistsByNombreUnderParentAsync.
  • SYSTEM_VERSIONING 10 años: alineado con ADM-001/ADM-008/ADM-009.
  • ToLookup en RubroTreeBuilder: GroupBy(...).ToDictionary() lanza DuplicateKeyException en runtime con int? keys.

Resumen de tests

Suite Total Estado
Application.Tests (unit + integration) 810 0 fallos
Api.Tests (integration e2e) 251 0 fallos
Frontend vitest 335 0 fallos
TOTAL 1396 100%

Nota: AuditEventRepositoryTests.QueryAsync_Limit_EmitsCursor_WhenMoreRowsAvailable es flaky pre-existente (issue #29) — pasa en ejecución secuencial individual.

Seguimientos abiertos (out of scope)

  • issue #29: Flakiness por SIGCM2_Test compartida — resolver antes de CAT-002.
  • MoveRubroDialog UI: onMove={() => {}} placeholder en RubrosPage — hook implementado y testeado, UI del dialog diferida.
  • Format backend: violaciones whitespace en TestWebAppFactory.cs + AssignPermisosToRolCommandHandlerTests.cs — pre-existentes, out of scope.

Artefactos SDD

Engram project sig-cm2: sdd/cat-001-arbol-nario-rubros/{explore,proposal,spec,design,tasks,apply-progress,verify-report,pr-body}

# feat: CAT-001 Árbol N-ario de Rubros ## Resumen Implementa el árbol N-ario de rubros (taxonomía jerárquica) que sustenta el catálogo comercial de SIG-CM 2.0. Cubre la pila completa: migración de BD, dominio, application, infraestructura, API REST y frontend React. ## Cambios por capa ### Base de datos (`d3ed830`, `4a88cb4`) - **V016__create_rubro.sql**: tabla `dbo.Rubro` con Adjacency List (`ParentId INT NULL FK auto-referencial`), `SYSTEM_VERSIONING ON` (history `dbo.Rubro_History`, retention 10 años), índice único filtrado `UQ_Rubro_ParentId_Nombre_Activo WHERE ParentId IS NOT NULL AND Activo=1`, índice cubriente `IX_Rubro_ParentId_Activo`, permiso `catalogo:rubros:gestionar` + RolPermiso admin. - Fix: `COLLATE SQL_Latin1_General_CP1_CI_AI` debe ir ANTES de `NOT NULL` (SQL Server 2019 parser). - `V016_ROLLBACK.sql` incluido. Aplicada a `SIGCM2` y `SIGCM2_Test`. ### Domain + Application (`4c9b7ea`, `d4c05cc`) - **`Rubro` entity**: `sealed class`, factory methods `ForCreation`, `WithRenamed`, `WithMoved`, `WithActivo`; validación `ValidateNombre`. - **6 domain exceptions**: `RubroNotFoundException` (404), `RubroNombreDuplicadoEnPadreException` (409), `RubroTieneHijosActivosException` (409), `RubroPadreInactivoException` (400), `RubroMaxDepthExceededException` (422), `RubroCycleDetectedException` (400) — todas wired en `ExceptionFilter`. - **`IRubroRepository`**: 9 métodos incluyendo `GetDepthAsync`, `GetMaxOrdenAsync`, `ExistsByNombreUnderParentAsync`, `GetDescendantIdsAsync`. - **`RubroTreeBuilder`**: O(n) con `ToLookup` (no `ToDictionary` — maneja claves `int?` sin `DuplicateKeyException`). - **Commands**: `CreateRubro`, `UpdateRubro`, `DeactivateRubro`, `MoveRubro` + handlers. - **Queries**: `GetRubroTree`, `GetRubroById` + handlers. ### Infrastructure (`cc3108d`) - **`RubroRepository`** (Dapper): recursive CTEs para árbol, `ExistsByNombreUnderParentAsync` con `UPPER()` CI, `GetMaxOrdenAsync` con MAX+1 semántico. - DI: `IRubroRepository → RubroRepository` registrado. ### API (`ff7c287`) - **`RubrosController`** (6 endpoints): `GET /rubros/tree`, `GET /rubros/{id}`, `POST /admin/rubros`, `PUT /admin/rubros/{id}`, `DELETE /admin/rubros/{id}`, `PATCH /admin/rubros/{id}/mover`. - Todos con auditoría `rubro.*` vía `IAuditLogger`. ### Frontend (`f6733ac`, `f8d861a`) - Feature `src/web/src/features/rubros/` — types, api (6 fns), hooks (5), components (CategoryTree, CategoryTreeNode, RubroFormDialog, DeleteRubroDialog), pages (RubrosPage). - **CategoryTreeNode**: Radix Collapsible, depth guard > 10, badge inactivo, botones condicionales. - **RubrosPage**: loading skeleton, error Alert, `CanPerform` gate, Switch incluir-inactivos, dialogs wired. - Route `/admin/rubros` (ProtectedPage, `catalogo:rubros:gestionar`) + sidebar entry "Rubros". - Fix build (`f8d861a`): `zodResolver` type inference con schema `z.string().transform+pipe` en lugar de `z.union([z.coerce.number(), z.literal('')])`. ### Tests (`b1be4a5`) - Propagación `Rubro_History` a `TablesToIgnore` + counts de permisos 24 → 25 en 5 archivos. ## Commits | Hash | Mensaje | |------|---------| | `d3ed830` | feat(bd): V016 create Rubro table con SYSTEM_VERSIONING (CAT-001) | | `4a88cb4` | fix(bd): V016 COLLATE order — SQL Server requiere COLLATE antes de NOT NULL (CAT-001) | | `4c9b7ea` | feat(domain): Rubro entity + domain exceptions (CAT-001) | | `d4c05cc` | feat(application): Rubros commands/queries + RubroTreeBuilder + audit (CAT-001) | | `b1be4a5` | fix(tests): propagar Rubro_History + permisos 25 a integration tests (CAT-001) | | `cc3108d` | feat(infrastructure): RubroRepository Dapper + DI + integration tests (CAT-001) | | `ff7c287` | feat(api): RubrosController + integration tests e2e + audit verification (CAT-001) | | `f6733ac` | feat(frontend): rubros feature + CategoryTree + CRUD dialogs (CAT-001) | | `f8d861a` | fix(frontend): corregir tipos zodResolver en RubroFormDialog (CAT-001) | ## Decisiones clave - **Adjacency List**: operaciones de movimiento O(1), queries de árbol completo vía CTE recursiva — adecuado para catálogos < 10k nodos. - **NO FK a `Seccion`**: `Rubro` independiente del módulo de medios; asociación diferida a CAT-002+. - **Soft-delete bloqueante**: rechaza si existen hijos activos — integridad referencial en dominio. - **Unicidad CI vía índice filtrado**: `WHERE ParentId IS NOT NULL AND Activo=1`; raíces enforced por `ExistsByNombreUnderParentAsync`. - **SYSTEM_VERSIONING 10 años**: alineado con ADM-001/ADM-008/ADM-009. - **`ToLookup` en RubroTreeBuilder**: `GroupBy(...).ToDictionary()` lanza `DuplicateKeyException` en runtime con `int?` keys. ## Resumen de tests | Suite | Total | Estado | |-------|-------|--------| | Application.Tests (unit + integration) | 810 | ✅ 0 fallos | | Api.Tests (integration e2e) | 251 | ✅ 0 fallos | | Frontend vitest | 335 | ✅ 0 fallos | | **TOTAL** | **1396** | **✅ 100%** | > Nota: `AuditEventRepositoryTests.QueryAsync_Limit_EmitsCursor_WhenMoreRowsAvailable` es flaky pre-existente (issue #29) — pasa en ejecución secuencial individual. ## Seguimientos abiertos (out of scope) - **issue #29**: Flakiness por `SIGCM2_Test` compartida — resolver antes de CAT-002. - **MoveRubroDialog UI**: `onMove={() => {}}` placeholder en `RubrosPage` — hook implementado y testeado, UI del dialog diferida. - **Format backend**: violaciones whitespace en `TestWebAppFactory.cs` + `AssignPermisosToRolCommandHandlerTests.cs` — pre-existentes, out of scope. ## Artefactos SDD Engram project `sig-cm2`: `sdd/cat-001-arbol-nario-rubros/{explore,proposal,spec,design,tasks,apply-progress,verify-report,pr-body}`
dmolinari added 12 commits 2026-04-19 10:49:34 +00:00
- dbo.Rubro: adjacency list, self-FK, soft-delete, temporal retention 10y
- Filtered unique index UQ_Rubro_ParentId_Nombre_Activo + covering IX_Rubro_ParentId_Activo
- Permission catalogo:rubros:gestionar seeded + assigned to admin role
- V016_ROLLBACK.sql: full reversal script
- RubrosOptions class (MaxDepth=10) + appsettings.json Rubros section
- services.Configure<RubrosOptions> registered in Infrastructure DI
- database/README.md updated with V013-V016 entries
Co-Authored-By: none
- Reemplaza z.union([z.coerce.number(), z.literal('')]) por z.string().transform+pipe para evitar inferencia unknown en zodResolver
- Simplifica RubroFormValues a {nombre: string, tarifarioBaseId?: number | null}
- Actualiza RubrosPage: tarifarioId ya llega como number|null del schema transform
Implementa MoveRubroDialog con flattenExcludingSubtree para prevenir ciclos en UI,
lo conecta en RubrosPage y agrega DialogDescription en RubroFormDialog.
Rebase de CAT-001 sobre main (post #29) requiere:
- EnsureV016SchemaAsync en SqlTestFixture
- Rubro_History en TablesToIgnore central (el commit original b1be4a5 se skipeo por ser obsoleto post consolidacion)
- catalogo:rubros:gestionar en seed canonical de Permiso + RolPermiso admin
- RubroRepositoryTests refactorizado al patron [Collection] + SqlTestFixture
- RubrosControllerTests apunta a TestConnectionStrings.ApiTestDb
- Counts de permisos admin actualizados 24 -> 25 en 5 tests

Verify: App 819/819 + Api 251/251 + vitest 349/349 verde post-rebase.
dmolinari force-pushed feature/CAT-001 from f5ed9c4b3c to 389dda6e5e 2026-04-19 10:49:34 +00:00 Compare
dmolinari merged commit 205f9c76ad into main 2026-04-19 10:49:37 +00:00
dmolinari deleted branch feature/CAT-001 2026-04-19 10:49:37 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dmolinari/SIG-CM2.0#30