Saltar a contenido

Especificación Técnica: log_cambios_cliente

Esquema: clientes
Base de Datos: crm
Servicio: svc-clientes
Tabla Legacy Origen: c297t
Propósito: bitácora de cambios de campos sensibles (ADR 0008)


1. Justificación y Mejoras de Arquitectura

  • Normalización Relacional Rigurosa: Aplica diseño normalizado (1FN a BCNF), eliminando grupos repetidos de booleanos, desnormalizaciones innecesarias y redundancias del sistema legacy.
  • Aislamiento Multi-Tenant (RLS): Integra empresa_id BIGINT NOT NULL indexado, gobernado por políticas PostgreSQL Row-Level Security (USING y WITH CHECK) para garantizar la total separación de datos por tenant.
  • Integridad Referencial Compuesta: Todas las claves foráneas internas hacia tablas con tenencia son compuestas (empresa_id, <fk_id>), asegurando la integridad física en PostgreSQL.
  • Trazabilidad y Auditoría: Auditoría estándar (created_at, updated_at, deleted_at, created_by, updated_by) con auditoría integral.

2. Definiciones de Implementación

Table clientes.log_cambios_cliente {
    id                      uuid          [pk, default: `gen_random_uuid()`, note: 'Identificador único UUID']
    empresa_id                  bigint        [not null, note: 'Identificador BIGINT de la empresa en core.identidad (RLS)']
    cliente_id              uuid          [not null, note: 'Cliente titular']
    promotor_id             uuid          [, note: 'Promotor o asesor comercial']
    usuario_id              uuid          [, note: 'Correlación lógica con core.identidad.mae_usuarios (sin FK cross-DB)']
    campo                   varchar(60)   [not null, note: 'Campo campo']
    valor_anterior          text          [, note: 'Campo valor_anterior']
    valor_nuevo             text          [, note: 'Campo valor_nuevo']

    // 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 {
        (empresa_id, id) [unique, name: 'uq_log_cambios_cliente_empresa_id']
        (empresa_id, cliente_id) [name: 'ix_log_cambios_cliente_empresa_cliente']
        (empresa_id, promotor_id) [name: 'ix_log_cambios_cliente_empresa_promotor']
        (empresa_id, usuario_id) [name: 'ix_log_cambios_cliente_empresa_usuario']
        (empresa_id, is_activo) [name: 'ix_log_cambios_cliente_empresa_activo']
    }
}

Ref: clientes.log_cambios_cliente.(empresa_id, cliente_id) > clientes.mae_clientes.(empresa_id, id) [delete: restrict]
Ref: clientes.log_cambios_cliente.(empresa_id, promotor_id) > fuerza_ventas.mae_promotores.(empresa_id, id) [delete: restrict]
CREATE TABLE IF NOT EXISTS clientes.log_cambios_cliente (
    id UUID NOT NULL DEFAULT gen_random_uuid(),
    empresa_id BIGINT NOT NULL,
    cliente_id UUID NOT NULL,
    promotor_id UUID,
    usuario_id UUID,
    campo VARCHAR(60) NOT NULL,
    valor_anterior TEXT,
    valor_nuevo TEXT,

    -- 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 pk_log_cambios_cliente PRIMARY KEY (id),
    CONSTRAINT uq_log_cambios_cliente_empresa_id UNIQUE (empresa_id, id),
    CONSTRAINT fk_log_cambios_cliente_cliente_id FOREIGN KEY (empresa_id, cliente_id) REFERENCES clientes.mae_clientes (empresa_id, id) ON DELETE RESTRICT,
    CONSTRAINT fk_log_cambios_cliente_promotor_id FOREIGN KEY (empresa_id, promotor_id) REFERENCES fuerza_ventas.mae_promotores (empresa_id, id) ON DELETE RESTRICT
);

CREATE INDEX IF NOT EXISTS ix_log_cambios_cliente_empresa_cliente ON clientes.log_cambios_cliente (empresa_id, cliente_id);
CREATE INDEX IF NOT EXISTS ix_log_cambios_cliente_empresa_promotor ON clientes.log_cambios_cliente (empresa_id, promotor_id);
CREATE INDEX IF NOT EXISTS ix_log_cambios_cliente_empresa_usuario ON clientes.log_cambios_cliente (empresa_id, usuario_id);
CREATE INDEX IF NOT EXISTS ix_log_cambios_cliente_empresa_activo ON clientes.log_cambios_cliente (empresa_id, is_activo);

ALTER TABLE clientes.log_cambios_cliente ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_log_cambios_cliente_tenant_isolation ON clientes.log_cambios_cliente
    FOR ALL
    USING (empresa_id = current_setting('app.current_empresa_id', true)::BIGINT)
    WITH CHECK (empresa_id = current_setting('app.current_empresa_id', true)::BIGINT);
from datetime import datetime
import uuid
from typing import Optional
from sqlalchemy import String, Boolean, DateTime, BigInteger, ForeignKey, ForeignKeyConstraint, UniqueConstraint, Index, Numeric, Integer, Text
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase

class Base(DeclarativeBase):
    pass

class CambiosCliente(Base):
    __tablename__ = 'log_cambios_cliente'
    __table_args__ = (
        UniqueConstraint('empresa_id', 'id', name='uq_log_cambios_cliente_empresa_id'),
        Index('ix_log_cambios_cliente_empresa_cliente', 'empresa_id', 'cliente_id'),
        ForeignKeyConstraint(['empresa_id', 'cliente_id'], ['clientes.mae_clientes.empresa_id', 'clientes.mae_clientes.id'], name='fk_log_cambios_cliente_cliente_id'),
        Index('ix_log_cambios_cliente_empresa_promotor', 'empresa_id', 'promotor_id'),
        ForeignKeyConstraint(['empresa_id', 'promotor_id'], ['fuerza_ventas.mae_promotores.empresa_id', 'fuerza_ventas.mae_promotores.id'], name='fk_log_cambios_cliente_promotor_id'),
        Index('ix_log_cambios_cliente_empresa_usuario', 'empresa_id', 'usuario_id'),
        Index('ix_log_cambios_cliente_empresa_activo', 'empresa_id', 'is_activo'),
        {'schema': 'clientes'}
    )

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    empresa_id: Mapped[int] = mapped_column(
        BigInteger, nullable=False)
    cliente_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
    promotor_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True)
    usuario_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True)
    campo: Mapped[str] = mapped_column(String(200), nullable=False)
    valor_anterior: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    valor_nuevo: Mapped[Optional[str]] = mapped_column(Text, nullable=True)

    # Auditoría
    is_activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False)
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
    deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
    created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True)
    updated_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True)
"""revision: crm_cli_0023
create table clientes.log_cambios_cliente
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

revision = 'crm_cli_0023'
down_revision = 'crm_cli_0022'
branch_labels = None
depends_on = None

def upgrade() -> None:
    op.create_table(
        'log_cambios_cliente',
        sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
        sa.Column(
            "empresa_id",
            sa.BigInteger(), nullable=False),
        sa.Column('cliente_id', postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column('promotor_id', postgresql.UUID(as_uuid=True), nullable=True),
        sa.Column('usuario_id', postgresql.UUID(as_uuid=True), nullable=True),
        sa.Column('campo', sa.String(length=200), nullable=False),
        sa.Column('valor_anterior', sa.Text(), nullable=True),
        sa.Column('valor_nuevo', sa.Text(), nullable=True),
        sa.Column('is_activo', sa.Boolean(), server_default=sa.text('true'), nullable=False),
        sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
        sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
        sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
        sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=True),
        sa.Column('updated_by', postgresql.UUID(as_uuid=True), nullable=True),
        sa.PrimaryKeyConstraint('id', name='pk_log_cambios_cliente'),
        sa.UniqueConstraint('empresa_id', 'id', name='uq_log_cambios_cliente_empresa_id'),
        sa.ForeignKeyConstraint(['empresa_id', 'cliente_id'], ['clientes.mae_clientes.empresa_id', 'clientes.mae_clientes.id'], name='fk_log_cambios_cliente_cliente_id'),
        sa.ForeignKeyConstraint(['empresa_id', 'promotor_id'], ['fuerza_ventas.mae_promotores.empresa_id', 'fuerza_ventas.mae_promotores.id'], name='fk_log_cambios_cliente_promotor_id'),
        schema='clientes'
    )
    op.create_index('ix_log_cambios_cliente_empresa_cliente', 'log_cambios_cliente', ['empresa_id', 'cliente_id'], unique=False, schema='clientes')
    op.create_index('ix_log_cambios_cliente_empresa_promotor', 'log_cambios_cliente', ['empresa_id', 'promotor_id'], unique=False, schema='clientes')
    op.create_index('ix_log_cambios_cliente_empresa_usuario', 'log_cambios_cliente', ['empresa_id', 'usuario_id'], unique=False, schema='clientes')
    op.create_index('ix_log_cambios_cliente_empresa_activo', 'log_cambios_cliente', ['empresa_id', 'is_activo'], unique=False, schema='clientes')
    op.execute('ALTER TABLE clientes.log_cambios_cliente ENABLE ROW LEVEL SECURITY')
    op.execute('''CREATE POLICY rls_log_cambios_cliente_tenant_isolation ON clientes.log_cambios_cliente FOR ALL USING (empresa_id = current_setting(\'app.current_empresa_id\', true)::BIGINT) WITH CHECK (empresa_id = current_setting(\'app.current_empresa_id\', true)::BIGINT)''')

def downgrade() -> None:
    op.execute('DROP POLICY IF EXISTS rls_log_cambios_cliente_tenant_isolation ON clientes.log_cambios_cliente')
    op.drop_index('ix_log_cambios_cliente_empresa_cliente', table_name='log_cambios_cliente', schema='clientes')
    op.drop_index('ix_log_cambios_cliente_empresa_promotor', table_name='log_cambios_cliente', schema='clientes')
    op.drop_index('ix_log_cambios_cliente_empresa_usuario', table_name='log_cambios_cliente', schema='clientes')
    op.drop_index('ix_log_cambios_cliente_empresa_activo', table_name='log_cambios_cliente', schema='clientes')
    op.drop_table('log_cambios_cliente', schema='clientes')