Saltar a contenido

0013. Modelo de datos: navegación (aplicaciones, módulos, menú)

Estado: Propuesto Fecha: 2026-09-04 Autor: Duval Alcivar Módulo(s) afectado(s): todos (core, ERP y CRM) — transversal

Contexto

La navegación de la plataforma se organiza en tres niveles: aplicación (ERP, CRM) → módulo (rrhh, inventario...) → página/menú (nómina → registro, vacaciones). Este ADR define el modelo de datos de esa navegación, aplicando la nomenclatura del ADR 0004 global. La bitácora de auditoría (audit_log) está en el ADR 0014.

Decisión

Tres niveles, cada uno en su tabla (todos en core_sigfa, dueño svc-identidad):

  • cat_aplicaciones — las aplicaciones/productos (ERP, CRM, futuros). Catálogo global (no depende de la empresa). Qué aplicación usa cada empresa lo decide rel_empresa_aplicacion (ADR 0004/0006 de identidad).
  • cfg_modulos — los módulos de cada aplicación (vista tipo Odoo). Por empresa (empresa_id + RLS).
  • cfg_menu — las páginas de un módulo, auto-referenciada con padre_id (submenús, máximo 3 niveles). Por empresa.
  • cfg_menu_acciones — acciones de cada página.
cat_aplicaciones (ERP, CRM)                    ← global (sin empresa_id)
   │  rel_empresa_aplicacion (empresa ↔ app)   ← ADR 0004/0006 de identidad
   │
cfg_modulos (rrhh, inventario, contabilidad)   ← empresa_id + aplicacion_id (RLS)
   │
cfg_menu (nómina → registro, vacaciones)       ← modulo_id + padre_id (self, 3 niveles)
   │
cfg_menu_acciones (crear / editar / ver / eliminar)

Ubicación de las tablas

Tabla BD Esquema Dueño
cat_aplicaciones core_sigfa core.identidad svc-identidad
cfg_modulos core_sigfa core.identidad svc-identidad
cfg_menu core_sigfa core.identidad svc-identidad
cfg_menu_acciones core_sigfa core.identidad svc-identidad

Por qué en core_sigfa: la navegación es configuración de acceso servida por svc-identidad; un solo lugar permite resolver el login ("qué aplicaciones/módulos ve esta empresa/usuario") sin consultar varias bases (regla de cero consultas entre bases, ADR 0003).

Convenciones aplicadas (ADR 0004 global)

Convención Valor
PK id UUID (gen_random_uuid())
empresa_id BIGINT — refiere a mae_empresas (ADR 0011)
Auditoría estándar created_at, updated_at, deleted_at, created_by, updated_by
Booleanos is_<...>is_activo, is_visible
RLS empresa_id en toda tabla con datos de empresa (ADR 0002/0012)
Prefijo cat_ (catálogo), cfg_ (configuración)

1. cat_aplicaciones — catálogo global de aplicaciones/productos

Catálogo global (ERP, CRM, futuros). Pocos registros (~2-5), cambia raramente. No tiene empresa_id ni RLS (todas las empresas ven las mismas aplicaciones; qué aplicación usa cada empresa lo define rel_empresa_aplicacion, ADR 0004/0006 de identidad).

Campo Tipo Restricciones Notas
id UUID PK DEFAULT gen_random_uuid()
codigo VARCHAR(30) UNIQUE, NOT NULL ej. erp, crm
nombre VARCHAR(100) NOT NULL ej. ERP Sigfa, CRM SigfaPro
dominio VARCHAR(100) ej. sigfa.com.ec, sigfapro.com.ec
descripcion TEXT
icono VARCHAR(50) icono para la ventana de login
is_activo BOOLEAN DEFAULT TRUE
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
deleted_at TIMESTAMPTZ NULL soft delete
created_by UUID
updated_by UUID
CREATE TABLE core.identidad.cat_aplicaciones (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    codigo        VARCHAR(30) NOT NULL,
    nombre        VARCHAR(100) NOT NULL,
    dominio       VARCHAR(100),
    descripcion   TEXT,
    icono         VARCHAR(50),
    is_activo     BOOLEAN DEFAULT TRUE,
    created_at    TIMESTAMPTZ DEFAULT now(),
    updated_at    TIMESTAMPTZ DEFAULT now(),
    deleted_at    TIMESTAMPTZ NULL,
    created_by    UUID,
    updated_by    UUID,

    CONSTRAINT uq_cat_aplicaciones_codigo UNIQUE (codigo)
);
class CatAplicacion(Base):
    __tablename__ = "cat_aplicaciones"
    __table_args__ = {"schema": "core.identidad"}

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    codigo: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
    nombre: Mapped[str] = mapped_column(String(100), nullable=False)
    dominio: Mapped[str | None] = mapped_column(String(100))
    descripcion: Mapped[str | None] = mapped_column(Text)
    icono: Mapped[str | None] = mapped_column(String(50))
    is_activo: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
    deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    created_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
    updated_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)

2. cfg_modulos — módulos de cada aplicación

Los módulos que se muestran dentro de una aplicación (vista tipo Odoo). Por empresa: empresa_id + RLS.

Campo Tipo Restricciones Notas
id UUID PK DEFAULT gen_random_uuid()
empresa_id BIGINT NOT NULL RLS (ADR 0002)
aplicacion_id UUID NOT NULL, FK → cat_aplicaciones aplicación dueña del módulo
codigo VARCHAR(50) NOT NULL ej. rrhh, inventario, contabilidad
nombre VARCHAR(150) NOT NULL nombre legible
descripcion TEXT
icono VARCHAR(50) icono para la grilla de módulos
orden INT NOT NULL, DEFAULT 0 orden en la vista
is_visible BOOLEAN DEFAULT TRUE se muestra en la grilla
is_activo BOOLEAN DEFAULT TRUE habilitado
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
deleted_at TIMESTAMPTZ NULL soft delete
created_by UUID
updated_by UUID
CREATE TABLE core.identidad.cfg_modulos (
    id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    empresa_id     BIGINT NOT NULL,
    aplicacion_id  UUID NOT NULL,
    codigo         VARCHAR(50) NOT NULL,
    nombre         VARCHAR(150) NOT NULL,
    descripcion    TEXT,
    icono          VARCHAR(50),
    orden          INT NOT NULL DEFAULT 0,
    is_visible     BOOLEAN DEFAULT TRUE,
    is_activo      BOOLEAN DEFAULT TRUE,
    created_at     TIMESTAMPTZ DEFAULT now(),
    updated_at     TIMESTAMPTZ DEFAULT now(),
    deleted_at     TIMESTAMPTZ NULL,
    created_by     UUID,
    updated_by     UUID,

    CONSTRAINT fk_cfg_modulos_aplicacion
        FOREIGN KEY (aplicacion_id) REFERENCES core.identidad.cat_aplicaciones(id),
    CONSTRAINT uq_cfg_modulos_empresa_aplicacion_codigo
        UNIQUE (empresa_id, aplicacion_id, codigo)
);

CREATE INDEX ix_cfg_modulos_empresa_id ON core.identidad.cfg_modulos (empresa_id);
CREATE INDEX ix_cfg_modulos_aplicacion_id ON core.identidad.cfg_modulos (aplicacion_id);

-- RLS
ALTER TABLE core.identidad.cfg_modulos ENABLE ROW LEVEL SECURITY;
CREATE POLICY aislamiento_empresa ON core.identidad.cfg_modulos
    USING (empresa_id = NULLIF(current_setting('app.current_empresa_id', true), '')::BIGINT);
class CfgModulo(Base):
    __tablename__ = "cfg_modulos"
    __table_args__ = (
        UniqueConstraint("empresa_id", "aplicacion_id", "codigo", name="uq_cfg_modulos_empresa_aplicacion_codigo"),
        Index("ix_cfg_modulos_empresa_id", "empresa_id"),
        Index("ix_cfg_modulos_aplicacion_id", "aplicacion_id"),
        {"schema": "core.identidad"},
    )

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    empresa_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
    aplicacion_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("core.identidad.cat_aplicaciones.id"), nullable=False)
    codigo: Mapped[str] = mapped_column(String(50), nullable=False)
    nombre: Mapped[str] = mapped_column(String(150), nullable=False)
    descripcion: Mapped[str | None] = mapped_column(Text)
    icono: Mapped[str | None] = mapped_column(String(50))
    orden: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    is_visible: Mapped[bool] = mapped_column(Boolean, default=True)
    is_activo: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
    deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    created_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
    updated_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)

3. cfg_menu — páginas de un módulo (auto-referenciada, máx. 3 niveles)

Las páginas de un módulo, con submenús auto-referenciados por padre_id hasta un máximo de 3 niveles. Por empresa: empresa_id + RLS.

MÓDULO: rrhh (cfg_modulos)
├── nomina                 (nivel 1)
│   ├── registro           (nivel 2)
│   └── vacaciones         (nivel 2)
├── asistencia             (nivel 1)
│   ├── marcaciones        (nivel 2)
│   └── reportes           (nivel 2)
│       └── consolidado    (nivel 3)
Campo Tipo Restricciones Notas
id UUID PK DEFAULT gen_random_uuid()
empresa_id BIGINT NOT NULL RLS
modulo_id UUID NOT NULL, FK → cfg_modulos módulo al que pertenece la página
padre_id UUID NULL, FK → cfg_menu NULL = nivel 1; 2 → padre nivel 1; 3 → padre nivel 2
nivel SMALLINT NOT NULL 1, 2 o 3 — CHECK ≤ 3
codigo VARCHAR(50) NOT NULL ej. nomina, registro, vacaciones
nombre VARCHAR(150) NOT NULL nombre legible
ruta_frontend VARCHAR(200) path SPA React (ej. /rrhh/nomina/registro), solo navegación
icono VARCHAR(50) icono para el sidebar
orden INT NOT NULL, DEFAULT 0 orden dentro del padre
is_visible BOOLEAN DEFAULT TRUE se muestra en el sidebar
is_activo BOOLEAN DEFAULT TRUE habilitado
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
deleted_at TIMESTAMPTZ NULL soft delete
created_by UUID
updated_by UUID
CREATE TABLE core.identidad.cfg_menu (
    id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    empresa_id     BIGINT NOT NULL,
    modulo_id      UUID NOT NULL,
    padre_id       UUID NULL,
    nivel          SMALLINT NOT NULL,
    codigo         VARCHAR(50) NOT NULL,
    nombre         VARCHAR(150) NOT NULL,
    ruta_frontend  VARCHAR(200),
    icono          VARCHAR(50),
    orden          INT NOT NULL DEFAULT 0,
    is_visible     BOOLEAN DEFAULT TRUE,
    is_activo      BOOLEAN DEFAULT TRUE,
    created_at     TIMESTAMPTZ DEFAULT now(),
    updated_at     TIMESTAMPTZ DEFAULT now(),
    deleted_at     TIMESTAMPTZ NULL,
    created_by     UUID,
    updated_by     UUID,

    CONSTRAINT fk_cfg_menu_modulo
        FOREIGN KEY (modulo_id) REFERENCES core.identidad.cfg_modulos(id),
    CONSTRAINT fk_cfg_menu_padre
        FOREIGN KEY (padre_id) REFERENCES core.identidad.cfg_menu(id),
    CONSTRAINT ck_cfg_menu_nivel CHECK (nivel IN (1, 2, 3)),
    CONSTRAINT uq_cfg_menu_modulo_codigo UNIQUE (modulo_id, codigo)
);

CREATE INDEX ix_cfg_menu_empresa_id ON core.identidad.cfg_menu (empresa_id);
CREATE INDEX ix_cfg_menu_modulo_id ON core.identidad.cfg_menu (modulo_id);
CREATE INDEX ix_cfg_menu_padre_id ON core.identidad.cfg_menu (padre_id);
CREATE INDEX ix_cfg_menu_nivel ON core.identidad.cfg_menu (nivel);

-- RLS
ALTER TABLE core.identidad.cfg_menu ENABLE ROW LEVEL SECURITY;
CREATE POLICY aislamiento_empresa ON core.identidad.cfg_menu
    USING (empresa_id = NULLIF(current_setting('app.current_empresa_id', true), '')::BIGINT);
class CfgMenu(Base):
    __tablename__ = "cfg_menu"
    __table_args__ = (
        CheckConstraint("nivel IN (1, 2, 3)", name="ck_cfg_menu_nivel"),
        UniqueConstraint("modulo_id", "codigo", name="uq_cfg_menu_modulo_codigo"),
        Index("ix_cfg_menu_empresa_id", "empresa_id"),
        Index("ix_cfg_menu_modulo_id", "modulo_id"),
        Index("ix_cfg_menu_padre_id", "padre_id"),
        Index("ix_cfg_menu_nivel", "nivel"),
        {"schema": "core.identidad"},
    )

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    empresa_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
    modulo_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("core.identidad.cfg_modulos.id"), nullable=False)
    padre_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("core.identidad.cfg_menu.id"))
    nivel: Mapped[int] = mapped_column(SmallInteger, nullable=False)
    codigo: Mapped[str] = mapped_column(String(50), nullable=False)
    nombre: Mapped[str] = mapped_column(String(150), nullable=False)
    ruta_frontend: Mapped[str | None] = mapped_column(String(200))
    icono: Mapped[str | None] = mapped_column(String(50))
    orden: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    is_visible: Mapped[bool] = mapped_column(Boolean, default=True)
    is_activo: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
    deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    created_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
    updated_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)

modulo_id se guarda en cada fila (también en niveles 2 y 3) para poder recuperar "todas las páginas de un módulo" con un índice simple, sin recorrer padre_id.


4. cfg_menu_acciones — acciones por página

Campo Tipo Restricciones Notas
id UUID PK DEFAULT gen_random_uuid()
empresa_id BIGINT NOT NULL RLS
menu_id UUID NOT NULL, FK → cfg_menu página sobre la que aplica
codigo VARCHAR(50) NOT NULL ej. crear, editar, eliminar, ver, subir_archivo
nombre VARCHAR(100) NOT NULL nombre legible de la acción
metodo_http VARCHAR(10) POST/PUT/PATCH/DELETE/GET (referencia)
is_activo BOOLEAN DEFAULT TRUE
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
deleted_at TIMESTAMPTZ NULL soft delete
created_by UUID
updated_by UUID
CREATE TABLE core.identidad.cfg_menu_acciones (
    id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    empresa_id     BIGINT NOT NULL,
    menu_id        UUID NOT NULL,
    codigo         VARCHAR(50) NOT NULL,
    nombre         VARCHAR(100) NOT NULL,
    metodo_http    VARCHAR(10),
    is_activo      BOOLEAN DEFAULT TRUE,
    created_at     TIMESTAMPTZ DEFAULT now(),
    updated_at     TIMESTAMPTZ DEFAULT now(),
    deleted_at     TIMESTAMPTZ NULL,
    created_by     UUID,
    updated_by     UUID,

    CONSTRAINT fk_cfg_menu_acciones_menu
        FOREIGN KEY (menu_id) REFERENCES core.identidad.cfg_menu(id),
    CONSTRAINT uq_cfg_menu_acciones_menu_codigo UNIQUE (menu_id, codigo)
);

CREATE INDEX ix_cfg_menu_acciones_empresa_id ON core.identidad.cfg_menu_acciones (empresa_id);
CREATE INDEX ix_cfg_menu_acciones_menu_id ON core.identidad.cfg_menu_acciones (menu_id);

-- RLS
ALTER TABLE core.identidad.cfg_menu_acciones ENABLE ROW LEVEL SECURITY;
CREATE POLICY aislamiento_empresa ON core.identidad.cfg_menu_acciones
    USING (empresa_id = NULLIF(current_setting('app.current_empresa_id', true), '')::BIGINT);
class CfgMenuAccion(Base):
    __tablename__ = "cfg_menu_acciones"
    __table_args__ = (
        UniqueConstraint("menu_id", "codigo", name="uq_cfg_menu_acciones_menu_codigo"),
        Index("ix_cfg_menu_acciones_empresa_id", "empresa_id"),
        Index("ix_cfg_menu_acciones_menu_id", "menu_id"),
        {"schema": "core.identidad"},
    )

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    empresa_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
    menu_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("core.identidad.cfg_menu.id"), nullable=False)
    codigo: Mapped[str] = mapped_column(String(50), nullable=False)
    nombre: Mapped[str] = mapped_column(String(100), nullable=False)
    metodo_http: Mapped[str | None] = mapped_column(String(10))
    is_activo: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
    deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    created_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
    updated_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)

Relación entre tablas

core_sigfa (core.identidad)
├── cat_aplicaciones          ← catálogo global de productos (UUID)
│     │  rel_empresa_aplicacion (empresa ↔ app) — ADR 0004/0006 de identidad
├── cfg_modulos               ← módulos (aplicacion_id → cat_aplicaciones, empresa_id RLS)
│     │
├── cfg_menu                  ← páginas (modulo_id → cfg_modulos, padre_id → self, 3 niveles)
│     │
└── cfg_menu_acciones         ← acciones (menu_id → cfg_menu)

Flujo de datos

1. Login → usuario elige la EMPRESA (y se valida usuario + empresa)
2. svc-identidad resuelve, en UNA sola consulta, las aplicaciones de esa
   empresa (rel_empresa_aplicacion, ADR 0005) Y sus módulos (cfg_modulos)
3. Frontend muestra UNA sola pantalla con todas las aplicaciones y sus
   módulos agrupados:

     CORE
       permisos, tickets, ...
     ERP
       produccion, rrhh, ...
     CRM
       promotores, ventas, ...

4. Al entrar a un módulo → GET /api/v1/navegacion/menu?modulo_id=... → árbol de páginas
   (3 niveles con submenús)

Las aplicaciones y sus módulos se cargan juntos, en una sola pantalla posterior al login (no hay un paso "elegir aplicación" previo a ver los módulos). El módulo es el punto de entrada a la navegación; la página se elige dentro del módulo vía cfg_menu.

Resumen de tablas

Tabla Prefijo BD PK empresa_id RLS Descripción
cat_aplicaciones cat_ core_sigfa UUID no Catálogo global de aplicaciones
cfg_modulos cfg_ core_sigfa UUID BIGINT Módulos por aplicación
cfg_menu cfg_ core_sigfa UUID BIGINT Páginas por módulo (3 niveles)
cfg_menu_acciones cfg_ core_sigfa UUID BIGINT Acciones por página

Alternativas descartadas

  • Una sola tabla auto-referenciada (módulo como nivel 1) — descartado: módulo y página son entidades distintas (el módulo agrupa, la página navega y se audita); separarlos permite la vista "grilla de módulos" sin mezclar niveles.
  • cat_aplicaciones con empresa_id directo — descartado: duplica la fila "ERP"/"CRM" una vez por empresa; rel_empresa_aplicacion (ADR 0005) expresa lo mismo sin redundancia.
  • empresa_id como UUID — descartado (corregido): inconsistente con ADR 0002/0004/0011 (BIGINT).
  • Módulos/menú en la BD de cada producto — descartado: fragmenta la navegación y obliga a consultar varias bases para armar el login; en core_sigfa el flujo completo lo resuelve svc-identidad.

Consecuencias

  • Ganancia: navegación en 3 niveles limpios (aplicación → módulo → página), con FK reales en core_sigfa.
  • Ganancia: login y navegación se resuelven en un solo servicio (svc-identidad) sin cruzar bases.
  • Resuelto: el catálogo inicial de módulos/acciones por aplicación se define como plantilla (core/erp/crm) en el ADR 0013 de svc-identidad y se clona a cada empresa al alta en el ADR 0016. El permiso por acción derivado está en el ADR 0014 y la asignación fina a usuario en el ADR 0015.
  • Resuelto: la relación usuario → páginas del menú (rel_usuario_menu) está en el ADR 0007 de svc-identidad; el filtro del panel/sidebar por permisos en el ADR 0012; el modelo de apps/módulos/menú consolidado en el ADR 0006; el flujo de pantallas de acceso (login → selector de empresa → panel) en el ADR 0010.

Complementa los ADRs 0008 (auditoría), 0009 (captura backend) y 0010 (frontend) definiendo el modelo de navegación. La bitácora de auditoría (audit_log) está en el ADR 0014.