Saltar a contenido

Especificación Técnica: cfg_menu

Esquema: core.identidad
Base de Datos: core
Servicio: svc-identidad
Propósito: Menú jerárquico por empresa y módulo: hasta tres niveles de navegación.


1. Justificación y Mejoras de Arquitectura

  • Menú con jerarquía (padre_id) y niveles 1..3 validados por CHECK.
  • Unique (modulo_id, codigo) evita códigos repetidos dentro de un módulo.
  • Ruta frontend e ícono para renderizado en el cliente web.
  • RLS por empresa actual aísla la configuración por empresa.

2. Definiciones de Implementación

Table core.identidad.cfg_menu {
    id                         uuid          [pk, default: `gen_random_uuid()`]
    empresa_id                 bigint        [not null]
    modulo_id                  uuid          [not null, ref: > cfg_modulos.id]
    padre_id                   uuid          [ref: > cfg_menu.id]
    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`]

    // Auditoría General
    is_activo               boolean       [not null, default: `true`, note: 'Estado lógico del registro']
    created_at              timestamptz   [not null, default: `now()`, note: 'Fecha de creación']
    updated_at              timestamptz   [not null, default: `now()`, note: 'Fecha de última actualización']
    deleted_at              timestamptz   [note: 'Fecha de eliminación lógica']
    created_by              uuid          [note: 'Usuario creador']
    updated_by              uuid          [note: 'Usuario modificador']

    Indexes {
        (modulo_id, codigo) [unique, name: 'uq_cfg_menu_modulo_codigo']
        empresa_id [name: 'ix_cfg_menu_empresa_id']
        modulo_id [name: 'ix_cfg_menu_modulo_id']
        padre_id [name: 'ix_cfg_menu_padre_id']
        nivel [name: 'ix_cfg_menu_nivel']
    }

    // RLS por empresa actual
}
CREATE TABLE IF NOT EXISTS core.identidad.cfg_menu (
    id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
    empresa_id BIGINT NOT NULL,
    modulo_id UUID NOT NULL,
    padre_id UUID,
    nivel SMALLINT NOT NULL,
    codigo VARCHAR(50) NOT NULL,
    nombre VARCHAR(150) NOT NULL,
    ruta_frontend VARCHAR(200),
    icono VARCHAR(50),
    orden INTEGER NOT NULL DEFAULT 0,
    is_visible BOOLEAN DEFAULT true,

    -- Auditoría General
    is_activo BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ,
    created_by UUID,
    updated_by UUID,
    CONSTRAINT uq_cfg_menu_modulo_codigo UNIQUE (modulo_id, codigo),
    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))
);
CREATE INDEX IF NOT EXISTS ix_cfg_menu_empresa_id ON core.identidad.cfg_menu (empresa_id);
CREATE INDEX IF NOT EXISTS ix_cfg_menu_modulo_id ON core.identidad.cfg_menu (modulo_id);
CREATE INDEX IF NOT EXISTS ix_cfg_menu_padre_id ON core.identidad.cfg_menu (padre_id);
CREATE INDEX IF NOT EXISTS ix_cfg_menu_nivel ON core.identidad.cfg_menu (nivel);

-- Row-Level Security
ALTER TABLE core.identidad.cfg_menu ENABLE ROW LEVEL SECURITY;
CREATE POLICY aislamiento_empresa ON core.identidad.cfg_menu
    FOR ALL
    USING (empresa_id = NULLIF(current_setting('app.current_empresa_id', true), '')::BIGINT);

-- Comentarios
COMMENT ON COLUMN core.identidad.cfg_menu.empresa_id IS 'Empresa configurada';
COMMENT ON COLUMN core.identidad.cfg_menu.modulo_id IS 'Módulo propietario del menú';
COMMENT ON COLUMN core.identidad.cfg_menu.padre_id IS 'Menú padre (autoreferencia)';
COMMENT ON COLUMN core.identidad.cfg_menu.nivel IS 'Nivel jerárquico (1..3)';
COMMENT ON COLUMN core.identidad.cfg_menu.codigo IS 'Código del menú';
COMMENT ON COLUMN core.identidad.cfg_menu.nombre IS 'Nombre del menú';
COMMENT ON COLUMN core.identidad.cfg_menu.ruta_frontend IS 'Ruta de la aplicación frontend';
COMMENT ON COLUMN core.identidad.cfg_menu.icono IS 'Clase o ruta del ícono';
COMMENT ON COLUMN core.identidad.cfg_menu.orden IS 'Orden de presentación';
COMMENT ON COLUMN core.identidad.cfg_menu.is_visible IS 'Visible en la navegación';
from datetime import datetime, date
from typing import Optional
import uuid

from sqlalchemy import (
    BigInteger,
    Boolean,
    CheckConstraint,
    Date,
    DateTime,
    ForeignKey,
    Identity,
    Index,
    Integer,
    SmallInteger,
    String,
    Text,
    UniqueConstraint,
    text,
)
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass

class CfgMenu(Base):
    __tablename__ = 'cfg_menu'
    __table_args__ = (
        UniqueConstraint('modulo_id', 'codigo', name='uq_cfg_menu_modulo_codigo'),
        CheckConstraint('nivel IN (1, 2, 3)', name='ck_cfg_menu_nivel'),
        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(
        UUID(as_uuid=True),
        primary_key=True,
        server_default=text("gen_random_uuid()"),
    )
    empresa_id: Mapped[int] = mapped_column(
        BigInteger,
        nullable=False,
        comment='Empresa configurada',
    )
    modulo_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        nullable=False,
        ForeignKey("cfg_modulos.id"),
        comment='Módulo propietario del menú',
    )
    padre_id: Mapped[uuid.UUID | None] = mapped_column(
        UUID(as_uuid=True),
        ForeignKey("cfg_menu.id"),
        comment='Menú padre (autoreferencia)',
    )
    nivel: Mapped[int] = mapped_column(
        SmallInteger,
        nullable=False,
        comment='Nivel jerárquico (1..3)',
    )
    codigo: Mapped[str] = mapped_column(
        String(50),
        nullable=False,
        comment='Código del menú',
    )
    nombre: Mapped[str] = mapped_column(
        String(150),
        nullable=False,
        comment='Nombre del menú',
    )
    ruta_frontend: Mapped[str | None] = mapped_column(
        String(200),
        comment='Ruta de la aplicación frontend',
    )
    icono: Mapped[str | None] = mapped_column(
        String(50),
        comment='Clase o ruta del ícono',
    )
    orden: Mapped[int] = mapped_column(
        Integer,
        nullable=False,
        server_default=text("0"),
        comment='Orden de presentación',
    )
    is_visible: Mapped[bool | None] = mapped_column(
        Boolean,
        server_default=text("true"),
        comment='Visible en la navegación',
    )

    # Auditoría General
    is_activo: Mapped[bool] = mapped_column(
        Boolean,
        nullable=False,
        server_default=text("true"),
        comment='Estado lógico del registro',
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        nullable=False,
        server_default=text("now()"),
        comment='Fecha de creación',
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        nullable=False,
        server_default=text("now()"),
        comment='Fecha de última actualización',
    )
    deleted_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True),
        comment='Fecha de eliminación lógica',
    )
    created_by: Mapped[uuid.UUID | None] = mapped_column(
        UUID(as_uuid=True),
        comment='Usuario creador',
    )
    updated_by: Mapped[uuid.UUID | None] = mapped_column(
        UUID(as_uuid=True),
        comment='Usuario modificador',
    )
"""revision: core_id_0014
create table core.identidad.cfg_menu
"""

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql


revision: str = 'core_id_0014'
down_revision: str | None = 'core_id_0013'
branch_labels: str | None = None
depends_on: str | None = None


def upgrade() -> None:
    op.create_table(
        'cfg_menu',
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
        sa.Column("empresa_id", sa.BigInteger(), nullable=False),
        sa.Column("modulo_id", postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column("padre_id", postgresql.UUID(as_uuid=True)),
        sa.Column("nivel", sa.SmallInteger(), nullable=False),
        sa.Column("codigo", sa.String(length=50), nullable=False),
        sa.Column("nombre", sa.String(length=150), nullable=False),
        sa.Column("ruta_frontend", sa.String(length=200)),
        sa.Column("icono", sa.String(length=50)),
        sa.Column("orden", sa.Integer(), nullable=False, server_default=sa.text("0")),
        sa.Column("is_visible", sa.Boolean(), server_default=sa.text("true")),
        sa.Column("is_activo", sa.Boolean(), nullable=False, server_default=sa.text("true")),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
        sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
        sa.Column("deleted_at", sa.DateTime(timezone=True)),
        sa.Column("created_by", postgresql.UUID(as_uuid=True)),
        sa.Column("updated_by", postgresql.UUID(as_uuid=True)),
        sa.UniqueConstraint('modulo_id', 'codigo', name='uq_cfg_menu_modulo_codigo'),
        sa.CheckConstraint('nivel IN (1, 2, 3)', name='ck_cfg_menu_nivel'),
        sa.ForeignKeyConstraint(['modulo_id'], ['core.identidad.cfg_modulos.id'], name='fk_cfg_menu_modulo'),
        sa.ForeignKeyConstraint(['padre_id'], ['core.identidad.cfg_menu.id'], name='fk_cfg_menu_padre'),
        schema='core.identidad',
    )
    op.create_index('ix_cfg_menu_empresa_id', 'cfg_menu', ['empresa_id'], unique=False, schema='core.identidad')
    op.create_index('ix_cfg_menu_modulo_id', 'cfg_menu', ['modulo_id'], unique=False, schema='core.identidad')
    op.create_index('ix_cfg_menu_padre_id', 'cfg_menu', ['padre_id'], unique=False, schema='core.identidad')
    op.create_index('ix_cfg_menu_nivel', 'cfg_menu', ['nivel'], unique=False, schema='core.identidad')

    op.execute(
        """
        ALTER TABLE core.identidad.cfg_menu ENABLE ROW LEVEL SECURITY;
        CREATE POLICY aislamiento_empresa
            ON core.identidad.cfg_menu
            FOR ALL
            USING (empresa_id = NULLIF(current_setting('app.current_empresa_id', true), '')::BIGINT);
        """
    )


def downgrade() -> None:
    op.drop_table('cfg_menu', schema='core.identidad')