Saltar a contenido

0008. Auditoría y registro de movimientos (menú por producto)

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

Contexto

Se requiere saber, de forma completa y centralizada, todo lo que un usuario hace dentro del sistema: qué movimiento ejecutó, quién lo hizo, cuándo, qué acción fue (crear, editar, eliminar, consultar), si subió un archivo, qué entidad y qué registro cambiaron, y de qué valor a qué valor.

Los campos de auditoría de la nomenclatura (ADR 0004: created_at, updated_at, created_by, updated_by, deleted_at) solo guardan la última huella de cada fila, no el historial. Para reconstruir "quién y cuándo cambió qué" hace falta una bitácora de movimientos separada.

Además, los módulos de la aplicación se organizan en un menú por producto (ERP sigfa.com.ec y CRM sigfapro.com.ec), cada uno con su propia base de datos y su propio árbol de módulos/páginas. Para poder mostrar "en qué pantalla y módulo ocurrió la acción" la auditoría debe referenciar ese catálogo de menú.

Decisión

Se crea, en cada base de datos de producto (BD del ERP y BD del CRM), una tabla central de movimientos (audit_log) que registra cada acción del usuario. El catálogo de navegación que la alimenta vive en core_sigfa (ADR 0013) y lo sirve svc-identidad.

La navegación se organiza en tres niveles, cada uno en su tabla (ADR 0013): - cat_aplicaciones — las aplicaciones (ERP, CRM). Contenedor, no cuenta como nivel. - cfg_modulos — los módulos de cada aplicación (rrhh, inventario...). - cfg_menu — las páginas de un módulo, auto-referenciadas por padre_id con máximo 3 niveles (submenús).

Las acciones (crear, editar, eliminar, ver, subir archivo) se asocian a cada página de cfg_menu vía cfg_menu_acciones.

APLICACIÓN: ERP (sigfa.com.ec) — cat_aplicaciones  ← contenedor (no cuenta como nivel)
└── MÓDULO: rrhh              (cfg_modulos)
    └── PÁGINA: nomina        (cfg_menu, nivel 1)
        ├── registro          (nivel 2)
        └── vacaciones        (nivel 2)
└── MÓDULO: inventario        (cfg_modulos)
    └── PÁGINA: ingreso       (cfg_menu, nivel 1)
        ├── producto          (nivel 2)
        └── facturas          (nivel 2)

APLICACIÓN: CRM (sigfapro.com.ec) — cat_aplicaciones
└── MÓDULO: clientes          (cfg_modulos)
    └── PÁGINA: cartera       (cfg_menu, nivel 1)
└── MÓDULO: fuerza-ventas     (cfg_modulos)
    └── PÁGINA: promotores    (cfg_menu, nivel 1)

Tabla cat_aplicaciones (catálogo de productos — ERP, CRM, etc.) vive en core.identidad (ADR transversal 0013). cfg_modulos la referencia con aplicacion_id; cfg_menu referencia su módulo con modulo_id y sus submenús con padre_id. Todo se filtra por empresa_id + RLS.

Relación con la nomenclatura ADR 0004: cfg_modulos, cfg_menu y cfg_menu_acciones llevan prefijo cfg_ porque son configuración de sistema (se modifican al rearmar la navegación), no catálogos de negocio estáticos (cat_). Ver el ADR 0013 para el esquema completo.

Tabla cfg_menu (páginas de un módulo, máximo 3 niveles):

Campo Tipo Notas
id UUID PK
empresa_id BIGINT RLS (ADR 0002) — refiere al catálogo mae_empresas (BIGINT, ADR 0011)
modulo_id UUID FK → cfg_modulos módulo dueño de la página
padre_id UUID FK → cfg_menu NULL = nivel 1 (página); nivel 2 → padre nivel 1; nivel 3 → padre nivel 2
nivel smallint 1, 2 o 3 — nunca mayor a 3
codigo varchar ej. nomina, registro, vacaciones
nombre varchar nombre legible
ruta_frontend varchar path de la SPA en React (ej. /inventario/ingreso), solo para navegación — no es el endpoint del API
orden int orden dentro del padre
is_visible bool se muestra en el sidebar
is_activo bool habilitado
created_at, updated_at timestamptz

Tabla cfg_menu_acciones (acciones de cada página):

Campo Tipo Notas
id UUID PK
empresa_id BIGINT RLS (ADR 0002) — refiere al catálogo mae_empresas (BIGINT, ADR 0011)
menu_id UUID FK → cfg_menu el ítem/página sobre el que aplica (cualquier nivel)
codigo varchar ej. crear, editar, eliminar, ver, subir_archivo
nombre varchar
metodo_http varchar POST/PUT/PATCH/DELETE/GET (referencia del controlador)
created_at, updated_at timestamptz

Tabla central audit_log (los movimientos):

Campo Tipo Notas
id UUID PK
empresa_id BIGINT RLS (ADR 0002) — siempre presente; refiere al catálogo mae_empresas (BIGINT, ADR 0011)
menu_id UUID (correlación → cfg_menu) la página exacta donde ocurrió
accion_id UUID (correlación → cfg_menu_acciones) qué acción (ID del catálogo, nunca texto libre)
modulo_id UUID (correlación → cfg_modulos) módulo donde ocurrió (snapshot del MODULE_MAP, ADR 0009)
actor_user_id UUID (correlación → mae_usuarios) quién lo hizo
actor_username / actor_nombre / actor_email varchar copia del momento (del token): username, nombre y email del actor cuando ocurrió el movimiento
modulo_codigo / modulo_nombre varchar copia del momento (del MODULE_MAP del servicio): módulo donde ocurrió
menu_codigo / menu_nombre varchar copia del momento (del catálogo): página donde ocurrió, tal como se veía entonces
accion_codigo / accion_nombre varchar copia del momento (del catálogo): acción ejecutada
fecha timestamptz cuándo (default now()); columna de partición (rango mensual)
metodo_http varchar lo que reportó el header del controlador (POST, DELETE, GET, ...)
endpoint varchar endpoint del API capturado por el middleware (ADR 0009)
referencia_tipo varchar entidad de negocio afectada (ej. pedido, cliente, factura)
referencia_id UUID el registro de negocio afectado (vive en la misma BD del producto)
detalle text descripción legible: "se modificó el precio de 10 a 12"
datos_antes jsonb estado previo (opcional, snapshot)
datos_despues jsonb estado posterior (opcional, snapshot)
archivos jsonb archivos del request (vacío/null si no hay); ver estructura abaixo
trace_id UUID trazabilidad distribuida (ADR 0003)
created_at timestamptz

Qué son referencia_tipo / referencia_id: apuntan al registro de negocio que se afectó, no al usuario ni al menú: por ejemplo pedido + su UUID, o cliente + su UUID. El registro vive en la misma base del producto (el pedido está en erp), así que no rompen la regla de no cruzar bases, y sirven para responder "¿qué movimientos tuvo este pedido/cliente?" Mientras actor_* y menu_*/accion_* son copias legibles del momento (la auditoría es historia: muestra cómo estaba cada cosa cuando ocurrió el movimiento, ver ADR 0014), referencia_tipo/id identifican el objeto afectado.

Ejemplo de archivos (jsonb) — 1 archivo:

[
  {
    "nombre": "factura-001.pdf",
    "ruta": "https://s3.amazonaws.com/sigfa-erp/archivos/2026/09/factura-001.pdf",
    "tipo": "application/pdf",
    "hash": "sha256:abc123...",
    "tamano_bytes": 245760,
    "descripcion": "Factura original del proveedor"
  }
]

Ejemplo de archivos (jsonb) — múltiples archivos, cada uno con su descripción:

[
  {
    "nombre": "factura-001.pdf",
    "ruta": "https://s3.amazonaws.com/sigfa-erp/archivos/2026/09/f-001.pdf",
    "tipo": "application/pdf",
    "hash": "sha256:abc123...",
    "tamano_bytes": 245760,
    "descripcion": "Factura original"
  },
  {
    "nombre": "nota-credito.pdf",
    "ruta": "https://s3.amazonaws.com/sigfa-erp/archivos/2026/09/nc-001.pdf",
    "tipo": "application/pdf",
    "hash": "sha256:def456...",
    "tamano_bytes": 182400,
    "descripcion": "Nota de crédito asociada"
  },
  {
    "nombre": "comprobante-pago.png",
    "ruta": "https://s3.amazonaws.com/sigfa-erp/archivos/2026/09/cp-001.png",
    "tipo": "image/png",
    "hash": "sha256:ghi789...",
    "tamano_bytes": 51200,
    "descripcion": "Comprobante de transferencia bancaria"
  }
]

Estructura por archivo (campos flexibles):

Campo Tipo Requerido Notas
nombre string nombre original del archivo
ruta string sí* ruta local o URL de S3 (https://s3.amazonaws.com/...)
tipo string no MIME type (application/pdf, image/png, ...)
hash string no integridad (sha256:...)
tamano_bytes int no tamaño en bytes
descripcion string no descripción individual del archivo
meta object no metadata libre adicional (ej. {"version": 2, "aprobado_por": "admin"})

* rutas: el campo ruta acepta tanto ruta local como URL de S3. Hoy puede ser /storage/erp/archivos/f-001.pdf; mañana, cuando se migre a AWS S3, será https://s3.amazonaws.com/sigfa-erp/archivos/.... El frontend solo muestra el link/icono del elemento; el formato del storage es transparente para la auditoría.

meta es un campo libre para que cada servicio agregue la metadata que necesite. Ejemplo: un servicio de calidad podría agregar {"estado_calidad": "aprobado", "lote": "L-2026-001"}; un servicio de RRHH podría agregar {"tipo_nomina": "mensual", "periodo": "2026-09"}. El sistema de auditoría no valida ni consume meta; solo lo almacena.

Reglas de grabación

  • Escrituras (POST/PUT/PATCH/DELETE): siempre se auditan.
  • Lecturas sensibles (GET): se auditan solo sobre entidades sensibles (ej. ver cartera, ver factura, ver nómina), no en consultas de listado común.
  • Un solo punto de emisión: la grabación se dispara de forma consistente desde un middleware HTTP global (ADR 0009) que intercepta todas las peticiones. El middleware ya conoce al usuario autenticado (JWT), el metodo_http, el endpoint y resuelve el módulo y la acción automáticamente. Un decorador opcional solo enriquece cuando hay acción especial o lectura sensible.
  • Mapping por servicio: cada microservicio declara un MODULE_MAP que conecta los prefijos de sus endpoints con los códigos de cfg_modulos (ADR 0013). El middleware usa este mapping para resolver el módulo sin parsing del path (ADR 0009).
  • La asociación es por IDs, no por rutas: el middleware (FastAPI) captura metodo_http y endpoint del request, y el catálogo cacheado resuelve menu_id + accion_id. ruta_frontend es solo el path de la SPA de React para navegación y no se usa para casar auditoría ni para identificar el endpoint — así React y el API pueden tener rutas distintas sin romper el registro.

Ejemplo de doble ruta (frontend y backend):

Cuando un usuario crea un ingreso en la SPA:

React (SPA) — lo que ve el navegador:
  /inventario/ingreso            ← página del menú (ruta_frontend en `cfg_menu`)
  /inventario/ingreso/crear      ← formulario (navegación React Router)

Backend (FastAPI) — lo que invoca la SPA para guardar datos:
  POST /api/v1/inventario/ingresos  ← endpoint del controlador
  GET  /api/v1/inventario/ingresos  ← listado

La auditoría registrada en audit_log para ese "crear":

modulo_id        = módulo inventario    ← del MODULE_MAP del servicio (snapshot)
modulo_codigo    = inventario           ← del MODULE_MAP (snapshot)
modulo_nombre    = Inventario           ← del MODULE_MAP (snapshot)
menu_id          = página ingreso       ← del catálogo cacheado (snapshot)
accion_id        = crear                ← del catálogo cacheado (snapshot)
menu_codigo      = ingreso              ← del catálogo (snapshot)
menu_nombre      = Ingreso              ← del catálogo (snapshot)
accion_codigo    = crear                ← del catálogo (snapshot)
accion_nombre    = Crear                ← del catálogo (snapshot)
actor_user_id    = ...                  ← del token
actor_username   = d.alcivar            ← del token (snapshot)
actor_nombre     = Duval Alcivar        ← del token (snapshot)
metodo_http      = POST                 ← del Request (automático)
endpoint         = /api/v1/inventario/ingresos   ← del Request (automático)
ruta_frontend    (NO se guarda aquí)    ← solo vive en `cfg_menu` para navegación

El middleware resuelve automáticamente: esta petición corresponde al módulo X, a la página Y y a la acción Z". No existe un match automático por string entre ruta_frontend y endpoint. - La auditoría es un historial (snapshot del momento): al grabar se copian también los nombres legibles — actor_username/actor_nombre/ actor_email (del token), modulo_codigo/modulo_nombre (del MODULE_MAP del servicio) y menu_codigo/menu_nombre/ accion_codigo/accion_nombre (del catálogo). Así el registro es autónomo: se lee con un solo SELECT local, sin consultar core_sigfa ni resolver por endpoint, y refleja cómo estaba cada cosa en esa pantalla cuando ocurrió el movimiento (si el usuario, el módulo o el menú cambian después, el histórico no cambia; ver ADR 0014). - Los archivos se referencian, no se copian en la BD: se guardan en archivos, un único campo jsonb flexible que soporta de 0 a N archivos. Cada archivo es un objeto con campos estándar (nombre, ruta, tipo, hash, tamano_bytes, descripcion) más un campo meta libre para metadata adicional del servicio. La sola presencia de contenido en el campo (array no vacío) actúa como bandera de "hay archivo(s)" para la vista de movimientos. El binario vive en S3; el registro solo lo referencia. - Migración a AWS S3: hoy ruta puede ser una ruta local (/storage/erp/archivos/f-001.pdf); cuando se migre a S3, será una URL (https://s3.amazonaws.com/sigfa-erp/archivos/...). El frontend solo muestra el link/icono del elemento; el formato del storage es transparente para la auditoría. - RLS aplica (ADR 0002): cada empresa solo ve sus propios movimientos; el catálogo de menú (cfg_menu) también es por empresa (RLS), por si un producto diferencia permisos/menú por empresa. - Escritura asíncrona: si el volumen lo exige, la emisión puede ir por outbox/evento (regla 4 del ADR 0003) para no frenar la transacción de negocio; la lectura siempre es directa en la BD del producto. - Una tabla por producto, sin cruce entre bases: nunca un audit_log global; cada BD (ERP o CRM) mantiene el suyo (ADR 0003: cero consultas entre bases, correlación por UUID).

Particionamiento

audit_log se particiona por rango mensual sobre fecha (PARTITION BY RANGE (fecha), con empresa_id como índice de RLS en cada partición). Se elige mes porque mantiene 12 particiones/año (vs. 52 si fueran semanales), coincide con la consulta típica ("movimientos de un usuario/mes") y permite purgar o archivar un mes entero con un solo DROP TABLE/detach, sin DELETE masivo.

Consecuencias técnicas:

  • PK compuesta: la clave primaria debe incluir la columna de partición → PRIMARY KEY (id, fecha).
  • La partición se crea por adelantado, de forma programada: Postgres no crea particiones automáticamente ante INSERT — si fecha cae en un rango sin partición, el INSERT falla. Un job (por ejemplo pg_partman o un job del propio servicio que ejecuta CREATE TABLE ... PARTITION OF audit_log FOR VALUES FROM ... TO ...) crea con antelación las particiones del mes actual y del siguiente (margen de +1 a +2 meses).
  • Escritura como append: el write path escribe solo en la partición del mes corriente; las anteriores quedan inmóviles, ideales para indexar y para retención/solo lectura.
  • Retención/purga: particiones viejas se detach + archivan o se eliminan completas según la política de retención definida por producto.

Alternativas descartadas

  • Guardar únicamente los campos de auditoría de ADR 0004 — descartado: solo muestran quién tocó por última vez una fila, no el historial de movimientos ni qué cambió cada vez.
  • Una sola tabla de auditoría en core compartida por ERP y CRM — descartado: viola la regla de cero consultas entre bases (ADR 0003) y mezcla movimientos de dos productos con catálogos de menú distintos.
  • Triggers de BD para registrar auditoría — descartado: acoplan la auditoría a la DDL, duplican la lógica en cada servicio y no tienen contexto del usuario/acción funcional (no saben qué botón o página fue).
  • Texto libre en accion_id (guardar nombre de acción como string) — descartado: perdería la referencia al catálogo de menú, imposibilita reportar "en qué módulo ocurrió qué" y desnormaliza datos que ya existen.
  • Páginas como tablas planas por nivel (cat_paginas + subniveles) — descartado: una página de nivel 2 y otra de nivel 3 son la misma naturaleza (ítem de menú); una sola tabla auto-referenciada (cfg_menu con padre_id) recorre el árbol de páginas uniformemente, sin duplicar esquemas por nivel ni romper si el árbol crece distinto. Los módulos sí son tabla aparte (cfg_modulos, ADR 0013) porque son otra entidad: agrupan páginas y se muestran en la grilla de la aplicación.

Consecuencias

  • Ganancia: trazabilidad completa y centralizada — quién, cuándo, qué acción, en qué módulo/página, sobre qué registro, con qué datos, y qué archivo (ruta/URL) se subió.
  • Ganancia: el catálogo de menú se reutiliza para permisos/navegación y para la auditoría; el ID de pantalla/acción se toma del mismo lugar.
  • Costo: audit_log crece con cada escritura (y cada lectura sensible); la partición mensual la mantiene manejable (purga por DROP de partición), pero exige un job que cree las particiones por adelantado y una política de retención definida por producto.
  • Pendiente: definir el catálogo inicial de acciones por pantalla (máx. 3 niveles), qué pantallas califican como "lectura sensible" y la política de retención de la tabla (tiempo/volumen) en cada producto.
  • Modelo de datos: el esquema de navegación (cat_aplicaciones, cfg_modulos, cfg_menu, cfg_menu_acciones) está en el ADR 0013; rel_empresa_aplicacion en el ADR 0006 de svc-identidad; y audit_log en el ADR 0014 (con su PK compuesta (id, fecha), particionado y snapshot del módulo). La captura automática está en el ADR 0009 (middleware + mapping por servicio).