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 deciderel_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 conpadre_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 porsvc-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_idse 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 recorrerpadre_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 | sí | Módulos por aplicación |
cfg_menu |
cfg_ | core_sigfa | UUID | BIGINT | sí | Páginas por módulo (3 niveles) |
cfg_menu_acciones |
cfg_ | core_sigfa | UUID | BIGINT | sí | 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_aplicacionesconempresa_iddirecto — descartado: duplica la fila "ERP"/"CRM" una vez por empresa;rel_empresa_aplicacion(ADR 0005) expresa lo mismo sin redundancia.empresa_idcomo 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_sigfael flujo completo lo resuelvesvc-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.