Saltar a contenido

0014. Modelo de datos: auditoría (audit_log)

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

Contexto

Los ADRs 0008 (auditoría) y 0009 (captura backend) definen la bitácora de movimientos. Este ADR fija el modelo de datos de audit_log, que vive en cada base de datos de producto (una por ERP y otra por CRM) y se particiona por rango mensual. La navegación que la alimenta está en el ADR 0013.

Decisión

audit_log registra cada acción del usuario: quién, cuándo, qué acción, en qué página/módulo, sobre qué registro, con qué datos y qué archivos. Además de los identificadores (UUID), guarda una copia legible del momento: el username/nombre/email del actor y los códigos/nombres de la página y de la acción tal como eran cuando ocurrió el movimiento, para que el historial se lea sin consultar otras bases (ADR 0003) y refleje lo que se vio en esa pantalla entonces.

Ubicación

Tabla BD Esquema Dueño
audit_log erp_sigfa / crm_sigfapro erp.audit / crm.audit cada producto

Una por producto, sin cruce entre bases (ADR 0003): cada BD mantiene su propia bitácora. menu_id/accion_id apuntan a cfg_menu/ cfg_menu_acciones que viven en core_sigfa (otra BD), así que se correlacionan por UUID, sin FK entre bases; sus nombres se copian en el registro como snapshot en el momento de grabar.

Tabla audit_log

Campos agrupados en identificación (UUID: correlación estable), datos del momento (texto copiado al grabar: lo que se vio en pantalla entonces) y detalle del cambio.

Campo Tipo Restricciones Notas
id UUID PK compuesta: (id, fecha) DEFAULT gen_random_uuid()
empresa_id BIGINT NOT NULL RLS
fecha TIMESTAMPTZ NOT NULL, DEFAULT now() columna de partición
menu_id UUID NOT NULL correlación → cfg_menu (core, sin FK entre BD)
accion_id UUID NOT NULL correlación → cfg_menu_acciones (core, sin FK entre BD)
modulo_id UUID correlación → cfg_modulos (core, sin FK entre BD)
actor_user_id UUID NOT NULL quién lo hizo (UUID del token)
actor_username VARCHAR(50) username del actor en ese momento
actor_nombre VARCHAR(200) nombre del actor en ese momento
actor_email VARCHAR(150) email del actor en ese momento
modulo_codigo VARCHAR(50) código del módulo en ese momento (ej. inventario, contabilidad)
modulo_nombre VARCHAR(150) nombre del módulo en ese momento
menu_codigo VARCHAR(50) código de la página en ese momento (ej. nomina)
menu_nombre VARCHAR(150) nombre de la página en ese momento
accion_codigo VARCHAR(50) código de la acción (ej. crear)
accion_nombre VARCHAR(100) nombre de la acción en ese momento
metodo_http VARCHAR(10) POST, PUT, PATCH, DELETE, GET
endpoint VARCHAR(300) endpoint del API (FastAPI)
referencia_tipo VARCHAR(50) registro de negocio afectado en la misma BD: pedido, cliente, factura, ingreso
referencia_id UUID el registro de negocio afectado (su UUID): responde "¿qué le pasó a este pedido?"
detalle TEXT descripción legible del cambio
datos_antes JSONB snapshot previo (opcional)
datos_despues JSONB snapshot posterior (opcional)
archivos JSONB archivos del request: [{nombre, ruta, tipo, hash, tamano_bytes, descripcion, meta}] flexible, 0-N archivos
trace_id UUID trazabilidad distribuida (ADR 0003)
created_at TIMESTAMPTZ DEFAULT now()

Por qué se guarda el snapshot del momento

La auditoría es un historial, no un espejo del estado actual: muestra cómo estaban las cosas cuando ocurrió el movimiento, no cómo están hoy.

  • Quién: si el usuario cambia de username, nombre o email, el movimiento debe seguir mostrando quién lo hizo entonces. actor_user_id (UUID) es la correlación estable; actor_username / actor_nombre / actor_email son la copia legible del momento (tomada del token en el request).
  • En qué pantalla: si mañana se rearma el menú (cfg_menu) o se renombra una acción, el histórico debe mostrar la página/acción que había cuando se registró. menu_id/accion_id (UUID) son la correlación; menu_codigo / menu_nombre / accion_codigo / accion_nombre son la copia legible del momento (tomada del catálogo de navegación al grabar).
  • En qué módulo: si mañana se reorganizan los módulos de la aplicación, el histórico debe mostrar el módulo que existía cuando se registró. modulo_id (UUID) es la correlación; modulo_codigo / modulo_nombre son la copia legible del momento (tomada del MODULE_MAP del servicio, ADR 0009). Esto permite filtrar "todo lo que se hizo en inventario" o "movimientos de contabilidad este mes" sin JOIN a core_sigfa — el módulo ya está en el snapshot.
  • Autonomía de lectura (ADR 0003): con esa copia, la vista de movimientos en el ERP (o CRM) se arma con un solo SELECT local a erp.audit_log / crm.audit_log — sin consultar core_sigfa ni llamar a ninguna API para resolver nombres. Esa es la única forma de garantizar cero consultas cruzadas entre bases y a la vez mostrar el historial legible.
  • referencia_tipo / referencia_id: identifican qué registro de negocio se afectó (ej. pedido + su UUID). El registro vive en la misma base del producto (está en erp/crm), así que no rompe la regla de no cruzar bases, y permite filtrar "todo lo que se hizo sobre este pedido/cliente/factura". A diferencia de actor_*/modulo_*/menu_*/ accion_* (copias legibles), estos dos apuntan al objeto de negocio afectado.
  • Regla de grabación: los campos de texto se llenan siempre en el momento de grabar (del token, del MODULE_MAP y del catálogo). Si por un caso extremo (legado) un nombre no se pudiera resolver, se inserta NULL y se loguea el fallo — la auditoría nunca deja de grabarse por una resolución de nombre.
CREATE TABLE erp.audit_log (
    id                UUID NOT NULL DEFAULT gen_random_uuid(),
    empresa_id        BIGINT NOT NULL,
    fecha             TIMESTAMPTZ NOT NULL DEFAULT now(),
    -- correlación estable (UUID, sin FK entre bases)
    menu_id           UUID NOT NULL,
    accion_id         UUID NOT NULL,
    modulo_id         UUID,
    actor_user_id     UUID NOT NULL,
    -- datos del momento (snapshot): copia legible al grabar
    actor_username    VARCHAR(50),
    actor_nombre      VARCHAR(200),
    actor_email       VARCHAR(150),
    modulo_codigo     VARCHAR(50),
    modulo_nombre     VARCHAR(150),
    menu_codigo       VARCHAR(50),
    menu_nombre       VARCHAR(150),
    accion_codigo     VARCHAR(50),
    accion_nombre     VARCHAR(100),
    -- detalle del cambio
    metodo_http       VARCHAR(10),
    endpoint          VARCHAR(300),
    referencia_tipo   VARCHAR(50),
    referencia_id     UUID,
    detalle           TEXT,
    datos_antes       JSONB,
    datos_despues     JSONB,
    archivos          JSONB,
    trace_id          UUID,
    created_at        TIMESTAMPTZ DEFAULT now(),

    PRIMARY KEY (id, fecha)  -- PK compuesta por partición
) PARTITION BY RANGE (fecha);

-- menu_id / accion_id / modulo_id se correlacionan por UUID a
-- cfg_menu / cfg_menu_acciones / cfg_modulos (core_sigfa, otra BD).
-- Sus nombres se copian en el momento de grabar como snapshot:
-- el historial se lee con un SELECT local, sin consultar core ni APIs.

CREATE INDEX ix_audit_log_empresa_id ON erp.audit_log (empresa_id);
CREATE INDEX ix_audit_log_empresa_fecha ON erp.audit_log (empresa_id, fecha);
CREATE INDEX ix_audit_log_actor_user_id ON erp.audit_log (actor_user_id);
CREATE INDEX ix_audit_log_menu_id ON erp.audit_log (menu_id);
CREATE INDEX ix_audit_log_modulo_codigo ON erp.audit_log (modulo_codigo);
CREATE INDEX ix_audit_log_referencia ON erp.audit_log (referencia_tipo, referencia_id);
CREATE INDEX ix_audit_log_trace_id ON erp.audit_log (trace_id);

-- RLS
ALTER TABLE erp.audit_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY aislamiento_empresa ON erp.audit_log
    USING (empresa_id = NULLIF(current_setting('app.current_empresa_id', true), '')::BIGINT);
class AuditLogERP(Base):
    __tablename__ = "audit_log"
    __table_args__ = (
        Index("ix_audit_log_empresa_id", "empresa_id"),
        Index("ix_audit_log_empresa_fecha", "empresa_id", "fecha"),
        Index("ix_audit_log_actor_user_id", "actor_user_id"),
        Index("ix_audit_log_menu_id", "menu_id"),
        Index("ix_audit_log_modulo_codigo", "modulo_codigo"),
        Index("ix_audit_log_referencia", "referencia_tipo", "referencia_id"),
        Index("ix_audit_log_trace_id", "trace_id"),
        {"schema": "erp"},
    )

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    empresa_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, nullable=False)
    fecha: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True, nullable=False, server_default=func.now())
    menu_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
    accion_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
    modulo_id: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
    actor_user_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
    actor_username: Mapped[str | None] = mapped_column(String(50))
    actor_nombre: Mapped[str | None] = mapped_column(String(200))
    actor_email: Mapped[str | None] = mapped_column(String(150))
    modulo_codigo: Mapped[str | None] = mapped_column(String(50))
    modulo_nombre: Mapped[str | None] = mapped_column(String(150))
    menu_codigo: Mapped[str | None] = mapped_column(String(50))
    menu_nombre: Mapped[str | None] = mapped_column(String(150))
    accion_codigo: Mapped[str | None] = mapped_column(String(50))
    accion_nombre: Mapped[str | None] = mapped_column(String(100))
    metodo_http: Mapped[str | None] = mapped_column(String(10))
    endpoint: Mapped[str | None] = mapped_column(String(300))
    referencia_tipo: Mapped[str | None] = mapped_column(String(50))
    referencia_id: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
    detalle: Mapped[str | None] = mapped_column(Text)
    datos_antes: Mapped[dict | None] = mapped_column(JSONB)
    datos_despues: Mapped[dict | None] = mapped_column(JSONB)
    archivos: Mapped[list | None] = mapped_column(JSONB)
    trace_id: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

PK compuesta (id, empresa_id, fecha) en SQLAlchemy (por el naming_convention); en SQL PG PRIMARY KEY (id, fecha) por la partición. PARTITION BY RANGE (fecha) se aplica en la migración Alembic, no en el modelo ORM.

Particionamiento

audit_log se particiona por rango mensual sobre fecha (12 particiones por año; permite purgar/archivar un mes entero con un solo DROP/detach).

CREATE TABLE erp.audit_log_2026_09 PARTITION OF erp.audit_log
    FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

CREATE TABLE erp.audit_log_2026_10 PARTITION OF erp.audit_log
    FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');

PK compuesta: PRIMARY KEY (id, fecha) porque fecha es la columna de partición. Sin partición para un rango, el INSERT falla; por eso las particiones se crean por adelantado, de forma automática con un job APScheduler (ver ADR 0015 — Tareas programadas).

Alternativas descartadas

  • Una sola tabla de auditoría global compartida por ERP y CRM — descartado: viola la regla de cero consultas entre bases (ADR 0003).
  • Particionado por LIST en empresa_id — descartado: el patrón típico es "movimientos de un usuario/mes" → RANGE (fecha); empresa_id se indexa.
  • FK de menu_id/accion_id a las tablas de navegación — descartado: viven en otra BD; se correlaciona por UUID (ADR 0003).

Consecuencias

  • Ganancia: trazabilidad completa y centralizada por producto.
  • Ganancia: el historial se lee con un solo SELECT local — los nombres del actor, del módulo, de la página y de la acción se graban en el momento; cero consultas cruzadas ni llamadas por API para resolver el historial (ADR 0003).
  • Ganancia: el snapshot del módulo (modulo_codigo/modulo_nombre) permite filtrar y agrupar por módulo sin JOIN a core_sigfa: WHERE modulo_codigo = 'inventario' o GROUP BY modulo_codigo, modulo_nombre.
  • Ganancia: el particionado se crea y cae solo, sin intervención manual ni extensiones del servidor (job APScheduler, ADR 0015).
  • Costo: audit_log crece con cada escritura (y los campos snapshot agregan texto); partición mensual + job programado que hay que monitorear (si falla meses seguidos → alerta).
  • Costo: si el catálogo o el usuario cambian después, el histórico conserva los nombres del momento (correcto para auditoría), pero el snapshot solo es legible para lo que existía al grabar; si una pantalla se elimina por completo, su histórico conserva igual su nombre copiado.
  • Pendiente: política de retención (tiempo/volumen) por producto.

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