Saltar a contenido

0004. Nomenclatura de tablas, campos y esquemas

Estado: Propuesto Fecha: 2026-08-28 Autor: Duval Alcivar

Contexto

Con la división en microservicios (ADR 0003) y el aislamiento por empresa vía RLS (ADR 0002), cada tabla nueva define: en qué esquema vive, con qué prefijo, con qué columnas de control y con qué convenciones de nombres. Sin estas convenciones fijas, cada desarrollador nombra distinto, las tablas de un mismo servicio se dispersan a lo largo del tiempo y el aislamiento por empresa depende del recuerdo de quién creó la tabla.

Hay que fijar la nomenclatura antes de la primera línea de código para que una tabla de svc-operaciones no aparezca mañana en el esquema de svc-compras, y para que empresa_id exista en todas desde el día uno.

Decisión

Esquemas y tabla: dos ejes independientes

Una tabla se identifica por dos ejes ortogonales: - Esquema = dominio de negocio del servicio al que pertenece. - Prefijo = naturaleza del dato (qué tipo de dato es, no dónde vive).

Ej.: erp.pedidos.trx_pedidos, erp.pedidos.cat_canales, erp.activos.mae_activos, core.identidad.mae_usuarios, core.identidad.cat_aplicaciones, core.workflow.trx_transiciones_estatus.

Esquemas — un esquema por microservicio/dominio, dueño exclusivo del mismo: core.identidad, erp.operaciones, erp.contable, crm.clientes, etc. Tres raíz agrupan por producto (core, erp, crm), uno por base de datos (coincidente con core_sigfa, erp_sigfa, crm_sigfapro). Ninguna tabla vive en public (reservado solo a migraciones/administración).

Prefijos de tabla — el prefijo dice de un vistazo, en un explorador con cientos de tablas, si la tabla es de referencia, una entidad central o un evento transaccional. Regla aplicada automáticamente vía naming_convention de SQLAlchemy, no por disciplina manual:

Tipo Prefijo Regla Ejemplos
Catálogo (referencia, cambia poco, cacheable) cat_ cat_<entidad_plural> cat_canales, cat_paises, cat_tipos_identificacion, cat_generos, cat_tipos_credito, cat_aplicaciones, cat_permisos
Maestro (entidad central del dominio) mae_ mae_<entidad_plural> mae_activos, mae_personal, mae_productos, mae_usuarios, mae_empresas, mae_sucursales
Transacción — cabecera (alto volumen, crece a diario) trx_ trx_<evento_plural> trx_casos, trx_ventas, trx_movimientos_inventario, trx_ordenes_produccion, trx_asientos_contables, trx_refresh_tokens
Transacción — detalle/líneas trx_..._detalle hijo de una cabecera trx_casos_detalle
Relación N:M rel_ rel_<entidad1>_<entidad2> rel_activo_componente, rel_usuario_sucursal, rel_usuario_permiso, rel_usuario_menu
Configuración de sistema cfg_ parámetros ajustables (no catálogo de negocio) cfg_variables_sistema, cfg_modulos, cfg_menu, cfg_menu_acciones
Auditoría / histórico log_ trazabilidad, sesiones, cambios log_sesiones, audit_log
Vista vw_ vw_cxc_vencida

Cada tabla lleva la columna empresa_id (BIGINT) y RLS activo, salvo las del core catalogadas como "transversales" (ej. listas de países), que se declaran explícitamente sin empresa_id en su ADR de mapeo.

Idioma y columnas estándar

Decisión de idioma: el dominio de negocio queda en español (fecha_caso, clientes, pedidos, canal); las columnas técnicas y de auditoría van en inglés (id, created_at, updated_at), por ser la convención que ORMs y herramientas de migración esperan.

Columnas estándar en toda tabla (aplicadas automáticamente):

Columna Tipo Regla
id UUID PK, correlación entre servicios
empresa_id BIGINT aisle por RLS, se llena desde el token
created_at TIMESTAMPTZ default now()
updated_at TIMESTAMPTZ default now()
deleted_at TIMESTAMPTZ NULL soft delete
created_by UUID user que la creó
updated_by UUID user de la última escritura

Campos

  • snake_case (negocio en español, técnica en inglés; ver idioma arriba).
  • Llave primaria (columna): id, siempre.
  • Llave foránea (columna): <entidad_singular>_idactivo_id, empresa_id, sucursal_id. Siempre el nombre de la entidad seguido de _id; nunca id_<entidad> (incorrecto: id_empresa, id_activo). Solo dentro del mismo servicio; nunca FK entre servicios (ADR 0003), se correlaciona por UUID vía id.
  • Booleano: is_<...>is_activo.
  • Fecha de negocio: fecha_<...> en español → fecha_caso.
  • Dinero: tipo NUMERIC(12,2) en moneda base; el símbolo y la tasa viven en tablas de configuración, nunca embebidos en la columna.
  • trace_id (UUID) obligatorio en toda tabla de outbox/eventos y en los logs, no necesariamente en toda tabla de negocio.

Constraints y otros objetos — mismos nombres, aplicados por el naming_convention de SQLAlchemy:

Elemento Convención
PK constraint pk_mae_activos
FK constraint fk_mae_personal_cat_tipos_identificacion (con calificador si hay ambigüedad)
Índice ix_<tabla>_<columna>ix_trx_casos_activo_id
Único uq_<tabla>_<columna>
Check ck_<tabla>_<regla>
Función fn_validar_transicion_estatus_caso
Trigger trg_mae_activos_before_insert_update

Equivalencia con el ERP actual. La convención real del ERP (prefijo cNNNt_ para tablas transaccionales — c048t_caso, c059t_venta — e iNNNt_ para catálogos/config — i010t_personal, i098t_almacen) es conceptualmente equivalente a trx_/cat_. El prefijo destino se aplica sin fricción conceptual; lo que cambia es escala y estado: cientos de tablas, dos sistemas EAV duplicados en Inventario y decenas de iNNNt_config_* ad-hoc que se rediseñan a cat_* tipados o ENUM nativo en la migración, no solo se renombran.

Tipos de datos

  • idUUID (gen_random_uuid)
  • Fechas/horas → TIMESTAMPTZ (nunca TIMESTAMP sin zona)
  • Dinero → NUMERIC(12,2) por defecto. Ver la nota de monedas y decimales abajo.
  • Enumerados → columna TEXT con CHECK o enum de PostgreSQL gestionado por migración; nunca strings libres sin validar.
  • JSON → JSONB, solo para datos de lectura flexibles, nunca para lógica que deba consultarse de forma estructurada.

Nota: por qué dinero es NUMERIC, y por qué la escala

La elección de fondo es NUMERIC, no FLOAT/REAL. El dinero se suma, se resta y debe cuadrar al centavo. Los tipos punto flotante binarios no representan 0.1 exactamente (en binario es un decimal infinito periódico), de modo que al sumar muchísimos registros se pierden fracciones de centavo. NUMERIC/DECIMAL guardan decimal exacto y el resultado siempre cuadra.

La escala (el 2 o 4) es una decisión de negocio, no técnica. No es "2 es mejor que 4": depende de la moneda y del dominio (ISO 4217 usa 2 decimales para casi toda moneda — USD, EUR, pesos — pero 3 o 4 para algunas). Usar NUMERIC(... , 4) con una moneda de 2 decimales guarda decimales de redondeo que no son dinero real del cliente y ensucian reportes.

Además, la escala y la precisión compiten por los mismos dígitos. En NUMERIC(P,S), P es el total de dígitos y S los que van a la derecha del punto; la parte entera tiene P − S dígitos. Comparación:

Tipo Parte entera Escala Máximo representable
NUMERIC(12,2) 10 dígitos 2 99.999.999.999,99
NUMERIC(12,4) 8 dígitos 4 99.999.999,9999
NUMERIC(14,4) 10 dígitos 4 99.999.999.999,9999

Mantener el mismo total 12 y subir la escala a 4 recorta 2 dígitos de la parte entera y limita los montos a ~99 millones en vez de cientos de miles de millones — demasiado pequeño para ventas/producción. Por eso no basta con "cambiar el 2 por el 4": hay que subir también P (ej. (14,4)).

Regla para este proyecto: NUMERIC(12,2) como estándar (monedas de 2 decimales — la mayoría de clientes). Un dominio que exija mayor fracción (precios unitarios muy pequeños o una moneda de 4 decimales) lo declara explícitamente en su ADR con su NUMERIC(P,S) ajustado y su test de redondeo en CI; no se cambia la escala sin esa justificación documentada. Ser conscientes de la escala importa más que elegir 2 o 4 a ciegas.

Cuándo usar FLOAT y cuándo NUMERIC

La regla se resume en una pregunta: ¿ese valor se suma/resta y debe cuadrar, o representa una medida continua que se promedia/busca en un rango?

  • NUMERIC (o DECIMAL) — cuando el valor es exacto, se acumula y debe cuadrar: dinero, cantidades facturables, stock, porcentajes que se suman. Es lo que se guarda en la aplicación de negocio.
  • DOUBLE PRECISION (≈ FLOAT8) — cuando el valor es una medida continua que se compara, promedia o usa en fórmulas científicas, y una mínima imprecisión de redondeo es irrelevante: temperaturas, pesos/medidas de laboratorio, coordenadas, ratios de rendimiento, mediciones de sensores, cálculos de ingeniería.
  • REAL (≈ FLOAT4) — como el anterior pero con precisión de 32 bits, menos espacio, suficiente para magnitudes de 6 cifras significativas. Evitar a menos que el volumen/espacio lo justifique.

No usar FLOAT donde aplica NUMERIC. Guardar dinero, stock o montos iva/facturables en DOUBLE PRECISION es un bug silencioso: los redondeos binarios se acumulan y el total no cuadra. Por el contrario, no es error almacenar una medición en NUMERIC si se quiere exactitud — el coste es rendimiento y espacio, no corrección.

Ejemplos según el dominio:

Dato Tipo Por qué
Monto de factura, iva, saldo CxC NUMERIC(12,2) se suma, debe cuadrar al centavo
Precio unitario, costo NUMERIC(12,2) (o (14,4) si el ADR del dominio lo justifica) se acumula en totales
Stock / cantidad facturable NUMERIC entero escala 0 o según la unidad se descuenta y debe cuadrar
Temperatura, peso de materia prima DOUBLE PRECISION medida continua, se promedia
Coordenadas GPS, sensores DOUBLE PRECISION (o REAL si basta) medición, se compara en rangos
Ratio/margen porcentual calculado DOUBLE PRECISION o NUMERIC calculado en la query valor derivado, no se persiste para acumular

Regla de revisión: un PR que defina una columna de dinero, stock o montos acumulables en FLOAT/DOUBLE PRECISION se rechaza.

Migraciones

  • Versionadas con Alembic: cada cambio de esquema es un archivo versionado, con rollback posible y trazabilidad de quién/cuándo/por qué.
  • Nombres de constraints e índices los aplica el naming_convention de SQLAlchemy automáticamente (pk_, fk_, ix_, uq_, ck_), no la disciplina manual de cada desarrollador.
  • El esquema de un servicio solo lo migra su propio pipeline.

Cuándo usar CHECK, índices (simples y compuestos) y particionado

CHECK

Un CHECK valida un invariante de negocio sobre el valor de una columna (o de varias columnas de la misma fila). Se usa cuando la regla es fija, no cambia con el tiempo y cualquier otro valor es inconsistente, no permisible:

  • Estados o rangos finitos dentro de una fila: estado IN ('activo','inactivo'), cantidad >= 0.
  • Coherencia entre columnas de la misma fila: fecha_fin >= fecha_inicio, porcentaje_descuento <= 100.

Se define solo cuando la regla es unívoca e inequívoca. Si la regla puede cambiar de criterio o lista de valores con el negocio (ej. "qué estados existen"), se prefiere una tabla de catálogo + FK en vez de un CHECK que habría que migrar cada vez (en PostgreSQL cambiar un CHECK requiere ALTER TABLE y revalidación). Los enums/selecciones del negocio que viven en tablas de catálogo no deben duplicarse como CHECK.

Índice simple

Un índice solo — en una columna — cuando se busca por ese campo solo, y típicamente por:

  • Claves foráneas intra-servicio (pedido_id): toda FK necesita índice (PostgreSQL no lo crea automáticamente).
  • empresa_id de cada tabla: obligatorio para que el RLS (ADR 0002) y el filtrado por empresa no haga scan completo.
  • Columnas que aparecen solas en WHERE/JOIN frecuente.

Índice compuesto

Un índice sobre varias columnas en un orden — se usa cuando el filtro más frecuente toca esas columnas juntas y respeta el orden de izquierda a derecha. Un compuesto sobre (a, b) sirve para buscar por a, por a AND b, pero no por b solo:

  • (empresa_id, fecha) — consultas por empresa y fecha (muy común en reportes ERP).
  • (pedido_id, item) — detalle de líneas de un pedido.
  • En la clave primaria compuesta de una tabla de detalle: (cabecera_id, secuencia).

No crear compuestos a ciegas: cada columna extra sirve solo si el filtro la usa en ese orden. Si se consulta por varias columnas por separado (a veces por a, otras por b), hacen falta índices simples, no un compuesto.

Particionado: rango vs lista

Se particiona una tabla cuando crece tanto que los índices no caben en memoria o el scan se vuelve lento (regla práctica: desde decenas de millones de filas, o cuando el mantenimiento/respaldo por rango sea un requisito). Las particiones rara vez se justifican en tablas pequeñas; se decide con métricas (explain analyze, tamaño), no por adelantado.

  • Por rango (RANGE) — sobre una columna continua y creciente, casi siempre fecha/timestamp. Cada partición es un intervalo (ej. una por mes/año). Ideal para históricos de solo escritura que se consultan por período y que además permiten descartar/archivar la partición vieja casi gratis: movimientos_stock, logs, eventos/outbox, facturas por período.
  • Por lista (LIST) — sobre una columna de valores discretos conocidos y finitos. Cada partición agrupa un valor (o varios): por empresa_id (una partición por cliente — encaja con el aislamiento por empresa del ADR 0002) o por tipo, cuando cada valor tiene volumen y patrón de uso distinto.
Criterio RANGE LIST
Columna base continua/creciente (fecha) valores discretos (empresa, tipo)
Partición por intervalo de valor conjunto de valores
Caso típico históricos por período, outbox tablas por empresa, catálogos por tipo
Ventaja archivar/descartar períodos viejos aislar/paralelizar por grupo

Elección cerrada en el ADR del dominio, con su columna de partición y la estrategia de mantenimiento (cuándo se crean/descartan particiones). El particionado no sustituye los índices por empresa ni el RLS: son complementarios.

Alternativas descartadas

  • Solo esquema, sin prefijos (erp.pedidos, erp.operaciones) — descartado: en un explorador con cientos de tablas el esquema no basta para saber de un vistazo si una tabla es de referencia, una entidad central o un evento transaccional. El prefijo (cat_, mae_, trx_, etc.) añade esa dimensión sin coste, y es aplicable al ERP actual sin fricción conceptual (equivale a iNNNt_/cNNNt_).
  • Usar public como esquema único — descartado: no deja claro a qué dominio/servicio pertenece cada tabla y rompe el aislamiento de qué pipeline migra qué.
  • Prefijo por producto en cada tabla (erp_pedidos, crm_clientes) — descartado como regla general: el esquema raíz ya aporta esa agrupación; el prefijo queda reservado a la naturaleza del dato, no al producto.
  • Convención por disciplina manual de cada desarrollador (sin naming_convention de SQLAlchemy) — descartado: los nombres de constraints e índices (pk_, fk_, ix_, uq_, ck_) dependen de que el motor los aplique automáticamente, para no depender del recuerdo ni del estilo individual.

Ejemplos de pares que se confunden (qué elegir y por qué)

NUMERIC(12,2) vs FLOAT para el precio unitario. Ambos guardan un número, pero el precio se acumula en totales de factura, línea por línea, día a día; en FLOAT los redondeos binarios se acumulan y la suma de la factura no cuadra con la suma de sus líneas. → NUMERIC(12,2).

NUMERIC(12,2) vs NUMERIC(12,4) para el monto. No es cuestión de "más precisión es mejor": (12,4) recorta la parte entera a ~99 millones (8 dígitos enteros) frente a los 10 del (12,2). Para ventas/producción de una empresa eso es poco. Si un cliente pide 4 decimales, se sube también el total: (14,4). → escala y precisión se deciden juntas, no el 4 a secas.

FLOAT/DOUBLE PRECISION vs NUMERIC para el peso de materia prima. Un peso de pesca/almacén es una medida continua que se promedia y compara en rangos; una fracción de gramo irrelevante no rompe nada. El mismo valor en dólares sería NUMERIC. Es el mismo dato (peso) en contexto medicinal no monetario. → DOUBLE PRECISION.

CHECK vs tabla de catálogo para el estado. estado IN ('activo','inactivo') con dos estados fijos que no van a cambiar → CHECK está bien. Pero estado IN ('borrador','aprobado','enviado','pagado','anulado') donde el negocio puede añadir estados → el CHECK obliga a un ALTER TABLE revalidando la tabla completa; una tabla de catálogo con FK solo exige un insert. → tabla de catálogo + FK cuando la lista puede crecer, CHECK solo para invariantes inmutables.

Índice simple vs compuesto en (empresa_id, fecha). Si el reporte filtra siempre por empresa y fecha juntas → compuesto (empresa_id, fecha) lo resuelve en un índice, eficiente para el rango de fechas dentro de una empresa. Si a veces se busca por fecha sola y a veces por empresa sola → el compuesto no sirve para la fecha sola; hacen falta dos índices simples (empresa_id, fecha). No es "compuesto mejor": depende de qué columnas viajan juntas en el WHERE.

Particionado RANGE por fecha vs LIST por empresa en movimientos. Un histórico de movimientos de stock que se archiva por período y se consulta por fechas → RANGE (fecha), permite descartar la partición vieja casi gratis. Pero si el patrón real es "todos los datos de una empresa se consultan/mantienen juntos" (coherente con el RLS del ADR 0002) el LIST (empresa_id) aísla por cliente. Rango y lista no son intercambiables: la columna y el patrón de acceso deciden. → se elige en el ADR del dominio.

Particionar vs solo indexar. No confundir particionado con índice: una tabla de 50 mil filas con un buen índice no necesita particionarse; el particionado paga solo con decenas de millones de filas o con requisito de descartar períodos viejos. Indexar es lo primero, particionar la excepción justificada con métricas.

Consecuencias

  • Toda tabla nueva debe cumplir esta nomenclatura o el PR se rechaza.
  • El mapeo de las 69 tablas de 5+ áreas (ADR 0003) debe resolver esquema y dueño usando estas reglas antes de escribir el código de cada dominio.
  • El esquema del servicio obliga a respetar la frontera: svc-operaciones solo migra y solo escribe en erp.operaciones.
  • Queda pendiente (fuera de este ADR, a decidir en el ADR de mensajería) la estructura exacta de las tablas de outbox/eventos, que aquí solo se referencian con trace_id.