Skip to content

Calidad de datos — gotchas confirmados

Todo lo de aquí se probó con queries contra la base. No re-descubrirlo dos veces: si algo cambia, actualizar este archivo.

Consolidado de exploraciones 2026-07-22 a 2026-08-18.

Lo que está bien

La integridad referencial es impecable. Cero huérfanos en las relaciones críticas verificadas:

Verificación Huérfanos
Venta_Encabezado sin Cliente 0
Venta_Encabezado sin Sucursal 0
Movimiento sin Producto 0
Venta sin Venta_Encabezado 0

PKs declaradas en prácticamente todas las tablas de negocio.


Estados

Es_Cve_Estado NO usa 'ACTI'

El catálogo Estado tiene 66 valores de 2–3 caracteres y ninguno es ACTI (verificado: SELECT COUNT(*) FROM Estado WHERE Es_Cve_Estado='ACTI' → 0). Filtrar por 'ACTI' devuelve cero filas siempre.

Valores reales: AC ACTIVO · BA BAJA · CA CANCEL PR. INIC. · FA FACTURADO · AP APLICADO · PA PAGADO · PD PENDIENTE · CE CERRADO · AU AUTORIZADO · EN ENVIADO · AB ABIERTO · CO CONFIRMADO…

El significado de "activo" cambia por tabla

No hay un filtro universal:

Tabla Distribución real
Venta_Encabezado FA 521,778 · CA 32,064 · AC 1,092
Factura_Encabezado AC 146,744 · AP 22,577 · CA 5,706 · PXC 4
Cliente AC 41,884 · BA 167
Producto AC 14,565 · BA 12,794
Consumo_Interno AC / CA
Gasto_Registro el reporte nativo excluye CA y PXA

Regla práctica: el criterio robusto es excluir cancelados (!= 'CA'), no incluir activos (= 'AC'). Es lo que hacen los modelos de dbt. Siempre validar antes:

SELECT Es_Cve_Estado, COUNT(*) FROM <tabla> GROUP BY Es_Cve_Estado ORDER BY 2 DESC;

Casi la mitad del catálogo de productos está de baja

12,794 de 27,359 en BA. Cualquier conteo de "productos" sin filtrar duplica la cifra real.

El estado de detalle no hereda el de cabecera (ZTRV_Solicitud_Material_Detalle)

ZTRV_Solicitud_Material.Es_Cve_Estado (cabecera) no determina el estado de cada línea. Cada renglón de ZTRV_Solicitud_Material_Detalle trae su propio Es_Cve_Estado, independiente entre sí y de la cabecera. Ejemplo real, folio 05-0066443 (cabecera en PR):

Línea Producto Es_Cve_Estado
0001 CANAL U DE 4" CE
0002 SOLERA DE 3" X 5/16" AB
0003 CINTA REFLEJANTE 3M AC

Cualquier pantalla o reporte que muestre "solicitudes en estado X" por pestaña (p. ej. las pestañas AC/AU/AB/PR de ZTRV098 "Control de Solicitudes de material v3") probablemente filtra por el estado de la línea, no solo por el de la cabecera. No asumir que basta con filtrar la cabecera. Ver Solicitud de material.

Cuantificado 2026-08-21 con un join real cabecera↔detalle sobre todo el histórico: la divergencia no es un caso raro, es el patrón dominante. La combinación más frecuente de todas (129,055 líneas / 69,593 folios) es cabecera CE (cerrada) con detalle AC (activa) — más frecuente que cabecera=detalle=CE (66,370 líneas, la combinación que "coincide"). Por eso fct_documento_trazabilidad expone solicitud_estado_encabezado y solicitud_estado_detalle como dos columnas separadas en vez de una sola — ver Warehouse.

Para contraste, el mismo tipo de divergencia no aplica a Compra_Encabezado↔Compra: de 150,727 líneas, 150,726 coinciden exactamente con su cabecera — ahí sí es seguro usar solo el estado de cabecera.


Joins que parecen obvios pero son falsos

❌ Requisicion_Compra ↔ Orden_Compra por (Rc_Folio=Oc_Folio, Rc_ID=Oc_ID)

Orden_Compra.Rc_ID sugiere fuertemente una FK. El join naive matchea 33,238 de 81,336 requisiciones (41 %) — parece razonable. Pero al validar coherencia de fecha (Oc_Fecha >= Rc_Fecha), 83 % de los matches tienen la fecha invertida, en algunos casos por años.

Es colisión de numeración de folio: ambas tablas usan el patrón SS-NNNNNNN por sucursal y los IDs bajos coinciden por casualidad. No usar este join.

Existe un puente indirecto validado al 99.9 % vía ZTRV_Presupuesto_Solicitud_Cambio.Sm_Folio compartido, pero cubre solo ~41 % de las requisiciones formales y solo desde 2024-03-31.

✅ La ruta correcta: patrón polimórfico _Tabla/_Documento

Exploración 2026-08-13. Requisicion_Compra y Orden_Compra siguen el mismo patrón polimórfico que ZTRV_Apartado (más abajo) — no hay que reconstruir el link por coincidencia de folio, ambas tablas ya traen el campo diseñado para eso:

  • Requisicion_Compra.Rc_Tabla/Rc_Documento puede apuntar directo a 'ZTRV_Solicitud_Material' (Rc_Documento = Sm_Folio). Confirmado sin fan-out: 0 combinaciones (Rc_Folio, Pr_Cve_Producto) con más de un Rc_Documento distinto.
  • Orden_Compra.Oc_Tabla/Oc_Documento tiene dos rutas hacia la solicitud original:
  • directa: Oc_Tabla='ZTRV_SOLICITUD_MATERIAL', Oc_Documento=Sm_Folio.
  • indirecta (cuando la OC nació de una requisición formal): Oc_Tabla='REQUISICION_COMPRA', Oc_Documento=Rc_Folio — hay que volver a Requisicion_Compra (con Rc_Tabla='ZTRV_Solicitud_Material' y mismo Pr_Cve_Producto) para llegar al Sm_Folio.
-- ruta directa
FROM Orden_Compra oc
INNER JOIN ZTRV_Solicitud_Material sm
        ON sm.Sm_Folio = oc.Oc_Documento AND oc.Oc_Tabla = 'ZTRV_SOLICITUD_MATERIAL'

-- ruta indirecta (via requisicion)
FROM Orden_Compra oc
INNER JOIN Requisicion_Compra rc
        ON rc.Rc_Folio = oc.Oc_Documento AND oc.Oc_Tabla = 'REQUISICION_COMPRA'
       AND rc.Pr_Cve_Producto = oc.Pr_Cve_Producto AND rc.Rc_Tabla = 'ZTRV_Solicitud_Material'

Catálogo Es_Cve_Estado de estas dos tablas (propio de cada una, no relacionado al de ZTRV_Solicitud_Material):

Tabla Valor Significado
Requisicion_Compra AC activa/pendiente (aún no se generó una OC)
Requisicion_Compra RCT ya se convirtió en Orden_Compra — mayoritario (13,604)
Requisicion_Compra CA/CE cancelada/cerrada
Orden_Compra AC vigente — ojo: no distingue "a tiempo" de "atrasada" (ver más abajo)
Orden_Compra RCT/RCP recibida total/parcial
Orden_Compra CA cancelada

⚠️ Orden_Compra.Es_Cve_Estado='AC' no equivale a "pendiente de entregar a tiempo": hay órdenes AC con Oc_Fecha_Entrega de hasta 2020, nunca cerradas ni canceladas — deuda de datos, no seguimiento real. Para saber si una orden sigue vigente y a tiempo, filtrar además Oc_Fecha_Entrega >= HOY. Detalle completo (con el caso de uso real: pestañas PR/APG de ZTRV098) en Solicitud de material.

❌ Compra_Encabezado.Co_Folio = Orden_Compra.Oc_Folio

Mismo patrón de colisión — 77 % de fechas invertidas.

✅ La dirección correcta: Compra_Encabezado.Co_Documento = Orden_Compra.Oc_Folio

Con Co_Tabla='ORDEN_COMPRA'. Este sí es el campo polimórfico diseñado para el enlace. Confirmado: 40,312 Compra_Encabezado con Co_Tabla='ORDEN_COMPRA', 95,117 matches (el fan-out es esperado: una OC puede recibirse en partes y tiene múltiples líneas).

❌ ZTRV_Apartado.Ap_Documento no es Sm_Folio cuando Ap_Tabla='COMPRA'

ZTRV_Apartado (registra apartados de inventario contra una solicitud de material) sigue el mismo patrón polimórfico Ap_Tabla/Ap_Documento que el resto del ERP — pero además trae una columna dedicada Sm_Folio que siempre apunta a la solicitud de material original, sin importar qué haya en Ap_Tabla. Cuando Ap_Tabla='COMPRA', Ap_Documento trae el folio de la orden de compra, no el de la solicitud. Confirmado con folio real:

Ap_Tabla='COMPRA', Ap_Documento='05-0025529'  (folio de compra)
Sm_Folio='05-0056696'                          (la solicitud real)

Un query que una por Ap_Documento esperando volver a la solicitud pierde silenciosamente todos los apartados originados desde compra — sin error, solo resultados incompletos. En una validación contra un baseline exportado de pantalla (2026-08-13), este error de join dejó fuera ~37 % de los folios esperados (188 de 510) hasta corregirlo.

✅ Usar siempre Sm_Folio para volver a la solicitud

FROM ZTRV_Apartado ap
INNER JOIN ZTRV_Solicitud_Material sm ON sm.Sm_Folio = ap.Sm_Folio   -- correcto
-- NO: ON sm.Sm_Folio = ap.Ap_Documento

ZTRV_Apartado.Es_Cve_Estado es un campo de estado propio de la tabla, no relacionado con el Es_Cve_Estado de ZTRV_Solicitud_Material ni el de su detalle. Valores observados: SUR (surtido, mayoritario), CA (cancelado), CE (cerrado), AC (activo) — para apartados vigentes, filtrar ap.Es_Cve_Estado = 'AC'. Columnas relevantes adicionales: Ap_Cantidad_Control_1 (cantidad apartada), Ap_Consumido_Control_1 (cantidad ya consumida) — en la muestra explorada, para folios con Es_Cve_Estado='AC', Ap_Consumido_Control_1 siempre fue 0 (no confirmado que sea invariante).

❌ Poliza_Detalle.Pd_Referencia = Gr_Folio sin aislar la póliza real en CONSUMO_INTERNO

Exploración 2026-08-18 (layout-gastos). Poliza_Detalle.Pd_Referencia es el único campo que liga una línea de póliza de vuelta a Gasto_Registro, pero es ambiguo de dos formas distintas:

  1. Colisiona entre años: es solo el número de folio, sin año ni sucursal — 3,179 referencias distintas colisionan entre 2020-2026 en toda la tabla. Por eso nunca se hace GROUP BY Pd_Referencia global sin pasar antes por Poliza_Control (Pc_Documento = gr.Gr_Folio) fila por fila.
  2. CONSUMO_INTERNO genera DOS pólizas paralelas por folio, bajo el mismo Pc_Documento, cada una con su propia línea Pd_Referencia = Gr_Folio:
  3. movimiento de inventario — cuenta 10500.012.003 ("Salida Por Consumo Interno"), raíz de grupo contable H (Cuentas de Orden/memo).
  4. reconocimiento de gasto real — cuenta variable (ej. 6200.001.003.001 "Combustible", o 2120.010.xxx.002 "Gastos A Cuenta De Costo Estandar"), raíz F (Gastos) o sin grupo con esa descripción.

Un join que suma el Cargo de ambas líneas (SUM(Pd_Importe) WHERE Pd_Tipo=1 AND Pd_Referencia=Gr_Folio, sin más filtro) da un Cargo total ≈ 2x el importe nativo del folio, con Abono siempre en 0 — no es partida doble normal, es la duplicación de las dos pólizas. Confirmado con folio real:

folio 05-0174493, importe nativo $179.47
  Pl_Folio 0000472906 -- Cargo 6200.001.003.001 "Combustible" $179.47        <- gasto real
  Pl_Folio 0000472693 -- Cargo 10500.012.003 "Salida Por Consumo Interno" $179.47  <- inventario, memo

✅ Aislar la póliza de gasto real (dos formas, mismo resultado exacto)

Por cuenta contable (mismo filtro que ya usaba el caso especial 3 de la conciliación Cargo/Abono para Abono en otros orígenes, aplicado aquí también a Cargo):

SUM(CASE WHEN pd.Pd_Tipo = 1 AND (
        ga.raiz = 'F'
     OR (ga.raiz IS NULL AND LEFT(pd.Cc_Cve_Cuenta_Contable, 1) = '6')
     OR (ga.raiz IS NULL AND LOWER(cc.Cc_Descripcion) LIKE '%gastos a cuenta de costo estandar%')
    ) THEN pd.Pd_Importe ELSE 0 END)

Por Poliza.Pl_Comentario (validado antes en el proyecto consumo_interno_trazabilidad para el mismo problema — más robusto, no depende de adivinar por código de cuenta):

AND pd.Pd_Tipo = 1
AND UPPER(pl.Pl_Comentario) LIKE '%CONSUMO INT%'
AND UPPER(pl.Pl_Comentario) NOT LIKE 'CUENTAS DE ORDEN%'
AND UPPER(pl.Pl_Comentario) NOT LIKE 'CTS ORDEN%'
AND UPPER(pl.Pl_Comentario) NOT LIKE 'CUENTA ORDEN%'

Ambos filtros dan el mismo total exacto ($6,388,627.47, enero 2026) — confirman que identifican el mismo conjunto de líneas por caminos distintos. Resultado: 99.12% de conciliación (2,944/2,970 folios, enero 2026); los 26 folios restantes no tienen ninguna póliza de gasto real (solo la de inventario) — hueco de datos real, no de filtro. Detalle completo en layout-gastos.

Por qué el Abono de la póliza de gasto real "sale en 0" al filtrar por Pd_Referencia

Exploración 2026-08-24 (consumo-interno-fifo round2). No es que la póliza de gasto real no tenga Abono — sí lo tiene, y es una cuenta de Activo de almacén (1140.010.XXX.007 "Salida Consumo Interno", una por sucursal/almacén). Lo que pasa es que esa línea de Abono no lleva Pd_Referencia: está agregada a nivel de almacén para toda la póliza consolidada del día, no atada a un folio de Gasto_Registro individual. Cualquier query que aísle líneas con Pd_Referencia = Gr_Folio (como el filtro de arriba) solo puede ver la línea de Cargo — el Abono real existe, pero solo aparece si se trae el Pl_Folio completo sin filtrar por referencia.

Confirmado con un folio real: Pl_Folio 0000124940 (póliza de gasto real consolidada de un día) trae ~60 líneas de Cargo (una por cada Gr_Folio del día, cada una con su Pd_Referencia) contra ~14 líneas de Abono (1140.010.XXX.007, una por almacén, sin Pd_Referencia) — Cargo y Abono suman exacto ($111,264.33 = $111,264.33), es partida doble real, solo que no 1:1 por documento.

La póliza de inventario/memo (10500.012.003 Cargo / 10600.012.003 Abono "...Contra") es distinta y sí trae Pd_Referencia en ambas líneas — esa sí se puede aislar por folio individual.

❌ Aislar la póliza real no basta: la rama de capitalización de activo fijo de CONSUMO_INTERNO no usa Pd_Referencia = Gr_Folio

Exploración 2026-09-08 (layout-gastos, a partir de una pregunta de usuario sobre un folio concreto). Con la póliza de gasto real ya aislada (filtro de arriba), un join adicional Pd_Referencia = Gr_Folio sigue dejando fuera en silencio (Cargo=Abono=0, sin error) un subconjunto de folios que sí tienen póliza contabilizada: los que capitalizan a activo fijo (cuenta 1210.xxx).

Causa, confirmada leyendo Poliza_Configuracion_Detalle directo (no por inferencia sobre los datos, ver configuracion-polizas, sección "Addendum 2026-09-08"): dos configuraciones de Gasto_Registro (0295 2018-ago 2021 y 0414 su sucesora vigente, las que cubren el grupo de Tipo_Gasto de activo fijo — 0181-0188, 0192, 0246) tienen Pcd_Referencia = Gasto_Registro.Gr_Referencia, no Gr_Folio. Es un campo real de Gasto_Registro, distinto del folio:

folio 07-0082044, Gr_Referencia = '445'
  Poliza 0000472949 -- Cargo 1210.002.002.001 "Maquinaria Y Equipo" $19,156.00, Pd_Referencia = '445'

Un join Pd_Referencia = Gr_Folio nunca encuentra esta línea ('445' ≠ '07-0082044') — el folio aparece con póliza real contabilizada pero como "sin match operativo" en cualquier reporte que asuma referencia=folio de forma universal.

Segundo hallazgo, del lado del origen operativo: la Pc_Relacion de estas 2 configuraciones tampoco parte de Gasto_Registro_Documento/Gasto_Registro_Control (la fuente que usan todas las reconciliaciones de este proyecto) — parte de Consumo_Interno/Consumo_Interno_Ceco, ligadas por Gasto_Registro.Gr_Documento = Consumo_Interno.Ci_Folio, con importe Ci_Costo_Importe * Cic_Factor. Coincide con Grc_Importe en los casos verificados, pero no está confirmado que coincida siempre — es la fuente que de verdad alimenta la póliza, no la que se usa hoy para reconciliar.

Complicación adicional al usar Gr_Referencia como llave: no es único dentro de una póliza — una póliza puede consolidar 2+ folios de Gasto_Registro bajo el mismo código de referencia (medido: 8 de 29 pólizas de esta rama en enero-marzo 2026). Se resuelve con la misma técnica de rank-pairing (ordenar ambos lados por importe dentro de (Pl_Folio, Referencia) y emparejar por posición) ya usada en el resto del proyecto para Grc_ID↔Poliza_Detalle.

Resultado tras corregir el join: CONSUMO_INTERNO sube de 99.35% a 99.81% de conciliación a nivel folio (enero-marzo 2026), 45/47 folios de esta rama recuperados; quedan 2 residuales (folios que reparten el gasto en más de un centro de costo, no cubiertos por el rank-pairing simple sin CECO en la llave). Aplicado en conciliacion-master/layout-gastos/12_reporte_base_ceco_consumo_interno.py y en producción (streamlit-reportes/contabilidad/layout_gastos_ceco_lib.py, CONT-1/CONT-2). Detalle completo en conciliacion-master/layout-gastos/PROGRESS.md, entrada 2026-09-08.

Lección, refuerza la de Compra_Indirecto más abajo: cuando un join "por inferencia" (Pd_Referencia = Gr_Folio, válido para la mayoría de las configuraciones) no cuadra, la fuente de verdad es Poliza_Configuracion_Detalle.Pcd_Referencia/Pcd_Relacion, no adivinar el patrón por prueba y error sobre los datos.

❌ Poliza.Pl_Tabla / Pl_Documento vienen vacíos — el origen real está en Poliza_Control

Exploración 2026-09-02 (poliza-explor). Poliza trae su propio par polimórfico Pl_Tabla/Pl_Documento (mismo patrón de convenciones), lo que sugiere que ahí está el enlace al documento de origen. Confirmado con muestra real de enero 2026: ambas columnas vienen siempre vacías, sin excepción. El enlace real vive en la tabla puente Poliza_Control (Pl_Folio → Pc_Tabla + Pc_Documento), que es la que usa layout-gastos para GASTO_REGISTRO y la que hay que usar para cualquier otro origen.

Relación 1:N en ambos sentidos: una póliza consolidada agrupa muchos documentos del mismo Pc_Tabla (ej. todos los cheques de un día bajo una sola Pl_Folio — aunque para Cheque específicamente la mediana es 1:1, ver abajo), y ocasionalmente el mismo Pc_Documento aparece bajo más de un Pl_Folio — en los casos revisados (Cheque, Compra_Indirecto) siempre resultó ser el par cancelada→activa del mismo folio, no un caso de negocio nuevo; se resuelve con el filtro estándar Es_Cve_Estado <> 'CA' sobre Poliza.

❌ Cheque: Poliza_Detalle.Pd_Referencia no liga de forma confiable al folio del cheque

Exploración 2026-09-02 (poliza-explor). Para pólizas de origen Pc_Tabla='Cheque', lo esperable es que la línea de Abono (banco) tenga Pd_Referencia = Pc_Documento (el folio del cheque) — pero en 812 de 1,128 pólizas activas de enero 2026 (72%), Pd_Referencia trae en su lugar Cheque.Ch_Referencia (la referencia externa del pago, ej. el folio de factura del beneficiario), no el folio del cheque.

Solución robusta que no depende de Pd_Referencia: aislar la línea de Abono por monto, dentro del mismo Pl_Folio:

JOIN Poliza_Detalle pd
    ON pd.Pl_Folio = plc.Pl_Folio
   AND pd.Pd_Tipo = 2                              -- Abono
   AND ABS(pd.Pd_Importe - ch.Ch_Importe) <= 1      -- match por monto, no por referencia

Validado sin ninguna ambigüedad (ningún folio con más de una línea candidata) en 1,128 pólizas de enero 2026 y 3,372 de Q1 2026 completo — 100% de cuadre en ambos rangos. Contraste: Recibo_Pago (mismo tipo de origen, tabla Rp_*) sí trae Pd_Referencia = Rp_Folio de forma directa y confiable — la inconsistencia es específica de Cheque, no generalizable.

❌ Compra_Indirecto comparte el patrón de doble póliza de CONSUMO_INTERNO, pese a no tener sub-orígenes propios

Exploración 2026-09-02 (poliza-explor). Compra_Indirecto.Ci_Tabla viene siempre vacío — señal que, tomada sola, sugiere un origen simple sin casos de negocio (como sí ocurre con Cheque/Recibo_Pago). Es engañosa: cada Ci_Folio genera dos pólizas activas paralelas bajo el mismo Pc_Documento, mismo patrón ya documentado arriba para CONSUMO_INTERNO:

  1. Póliza "real" (Pl_Comentario tipo INDIRECTOS EN COMPRAS NACIONAL...) — Cargo a cuenta de almacén/costo (ej. 1140.001.001.003), un solo renglón por Ci_Folio con el total sumado: Cargo = SUM(Ci_Precio_Descontado_Importe) de todos los Ci_ID de ese folio.
  2. Póliza "cuentas de orden" (Pl_Comentario tipo CUENTAS ORDEN INDIRECTOS EN COMPRAS...) — Cargo a cuenta memo raíz H (ej. 10500.012.011), un renglón por cada Ci_ID (no sumado), mismos importes a nivel línea.

Filtro para aislar la póliza real: UPPER(pl.Pl_Comentario) NOT LIKE '%CUENTAS ORDEN%' (mismo patrón de Pl_Comentario ya usado arriba para CONSUMO_INTERNO). Validado 100% (5/5 documentos) en enero 2026 — volumen bajo, no se corrió contra un rango más amplio. Pendiente: nunca se validó el lado Abono de ninguna de las dos pólizas.

Causa raíz identificada 2026-09-03 (configuracion-polizas): no es una rareza de Compra_Indirecto ni de CONSUMO_INTERNO. Son dos configuraciones de póliza distintas sobre la misma Pc_Tabla, una marcada (CUENTAS DE ORDEN) en su Pc_Descripcion, ambas con Pc_Aplicacion_Automatica = 'SI': las dos corren y las dos producen póliza. Hay al menos un caso triple (0454/0455/0456, conversión de producto). El indicador correcto para anticipar el patrón en un origen nuevo no es la columna Xx_Tabla, es contar cuántas configuraciones activas apuntan a esa Pc_Tabla:

SELECT Pc_Tabla, COUNT(*) FROM Poliza_Configuracion
WHERE Es_Cve_Estado = 'AC' GROUP BY Pc_Tabla HAVING COUNT(*) > 1

Filtro más robusto que Pl_Comentario: por Pl_Configuracion explícito, o descartando Pc_Descripcion LIKE '%CUENTAS DE ORDEN%'.

Lección para clasificar otros orígenes: Xx_Tabla propio siempre vacío es una condición necesaria pero no suficiente para considerar un origen "simple" — hay que revisar también si existe un par de Pl_Comentario tipo "real" vs. "cuentas de orden"/"memo" bajo el mismo Pc_Documento.


Personalizaciones ZTRV_Orden_Compra_* sin uso real (o de otro propósito)

Existen tres tablas personalizadas con nombre que sugiere ser catálogo/detalle de Orden de Compra. Ninguna es la fuente correcta para consultas de negocio sobre OC — esa sigue siendo Orden_Compra (sin prefijo, del ERP estándar), que ya es tabla a nivel línea (trae Pr_Cve_Producto como columna propia, confirmado vía el .asp nativo de RPCO001) — no hace falta ni existe un Orden_Compra_Detalle separado.

Tabla Filas (.207) Qué es en realidad
ZTRV_Orden_Compra_Requisicion 0 Vacía. Aunque tiene columnas que sugieren ser el puente correcto Orden_Compra ↔ Requisicion_Compra (Oc_Folio, Rc_Folio, Rc_Id), no tiene ninguna fila cargada.
ZTRV_Orden_Compra_Requisicion_Ceco 0 Vacía también.
ZTRV_Orden_Compra_Ceco 20,502 Sí tiene datos, pero es reparto por centro de costo a nivel línea (Oc_Folio, Oc_Id, Cc_Cve_Centro_Costo, Ocrc_Importe, Ocrc_Porcentaje). No tiene columna de fecha propia — para filtrar por periodo hay que unir contra Orden_Compra.Oc_Fecha. Fan-out esperado: múltiples filas por folio (una por línea × centro de costo).

Fácil confundirlas con el puente falso Rc_ID de Orden_Compra (ver Joins que parecen obvios pero son falsos) y asumir que alguna resuelve el enlace — validar con COUNT(*) antes de usarlas.

Filtro validado para "Órdenes de Compra activas"

Confirmado por reconciliación contra el reporte nativo RPCO001 (vía Metabase): 310 folios vs 309 esperados, diferencia de 1 atribuible a timing del corte ("hasta hoy").

SELECT COUNT(DISTINCT Oc_Folio) AS n
FROM Orden_Compra
WHERE Es_Cve_Estado <> 'CA'
  AND Oc_Fecha >= '20260801'
  AND Oc_Fecha < '20260819';
  • Es_Cve_Estado <> 'CA', no = 'AC' — consistente con la regla general de Estados, y confirmado específicamente para esta tabla vía la lógica real del .asp de RPCO001.
  • El conteo de negocio es por folio distinto (COUNT(DISTINCT Oc_Folio)), no por filas crudas — como Orden_Compra es a nivel línea, contar filas sobreestima el número de "órdenes".
  • No hizo falta filtrar por Empresa/Sucursal/Comprador para reconciliar este número — si una pregunta de negocio distinta lo requiere, no está validado aquí.

RPCO001 es patrón A (Reportes nativos) — el .asp completo trae más columnas y lógica (join contra Compra para "entregado vs ordenado", moneda, tipo de cambio) no validadas en esta sesión por venir el archivo truncado al copiarlo. Para replicar el reporte completo, volver a extraer RPCO001.asp íntegro y confirmar en particular las condiciones exactas del LEFT JOIN Compra (columnas de color/talla no confirmadas contra el esquema real).


Comparar fechas con separadores puede invertir día/mes (DATEFORMAT)

Una comparación aparentemente inofensiva contra una columna datetime:

WHERE Oc_Fecha >= '2026-08-01' AND Oc_Fecha < '2026-08-19'

puede fallar con un error de conversión, o peor todavía, puede NO fallar y devolver resultados silenciosamente incorrectos si el día del mes es ≤ 12.

Causa raíz: con @@LANGUAGE = 'Español' (default de sesión en .207/TRIVASADB, collation Modern_Spanish_CI_AS), el DATEFORMAT de sesión es dmy (día-mes-año), no mdy — el que casi todos asumen implícitamente al escribir 'YYYY-MM-DD'. Confirmarlo en cualquier sesión sospechosa:

SELECT
    @@LANGUAGE AS idioma_sesion,
    @@DATEFIRST AS datefirst,
    (SELECT dateformat FROM sys.syslanguages WHERE langid = @@LANGID) AS dateformat_default_idioma;

Con dmy activo, un literal como '2026-08-19' se interpreta con los segmentos de día y mes potencialmente invertidos:

  • Día > 12 (ej. '2026-08-19', día=19): no existe "mes 19" → error 22007 explícito. Molesto, pero al menos avisa.
  • Día ≤ 12 (ej. '2026-08-01', día=01): el intercambio día/mes no truena — ambos números son meses válidos — y la query corre "exitosamente" pero filtrando por la fecha equivocada, sin ningún error visible. Este es el caso peligroso.

Regla: usar siempre el formato 'YYYYMMDD' (sin separadores) al comparar contra columnas datetime/date. Es el único literal de fecha que SQL Server interpreta de forma inequívoca sin importar DATEFORMAT/LANGUAGE de la sesión — garantía del estándar, no convención de este proyecto.

-- ❌ Riesgoso: depende del DATEFORMAT de la sesión
WHERE Oc_Fecha >= '2026-08-01' AND Oc_Fecha < '2026-08-19'

-- ✅ Seguro: inequívoco en cualquier sesión
WHERE Oc_Fecha >= '20260801' AND Oc_Fecha < '20260819'

Nota sobre herramientas de exploración ad-hoc (Harlequin/hsql): su adaptador ODBC no acepta hooks de "SQL de inicialización", solo el connection string. Se puede forzar Language=us_english; en la cadena de conexión (da DATEFORMAT mdy, a costa de mensajes de error del servidor en inglés) — se evaluó y se descartó a favor de la regla 'YYYYMMDD' de arriba, más robusta porque no depende de la configuración de la conexión en turno.

Ámbito confirmado: solo .207/TRIVASADB. No se verificó si .200/.205/TRIVASADB3 tienen el mismo @@LANGUAGE de sesión por default — revisar antes de asumir que aplica igual ahí.


Fechas sentinela — no son NULL

  • ZTRV_Estado_Solicitud.Fecha_Fin = '2000-01-01' cuando el tramo sigue abierto. No es NULL. Filtrar con Fecha_Fin > '2001-01-01' antes de calcular cualquier duración.
  • ZTRV_Solicitud_Material.Sm_Fecha_Cierre = '2001-01-01' para "no cerrado" — valor distinto al anterior.

Cada tabla parece tener su propio placeholder de "fecha no aplica". No asumir que es universal.


Cursores incrementales

Fecha_Ult_Modif puede existir y estar vacía

ZTRV_Solicitud_Agenda_Logistica tiene la columna, pero el 96 % de sus filas la trae NULL (136/142 en .200, 164/170 en .207). dlt descarta las filas cuyo cursor es NULL, así que el incremental cargaba 6 de 142.

Que la columna exista no basta:

SELECT COUNT(*) total, SUM(CASE WHEN Fecha_Ult_Modif IS NULL THEN 1 ELSE 0 END) nulos FROM <tabla>;

Fecha_Ult_Modif no siempre cambia después de crear

En Comprobante_Digital es idéntica a Fecha_Alta (muestreo de 20 filas, 2026-07-27): el registro se crea ya con el UUID timbrado y nunca se actualiza. Válido como cursor para esa tabla; no asumir que aplica igual a otras.


Duplicados y claves

  • ZTRV_SOLICITUD_MATERIA_DOCUMENTO: sin PK declarada y 31,583 grupos duplicados sobre (Sm_Folio, Sm_ID, Smd_Documento). No hay clave natural — merge no es opción, solo replace.
  • ZTRV_Estado_Solicitud: sin PK, 229 grupos duplicados sobre (Sm_Folio, Estado, Fecha_Inicio).
  • Reorden: algunos productos tienen registros duplicados — una fila con talla/color vacíos y otra con '00'/'00'. Dato sucio del ERP, no error de carga.
  • Consumo_Interno: mismo patrón '00'/'00' en Tl_Cve_Talla/Cl_Cve_Color.
  • PKs compuestas y anchas: Precio_Minimo tiene PK de 7 columnas, Existencia de 5. Al replicar con merge, declararla completa o se generan duplicados.

Estado_Activo = 'SI' no identifica un único estado vigente

En ZTRV_Estado_Solicitud hay folios con el mismo Fecha_Inicio repetido varias veces, todos con Estado_Activo='SI' (un folio observado con 11 filas idénticas). También coexisten estados con secuencia de fecha fuera de orden. Es captura duplicada/administrativa, no multi-estado real.

Deduplicar por (Sm_Folio, Estado, Fecha_Inicio, Fecha_Fin) antes de agregar, y no usar Estado_Activo='SI' como "estado actual" sin tomar el MAX(Fecha_Inicio) por folio.


Campos vacíos por cambio de proceso, no por error

Requisicion_Compra.Rc_Fecha_Autorizacion/Rc_Autorizo y Orden_Compra.Oc_Fecha_Autorizacion/Oc_Autorizo están 100 % vacíos (0/81,336 y 0/121,147).

No es que el dato no se capture: el proceso se mudó a ZTRV_Presupuesto_Autorizacion_Documento a partir de 2024-03-31. Los campos legacy quedaron muertos porque el proceso que los llenaba fue reemplazado. Ver Solicitud de material.

De forma análoga, el estado AB (ABIERTO) dejó de usarse después de 2024-11-18. Al comparar periodos pre/post, tratarlo como cambio de proceso, no como anomalía.


Basura de plantillas capturada como dato real

En ZTRV_Presupuesto_Autorizacion_Documento aparece al menos una fila con Pad_Documento='{FOLIO}' y Pad_Operador='{OPERADOR}' — literal, sin sustituir. Filtrar Pad_Documento <> '{FOLIO}' antes de cualquier JOIN o agregación.

Pad_Tabla tiene dos variantes de casing para la misma tabla

ZTRV_Presupuesto_Autorizacion_Documento.Pad_Tabla='ZTRV_Solicitud_Material' (61,841 filas) y 'ZTRV_SOLICITUD_MATERIAL' (25 filas) — mismo origen, casing distinto. Filtrar solo por uno de los dos pierde las 25 filas en silencio.

⚠️ No arreglar con upper(Pad_Tabla) = '...'. Sin estadísticas sobre el resultado de la función, Postgres estimó ~520 filas en vez de ~62,000 (100x de error) y eligió nested loop para los joins siguientes — una tabla que debía tardar <1 segundo tardó más de 4 minutos (confirmado 2026-08-14 construyendo int_solicitud_material_autorizacion en trivasa-bi-core). Usar una lista explícita en su lugar:

WHERE Pad_Tabla IN ('ZTRV_SOLICITUD_MATERIAL', 'ZTRV_Solicitud_Material')

Campos que no son catálogos cerrados

Cuenta_X_Pagar.Cxp_Tabla tiene ~75,000 valores distintos. Para nómina el patrón es GASTO_REGISTRO_NOMINA_CXP:<folio>, con el folio embebido en el valor. Nunca hacer GROUP BY Cxp_Tabla exploratorio sin TOP N; filtrar siempre por el valor explícito que interese.


Tablas con nombre casi idéntico

dbo.Estado_Solicitud (sin prefijo ZTRV_) existe con columnas casi iguales — incluido un typo, Fecha_Incio — pero está vacía. La tabla viva es ZTRV_Estado_Solicitud (con Fecha_Inicio bien escrito).

Fácil escribir mal el nombre y obtener 0 filas sin error. Validar con COUNT(*) antes de asumir que una tabla está vacía por diseño.


"NUMERO PARTE" en la pantalla de Solicitud de material NO es Pr_Numero_Parte

Confirmado 2026-08-21, construyendo la tabla de detalle de Solicitud de material para fct_documento_trazabilidad. Hay tres campos candidatos con nombre parecido, y solo uno es el que la pantalla nativa (ZTRV098) realmente muestra bajo la columna NUMERO PARTE:

Campo Qué es en realidad
Producto.Pr_Cve_Producto (producto_id) Es el correcto. Confirmado contra un folio real de pantalla: producto_id='0000037010' = "CINTA 20 MTS FIBRA DE VIDRIO", coincide exacto con el resto de la fila.
Producto.Pr_Clave_Corta (producto_clave_corta en dim_producto) Otro campo real de Producto, pero no es lo que la pantalla muestra — se asumió que sí en una sesión anterior, quedó mal etiquetado en _marts.yml hasta que se corrigió.
ZTRV_Solicitud_Material_Detalle.Pr_Numero_Parte Existe en la tabla de detalle (nombre más parecido al de la columna en pantalla), pero casi siempre viene vacío y tampoco es lo que se muestra.

Lección: un nombre de columna parecido al de la pantalla no garantiza que sea el campo correcto — verificar siempre contra un folio real exportado de pantalla, no solo por coincidencia de nombre.


Otros

  • raw.sucursal (41) y raw.almacen (465) SÍ están completos — verificado 2026-08-21 con query directa contra .205/TRIVASADB3 (vía SSH a ctunlinux): SELECT COUNT(*) FROM Sucursal → 41 (exacto) y SELECT COUNT(*) FROM Almacen → 464 (vs 465 en raw, diferencia de 1 por timing). La nota anterior en Warehouse afirmaba un gap de raw.almacen contra 928 filas en origen — ese 928 era un dato incorrecto, corregido.
  • Empresas fantasma: 0097, 0098, 0099 (BACKUP *) tienen 5 sucursales entre las tres y 0 ventas. Excluirlas.
  • Sucursales marcadas en el nombre: hay descripciones como KANTUNILKIN (NO UTILIZAR) y duplicadas (0005 FABRICA en AC, 0006 FABRICA en BA). Agrupar por Sc_Cve_Sucursal, no por Sc_Descripcion.
  • opc_* duplican tablas base — sumarlas junto a las originales duplica cifras.
  • Requisicion_Documento (536 filas) es un agregador administrativo para compras recurrentes de volumen (diésel, uniformes), no parte del camino crítico.
  • Los borrados físicos no dejan rastro: la auditoría Audit_Delete_DML no tiene especificación ligada a TRIVASADB3. Las bajas lógicas sí (Fecha_Baja, Es_Cve_Estado).

No toda Orden_Compra/Requisicion_Compra nace de una solicitud de material

Confirmado 2026-08-21 con conteos reales sobre origen_tabla (Oc_Tabla/Rc_Tabla) contra raw.orden_compra/raw.requisicion_compra, construyendo fct_documento_trazabilidad (Warehouse).

Es fácil asumir que el flujo de compras siempre arranca en una solicitud de material (Solicitud de material → Requisición → Orden de compra, ver Solicitud de material) y que basta con seguir el patrón polimórfico hacia atrás para siempre llegar a un Sm_Folio. No es así: la mayoría de las órdenes de compra y requisiciones del ERP no pasan por ese flujo en absoluto.

Distribución real de origen_tabla:

Tabla origen_tabla n Qué es
Orden_Compra REQUISICION_COMPRA 42,271 viene de una requisición formal
Orden_Compra (vacío) 18,021 sin origen registrado
Orden_Compra REQUISICION_DOCUMENTO 4,097 agregador de compras recurrentes de volumen, ver Otros
Orden_Compra ZTRV_SOLICITUD_MATERIAL 861 ruta directa desde la solicitud
Requisicion_Compra (vacío) 53,224 sin origen registrado — la mayoría
Requisicion_Compra ZTRV_Solicitud_Material 19,526 viene de una solicitud de material
Requisicion_Compra Resurtido 7,579 resurtido automático
Requisicion_Compra RESURTIDO 2,347 mismo resurtido automático, casing distinto — mismo gotcha de casing duplicado ya documentado para Pad_Tabla (Basura de plantillas)
Requisicion_Compra ORDEN_SERVICIO 745 viene de una orden de servicio, no de compras

Implicación práctica: de las 42,271 líneas de OC que sí vienen de una requisición, solo 19,526 de esas requisiciones trazan a su vez a ZTRV_Solicitud_Material — el resto queda sin solicitud de origen resoluble. Un modelo que intente "subir" desde cualquier OC/RC hasta una solicitud de material va a dejar solicitud_folio en NULL para la mayoría de las filas (~44% de las líneas de OC, ~86% de las de RC en fct_documento_trazabilidad) — es dato real, no un hueco de join. No asumir que un solicitud_folio vacío en ese contexto es un error de código.


al_cve_almacen no es llave por sí solo

Confirmado 2026-08-25, al agregar tests de dbt sobre raw.almacen: el mismo código (ej. "0001") se repite hasta 40 veces — una vez por cada sucursal que tiene un almacén con ese código corto. La llave real es compuesta: (sc_cve_sucursal, al_cve_almacen), verificada sin duplicados. No asumir que Al_Cve_Almacen identifica un almacén global.

mv_id (Movimiento) tampoco es llave de fila pese al nombre

Confirmado 2026-08-25: un solo valor de mv_id ("0001") aparece en 2.5 millones de filas de Movimiento. No es un id de renglón — parece ser un código corto (tipo/secuencia), aunque el nombre de columna sugiera lo contrario. Movimiento no tiene una llave de fila de una sola columna identificada todavía; para deduplicar o unir por fila específica hay que usar el surrogate _dlt_id que genera dlt en raw.movimiento, no mv_id.

load_reorden.py — fallo real por schema evolution en sucursal.z_rango_escaneo

Hallazgo del 2026-08-25, revisando monitoring.pipeline_runs (tracking nativo de dlt, ver docs/arquitectura/stack-bi.md en este mismo repo): la corrida de las 06:15 del cron load_reorden.py (que carga reorden + producto + 6 catálogos, incluido sucursal) falló con SchemaUpdateTerminalError — dlt no pudo aplicar un cambio de schema en sucursal porque la columna z_rango_escaneo ya tiene valores NULL en destino y el update de dlt entra en conflicto con eso. Efecto en cascada: como load_reorden.py también carga producto y otros catálogos en el mismo pipeline, todos quedaron sin actualizar ese día — explica los fail/warn de producto, almacen, sucursal vistos ese mismo día en check_raw_freshness.py/check_raw_volume.py. El mensaje completo de dlt (con la traza exacta) vive en monitoring.pipeline_runs.error_message — consultar ahí antes de reintentar nada:

select pipeline_name, script, started_at, error_message
from monitoring.pipeline_runs
where status != 'success'
order by started_at desc;

No se tocó load_reorden.py ni el schema de sucursal — está fuera del alcance de quien solo declara sources/staging en dbt. Queda para quien mantenga el pipeline de dlt: probablemente hace falta backfillear/limpiar los NULL de z_rango_escaneo en destino, o ajustar el modelo de dlt para que tolere la columna nullable.

⚠️ Poliza.Pl_Configuracion liga a la regla que generó la póliza — y los vacíos son pólizas manuales

Exploración 2026-09-03 (configuracion-polizas). Complementa el gotcha de Pl_Tabla/Pl_Documento vacíos: aunque esas dos columnas no sirven, Pl_Configuracion sí está poblado, en 334,374 de 337,868 pólizas (98.97%), y trae la clave de Poliza_Configuracion que la generó. Es más preciso que Poliza_Control.Pc_Tabla cuando la pregunta es por qué una póliza trae ciertas cuentas: da la variante exacta de la regla, no solo el módulo. Poliza_Control sigue siendo la vía correcta para ligar al documento.

Los 3,494 registros con Pl_Configuracion vacío no tienen tampoco ninguna fila en Poliza_Control (0 de 3,494). No son ruido ni carga de migración: son pólizas capturadas a mano por Contabilidad, ~500 al año de forma estable desde 2019, con capturista nombrado en Oper_Alta (IVALDEZ, JFERNANDEZ, JEROSA…). Cualquier reconciliación que parta de Poliza_Control las excluye en silencio — hay que decidir explícitamente si entran o no.

-- póliza manual: sin regla que la generó y sin documento origen
SELECT * FROM Poliza p
WHERE ISNULL(p.Pl_Configuracion,'') = ''
  AND NOT EXISTS (SELECT 1 FROM Poliza_Control c WHERE c.Pl_Folio = p.Pl_Folio)

⚠️ Una póliza descuadrada casi nunca llega a Poliza — no la busques ahí

Exploración 2026-09-03 (configuracion-polizas). El motor no genera la póliza si no cuadra. De ~40,000 pólizas de 2026, solo 4 están descuadradas en Poliza_Detalle, y 3 de ellas canceladas. Consecuencia para cualquier diagnóstico o detector: la póliza problemática es una que todavía no existe, así que auditar el resultado no sirve — hay que reejecutar la configuración contra los datos que la alimentan. Ese es el motivo de que el proceso manual de soporte pase por el botón "Generar script" de CT001. Reconstrucción del query desde las tablas de configuración: poliza-explor/scripts/05_huecos_ceco_configuracion.py.

⚠️ Las cuentas contables de gastos no están en la configuración: se resuelven en opc_Tipo_Gasto.Cuenta_ContableN

Exploración 2026-09-03 (configuracion-polizas). Los renglones de cargo de las configuraciones de Gasto_Registro no escriben una cuenta: eligen cuál de las ~14 columnas Cuenta_Contable1..14 de opc_Tipo_Gasto leer, en función del grupo de centro de costo. Los grupos 000001–000005 deciden por igualdad de grupo y son estables; el grupo 000006 (producción) se desglosa con listas IN de claves de CeCo cableadas a mano en Pcd_Condicion.

Un CeCo que no caiga en ninguna lista no genera renglón de cargo, pero sus contrapartes (abono a acreedores, impuestos) sí → la póliza descuadra. Es el modo de falla que soporte describe como "algún ceco que no se contempló", y fue la causa confirmada del único descuadre vivo de 2026 (póliza 0000478288, CeCo 000501 PLANTA CONCRETERA).

Variante silenciosa, peor: si la configuración es simétrica (mismos renglones de cargo y de abono, como las de reclasificación — config 0371), el CeCo descubierto pierde los dos lados a la vez. La póliza cuadra y la reclasificación simplemente no se hace; ninguna validación de balance lo detecta.

Cuidado al medir esto: extraer los CeCos por regex de las condiciones y restarlos del catálogo da muchísimos falsos positivos (69 configs "afectadas" vs. 2 reales), porque una config filtra además por grupo de tipo de gasto, proveedor y fecha. La prueba válida reconstruye el query completo y pregunta qué filas pasan las condiciones globales pero no caen en ningún renglón de cargo.

⚠️ Las cuentas de orden se identifican por la RAÍZ de la cuenta (10100-10600), no por el texto de la configuración

Confirmado 2026-09-10 (conciliacion-cfdi). Un mismo documento puede tener dos pólizas activas en paralelo: la real y una de cuentas de orden (valores ajenos / contingentes / de control), con el mismo importe. Quien sume Poliza_Detalle sin excluir las de orden cuenta el importe dos veces.

Filtrar por la descripción de la Poliza_Configuracion no basta: el catálogo usa al menos cinco redacciones distintas y las variantes abreviadas se escapan de un NOT LIKE '%CUENTAS DE ORDEN%':

Config Origen Descripción
0130 Cuenta_x_Pagar X CUENTA X PAGAR LIBRE (CTS ORDEN) ENE2018-MAY2020
0360 Cuenta_x_Pagar CUENTA X PAGAR LIBRE ( CUENTA DE ORDEN) 2020
0293 Gasto_Registro CONSUMO INTERNO MTTO CORRECTIVO 2018 (CUENT ORDEN)
0296 Gasto_Registro CONSUMO INTERNO ADIC Y MEJO 2018 EN AD(CTS ORDEN)
0235/0352 Nota_Credito_Proveedor NOTAS DE CREDITO PROVEED (CUENTA ORDEN) 2020 EN AD

El criterio estructural, independiente de la redacción: en Cuenta_Contable, las cuentas de orden son exactamente las de raíz de 5 dígitos, 10100 a 10600, agrupadas bajo Cc_Acumula = E.A (Valores Ajenos), E.B (Valores Contingentes) y E.C (De Control). Todas las cuentas reales tienen raíz de 4 dígitos.

-- excluye cualquier renglón de póliza posteado a cuentas de orden
AND pd.Cc_Cve_Cuenta_Contable NOT LIKE '10[1-6]00%'

Caso testigo: CFDI de BANCO SABADELL por $300,000 (folios 01-0094874/01-0094875), cada folio con la póliza real (config 0380, cuenta 1150.003.001) y la de orden (config 0360, cuenta 10500.011.001) por el mismo importe — sumaba $600,000.

⚠️ Confirmado desde otro ángulo: (Gr_Folio, Grd_ID) sí basta para reproducir el cargo exacto de Poliza_Detalle, sumando TODOS los Grc_ID

Exploración 2026-09-09 (conciliacion-cfdi, cuadrando cargo de póliza contra el Subtotal del CFDI, no el problema de layout-gastos). Complementa el hallazgo de layout-gastos de que "no existe llave real entre Grc_ID y Poliza_Detalle" (layout-gastos-ceco-cont-1.md): confirmado con datos reales que, aunque no hay llave 1-a-1 hacia una línea individual de Poliza_Detalle, la suma de todos los Grc_Importe de Gasto_Registro_Control para un mismo (Gr_Folio, Grd_ID) sí reproduce exacto el cargo total posteado (verificado en folio 01-0027091: 4 líneas de Grc_ID por prorrateo de CeCo, suma $2,746.64 == cargo real en Poliza_Detalle; y en 01-0035252, con 2 Grd_ID distintos para 2 CFDI del mismo folio, cada uno cuadra por separado). No resuelve la ambigüedad de a qué línea individual de Poliza_Detalle corresponde cada Grc_ID — pero sí confirma que agregar por (Gr_Folio, Grd_ID) es una llave suficiente cuando lo que se necesita es el total, no el desglose por CeCo. Detalle en conciliacion-cfdi/docs/hallazgos.md punto 15.

También se confirmó ahí que Comprobante_Digital.Cd_Documento para GASTO_REGISTRO nunca tiene más de un valor distinto por (Gr_Folio, Grd_ID) (los primeros 14 caracteres) sin importar si el formato capturado es de 14 u 18 — coincide con y corrobora el gotcha de longitud documentado abajo, desde el ángulo de cuadre contable en vez de parseo de XML.

✅ CORREGIDO — Grc_Importe "duplicado idéntico entre folios" era conversión de moneda, no un error de captura

Retractación. Una versión anterior de esta entrada (2026-09-09) reportaba como error de captura del cliente que 5 CFDI de BRIGGS EQUIPMENT tuvieran Grc_Importe exactamente igual, $45,060.3725. Es falso, verificado contra el dato vivo el 2026-09-10. Se conserva la entrada porque el modo de falla —confundir un artefacto de moneda con un error del cliente— es fácil de repetir.

Los 5 CFDI (folios 05-0178783/784/786/831/832, renta de montacargas, unidades y centros de costo distintos) son de USD 2,615.00 cada uno, y Grd_Tipo_Cambio es 17.2315 en los cinco: 2,615.00 x 17.2315 = 45,060.3725. El importe se repite porque el importe en dólares y el tipo de cambio se repiten — es la misma renta mensual facturada por unidad. Los 8 CFDI de BRIGGS de feb-2026 cuadran exacto una vez que se convierte con el tipo de cambio del documento.

La "duplicación simétrica del abono" tampoco era duplicación — y esto sí es un patrón reutilizable. Las referencias A370838-A370845 de la póliza 0000478228 aparecen dos veces cada una, pero en cuentas distintas, y las dos filas suman el pasivo correcto en pesos:

Cuenta Descripción A370839 A370838
2110.001.001.002 Proveedor Nacional Dollar 3,033.40 1,740.00
2110.001.001.004 Proveedor Nacional complementaria Dollar 49,236.63 28,242.81
suma = importe USD x 17.2315 52,270.03 29,982.81

Es decir: mpro parte el pasivo en moneda extranjera en dos renglones — uno con el importe tal cual en la divisa y otro "complementario" con la diferencia en pesos — de modo que la suma de ambos es la valuación en MXN al tipo de cambio del documento. El catálogo tiene 9 cuentas con este rol, no es un caso aislado:

1110.003.001.008.003  Banco Monex Cta.20252455 (Usd Cta Complementaria)
1110.003.001.008.005  Banco Monex Cta.20252455 (Eur Cta Complementaria)
2110.001.001.004      Proveedor Nacional complementaria Dollar
2110.001.001.005      Proveedor Nacional complementaria Euro
2110.001.002.003      Proveedores Extranjero Complementaria Dollar
2110.001.002.004      Proveedores Extranjero Complementaria Euro
2130.001.005.001.002 / 2230.001.005.001.002 / 2230.001.005.001.004  (arrendamiento, Dollar)

Consecuencia práctica: al leer un saldo o un movimiento de proveedores/bancos en moneda extranjera hay que sumar la cuenta y su complementaria. Tomar solo la cuenta base da el importe en divisa (no en pesos); tomar solo la complementaria da un número que no significa nada por sí solo; y tratar los dos renglones como duplicados —el error de la versión anterior de esta entrada— borra la mitad del pasivo.

Lo que sí sigue en pie del hallazgo original: la Poliza_Configuracion que genera el cargo (0450, renglón 0010 "CARGO A GASTOS MINA") es SUM(ABS(Grc_Importe)) agrupado por (Gr_Folio, Grd_ID), y reproduce fielmente lo que haya en Gasto_Registro_Control — la fórmula no transforma nada.

-- reproduce el renglón de cargo real: agrupa Grc_Importe por (Gr_Folio, Grd_ID)
SELECT gr.Gr_Folio, grd.Grd_ID, SUM(ABS(grc.Grc_Importe)) AS cargo
FROM Gasto_Registro gr
JOIN Gasto_Registro_Documento grd ON grd.Gr_Folio = gr.Gr_Folio
JOIN Gasto_Registro_Control grc ON grc.Gr_Folio = grd.Gr_Folio AND grc.Grd_ID = grd.Grd_ID
WHERE gr.Gr_Folio IN ('05-0178783','05-0178784','05-0178786','05-0178831','05-0178832')
GROUP BY gr.Gr_Folio, grd.Grd_ID

Detalle en conciliacion-cfdi/docs/hallazgos.md puntos 16 y 20.


Comprobante_Digital (CFDI) — cuatro gotchas confirmados, sesión 2026-09-05

Exploración de layout-gastos (scripts 15-17), tratando de ligar el XML/CFDI real a cada documento de Gasto_Registro. Los cuatro afectaban directamente el % de conciliación calculado — cada uno se descubrió porque el número no cuadraba con lo esperado, no por inspección preventiva del esquema.

Catálogo real de Cd_Tabla — solo GASTO_REGISTRO decodificado a fondo

Comprobante_Digital.Cd_Tabla (el lado polimórfico, ver Convenciones) tiene 14 valores reales, cada uno con su propia longitud de Cd_Documento — confirmado con GROUP BY Cd_Tabla, MIN/MAX(LEN(Cd_Documento)) sobre toda la tabla (~684K filas):

Cd_Tabla Filas LEN(Cd_Documento)
FACTURA 175,624 10–12
TRASLADO 148,997 10
GASTO_REGISTRO 96,872 14 o 18 — ver decodificación abajo
NOMINA 69,766 10
COMPROBANTE_PAGO 68,637 10–14
COMPRA 62,023 14
CHEQUE 28,946 14
NOTA_CREDITO 25,044 10
CUENTA_X_PAGAR 7,833 14
COMPRA_INDIRECTO 3,105 14
NOTA_CREDITO_PROVEEDOR 2,144 14
ANTICIPO_CXP 942 18
CONSTANCIA_RETENCION 733 10
CONTABILIDAD_ELECTRONICA 304 10

Solo GASTO_REGISTRO está decodificado y confirmado en este proyecto (ver abajo). Para el resto, la longitud es un dato observado, no una decodificación verificada — no asumir que el mismo patrón FOLIO(10) + subdocumento(4) aplica a COMPRA/CHEQUE/CUENTA_X_PAGAR/etc. sin confirmarlo con datos reales de cada uno (una nota suelta de un proyecto hermano, no verificada aquí, sugiere que para esos módulos el tramo de posiciones 11-14 es un consecutivo de comprobante por folio, no un sub-documento como Grd_ID — sin confirmar).

❌ Cd_Documento (para Cd_Tabla='GASTO_REGISTRO') tiene DOS formatos de longitud, no uno

El supuesto de partida era Cd_Documento = Gr_Folio (10 car.) + Grd_ID zero-padded (4) + un sufijo casi siempre fijo '0001' (4) = 18 caracteres. Filtrar LEN(Cd_Documento) = 18 parecía razonable — pero existe un segundo formato de 14 caracteres, Gr_Folio(10) + Grd_ID(4) sin el sufijo, que decodifica igual de limpio y es el mismo mecanismo, no ruido:

SELECT LEN(Cd_Documento) AS LEN_DOC, COUNT(*) n
FROM Comprobante_Digital WHERE Cd_Tabla='GASTO_REGISTRO'
GROUP BY LEN(Cd_Documento)
-- 14: 22,520 filas históricas   18: 74,352 filas históricas

Filtrar solo LEN=18 pierde ~23% de los XML ligados (441 de 1,510 documentos con XML, enero 2026 en solitario) — descubierto porque 17 folios de ORDEN_COMPRA que parecían "sin XML" en realidad sí lo tenían, solo que en el formato de 14. Usar siempre LEN(Cd_Documento) IN (14, 18) al decodificar (FOLIO, GRD_ID) de este Cd_Tabla.

❌ Aceptar ambos formatos puede duplicar el mismo XML en el resultado

Consecuencia directa del punto anterior: un mismo (FOLIO, GRD_ID) puede tener 2 filas en Comprobante_Digital — una por cada formato — casi siempre con el mismo Cd_Timbre_UUID (el mismo comprobante capturado dos veces). Sin deduplicar por (FOLIO, GRD_ID, Cd_Timbre_UUID), un join naive cuenta el importe del documento 2 veces. Caso real confirmado: folio 23-0006650, IMPORTE_DOC salía exactamente el doble de Cd_Monto.

En un subconjunto raro (7 de 4,511 pares (FOLIO,GRD_ID) en enero-marzo 2026), las dos filas traen Cd_Timbre_UUID distinto — no duplicado, ambigüedad real (el patrón de fechas de timbrado sugiere recaptura/corrección, no confirmado). Sin resolver, cuenta como residual conocido.

Corolario al deduplicar por documento, no por XML (2026-09-10, conciliacion-cfdi): si lo que se suma es el importe contabilizado por documento —no el XML— la llave de dedup tiene que ser LEFT(Cd_Documento, 14) (folio + Grd_ID), no Cd_Documento completo. Deduplicar por el documento completo deja pasar las dos filas del mismo (FOLIO, GRD_ID) como si fueran dos capturas distintas y duplica el cargo. Costó siete falsos "el cliente capturó el CFDI dos veces" en feb-2026, todos con un ratio de exactamente 2.0 sobre el importe del CFDI:

05-01784220001      (14)  05E5388C-...  9,373.96
05-017842200010001  (18)  05E5388C-...  9,373.96   <- la misma captura

Regla práctica: un patrón de "exactamente 2x" o de "importe idéntico repetido" es, hasta que se demuestre lo contrario, un artefacto propio — doble conteo por los dos formatos, o dos monedas de la misma partida (ver el caso BRIGGS arriba) — antes que un error de captura del cliente.

⚠️ Cd_Monto viene en la MONEDA ORIGINAL del CFDI, nunca convertido a MXN

Gasto_Registro_Documento.Grd_Precio_Neto_Importe se captura en la moneda nativa del documento (Mn_Cve_Moneda), y Grd_Tipo_Cambio es el factor para llevarlo a MXN — correcto para sumar folios de distinta moneda en un total agregado (layout_gastos_lib.IMPORTE_FOLIO_SQL). Pero Comprobante_Digital.Cd_Monto (y Cd_Moneda/Cd_Tipo_Cambio, columnas propias de esa tabla) refleja el CFDI tal como se timbró — en USD, sigue en USD, sin convertir.

Comparar el importe ya convertido a MXN contra Cd_Monto da diferencias de hasta 18x, exactamente el factor del tipo de cambio del documento — parece un descuadre grande, es solo comparar monedas distintas. Confirmado con datos reales, 12 documentos en USD de ORDEN_COMPRA (enero 2026): cuadran exacto ($0.00) comparando Grd_Precio_Neto_Importe sin convertir contra Cd_Monto. Seguro también para MXN: Grd_Tipo_Cambio es siempre 1.0 ahí (verificado 7,026/7,026 documentos), así que no aplicar la conversión no rompe el caso normal.

SELECT Mn_Cve_Moneda, MIN(Grd_Tipo_Cambio), MAX(Grd_Tipo_Cambio), COUNT(*)
FROM Gasto_Registro_Documento GROUP BY Mn_Cve_Moneda
-- MXN: 1.0 / 1.0 (siempre) -- USD/EUR: variable, ~17-22

Alcance confirmado: 412 XML en USD y 49 en EUR de un total de ~96,872 con Cd_Tabla='GASTO_REGISTRO' (dato explícito en Cd_Moneda, no inferido).

⚠️ Cada tabla de origen trae su propio tipo de cambio — y Cheque no tiene columna de moneda

Confirmado 2026-09-10 (conciliacion-cfdi), generalizando el gotcha anterior más allá de GASTO_REGISTRO. Lo mismo que pasa con Cd_Monto pasa con raw_sat.cfdi_recibidos.subtotal/.total: vienen en la moneda original del CFDI, mientras que Poliza_Detalle y Gasto_Registro_Control postean siempre en MXN. El factor correcto para conciliar es el del documento que originó la póliza, y cada tabla lo guarda en su propia columna:

Cd_Tabla Tabla Folio Tipo de cambio ¿Mn_Cve_Moneda?
COMPRA Compra_Encabezado Co_Folio Co_Tipo_Cambio sí
COMPRA_INDIRECTO Compra_Indirecto Ci_Folio Ci_Tipo_Cambio sí
CUENTA_X_PAGAR Cuenta_X_Pagar Cxp_Folio Cxp_Tipo_Cambio sí
NOTA_CREDITO_PROVEEDOR Nota_Credito_Proveedor Nc_Folio Nc_Tipo_Cambio sí
FACTURA Factura_Encabezado Fc_Folio Fc_Tipo_Cambio sí
GASTO_REGISTRO Gasto_Registro_Documento (Gr_Folio, Grd_ID) Grd_Tipo_Cambio sí
CHEQUE Cheque Ch_Folio Ch_Tipo_Cambio no existe

Dos trampas concretas:

  • Cheque no tiene Mn_Cve_Moneda (solo Ch_Tipo_Cambio), verificado contra INFORMATION_SCHEMA. Pedirla no devuelve un error de SQL legible: el bridge responde HTTP 502 genérico, que parece caída del servicio — es el mismo gotcha de "columna inexistente = 502" documentado en arquitectura.
  • No usar Comprobante_Digital.Cd_Tipo_Cambio como factor de conversión: en varios casos difiere del que realmente se usó para postear (CADECO: 17.2698 en el documento vs 17.6900 en Comprobante_Digital).

Escala del problema: 43 de 149 descuadres de feb-2026 en conciliacion-cfdi eran solo esto — CFDI en USD comparados contra pesos, todos con ratio cargo/subtotal entre 15 y 20.

⚠️ raw_sat.cfdi_recibidos.iva es SOLO el IVA trasladado — restarle IEPS a mano da negativo

Confirmado 2026-09-10 (conciliacion-cfdi), al migrar recibidos/nivel_documento/conciliacion_xml_lib.py de parsear Cd_XML fila por fila a leer los campos ya parseados de raw_sat.cfdi_recibidos. El docstring de extract_sat.py (desde 2026-08-07) decía "iva es TotalImpuestosTrasladados del XML tal cual" — validado en su momento contra una muestra sin CFDI de combustible, donde la afirmación es trivialmente cierta porque ieps_trasladado vale 0 y no hay nada que restar. Con CFDI que sí traen IEPS (combustible), la fórmula ingenua iva_real = iva - ieps_trasladado da negativo: UUID 14235D58-A79C-4461-AA2A-53832F4C4D21, enero 2026 — iva=0.00, ieps_trasladado=330.67, total-subtotal=330.67 → la resta da -330.67 en vez de 0.00.

Verificado con SQL directo (periodo='2026-01' AND ieps_trasladado > 0): iva + ieps_trasladado = total - subtotal exacto en cada fila de la muestra. Es decir, iva ya es solo el traslado código SAT 002 (nunca incluyó IEPS) y no hace falta ninguna resta — el IVA trasladado real es iva tal cual. Esto ya coincide con la definición correcta en Warehouse (iva = "solo IVA, Impuesto='002'"); lo que estaba desactualizado era el comentario suelto en extract_sat.py del repo conciliacion-cfdi — corregido ahí en el mismo cambio.

SELECT uuid, subtotal, iva, ieps_trasladado, total, (total - subtotal) AS total_menos_subtotal
FROM raw_sat.cfdi_recibidos
WHERE periodo = '2026-01' AND ieps_trasladado > 0
-- iva + ieps_trasladado = total_menos_subtotal, siempre -- no restar

Cualquier otro consumidor de raw_sat.cfdi_recibidos.iva / cfdi_emitidos.iva que asuma que es el total de impuestos trasladados (en vez de solo IVA) va a fallar igual con cualquier CFDI de combustible o con IEPS.

⚠️ Poliza_Control.Pc_Documento para Pc_Tabla='VENTA' NO es Factura_Encabezado.Fc_Folio — reciclaje de folio entre módulos

Confirmado 2026-09-10 (conciliacion-cfdi, emitidos). Comprobante_Digital.Cd_Tabla='FACTURA' no tiene módulo homónimo en Poliza_Control — se contabiliza bajo Pc_Tabla='VENTA'. Una consulta ingenua por Poliza_Control WHERE Pc_Documento = '<Fc_Folio>' sí devuelve filas con Pc_Tabla='VENTA', lo que parece confirmar que el folio es el mismo valor — es un falso positivo por reciclaje de folio: la serie XX-NNNNNNN de Venta_Encabezado.Vn_Folio es independiente de la de Factura_Encabezado.Fc_Folio, y ambas reciclan el mismo rango de números en años distintos.

Se detectó cruzando Poliza.Pl_Fecha contra Factura_Encabezado.Fc_Fecha: para una muestra de 8 FACTURA de enero 2026, todos los "matches" por texto directo traían pólizas de 2018 a 2025, sin relación con la fecha real de la factura (ejemplo: Fc_Folio='02-0019715', Cd_Timbre_Fecha=2026-01-02, pero el match en Poliza_Control traía Pl_Fecha=2020-01-11).

La cadena correcta, verificada con fecha:

Factura_Encabezado.Fc_Folio (= Cd_Documento, siempre 10 caracteres exactos, sin sufijo)
  -> Venta_Encabezado.Fc_Folio  (puede haber varios Vn_Folio por una sola factura —
                                  hasta 382 en enero 2026, "una factura consolida
                                  muchas ventas/tickets"; el caso típico, 93.5% de
                                  2,201 facturas, es 1 factura = 1 venta)
  -> Vn_Folio = Poliza_Control.Pc_Documento (Pc_Tabla='VENTA'),
     exigiendo ADEMÁS Poliza.Pl_Fecha cerca de Venta_Encabezado.Vn_Fecha
     (el mismo reciclaje de folio aplica un nivel más abajo, a Vn_Folio)

Regla de oro para cualquier exploración futura de folios de mpro: validar SIEMPRE la fecha del match, no solo el string. El lado recibido (Compra_Encabezado, Gasto_Registro_Documento, etc.) no mostró este problema — o no se detectó — pero el lado venta sí, al menos para el módulo VENTA.

Nota aparte, más grave para cualquier método que quiera conciliar por póliza: VENTA postea dos pólizas separadas por documento — una de ingreso (Poliza_Configuracion "VENTAS ... EN ADELANTE": Cargo Clientes = Total del CFDI, Abono Ventas+IVA) y otra de costo ("COSTO DE VENTA ... EN ADELANTE": Cargo Costo de Venta = Abono Inventario, sin relación con el CFDI). De las dos, la que aísla el documento por Pd_Referencia de forma consistente es la de costo, no la de ingreso — la póliza de ingreso aparenta postearse consolidada por sucursal/día, sin trazabilidad a documento individual para la inmensa mayoría de las ventas (confirmado: de 2,191 documentos FACTURA de enero 2026, solo 19 encuentran algún Cargo aislado incluso siguiendo la cadena correcta de arriba).

⚠️ CFDI tipo RETENCIONES: el monto real vive en una fila HERMANA, bajo otro Cd_Tabla

Comprobante_Digital.Cd_Tipo_CFDI='RETENCIONES' (Constancia de Retención, obligación fiscal de Trivasa al retener ISR sobre honorarios pagados a personas físicas) se liga con Cd_Monto = 0 cuando aparece bajo Cd_Tabla='GASTO_REGISTRO' — el monto real vive en otra fila, con el MISMO Cd_Timbre_UUID, bajo Cd_Tabla='CONSTANCIA_RETENCION' (Trivasa es el emisor de esa fila, no el receptor):

SELECT Cd_Tabla, Cd_Documento, Cd_Timbre_UUID, Cd_Monto, Cd_Tipo_CFDI, Cd_RFC_Emisor
FROM Comprobante_Digital WHERE Cd_Timbre_UUID = '1B930B87-0FCA-4DE0-BF2F-5BF377F621C6'
-- CONSTANCIA_RETENCION  01-0000791          ...  51574.0  (vacío)       TRI970922TL2
-- GASTO_REGISTRO        01-003464400010001  ...      0.0  RETENCIONES  TRI970922TL2

Confirmado con datos reales (16 casos verificados): IMPORTE_DOC = Cd_Monto_constancia × 0.90 exacto — retención de ISR del 10% sobre el fee bruto de honorarios. Explicaba 31 de 58 XML_UUID sin cuadrar de GASTO_DIRECTO en un análisis de enero 2026 (16 de esos 31 cuadran exacto con la regla del 90%; los otros 15 no tienen constancia ligada — residual sin explicar).

⚠️ Retención: hay DOS conceptos con cve_retenc distinta, y solo uno usa CONSTANCIA_RETENCION — el otro nunca

Investigado a fondo 2026-09-09/10 (conciliacion-cfdi), retomando el residual "15 sin constancia ligada" del punto anterior. raw_sat.cfdi_retencion trae dos tipos de retención de ISR (impuesto_retenido_codigo='001' en ambos), emitidos por Trivasa a personas físicas, con comportamiento estructuralmente distinto:

cve_retenc Tasa Concepto (confirmado por XML/Gasto_Registro.Gr_Comentario) Frecuencia ¿Usa CONSTANCIA_RETENCION?
14 10% Arrendamiento/honorarios (el de la sección anterior) Cada ~4 meses (visto: ene y may 2026, cubriendo periodos Cr_Fecha_Inicial/Cr_Fecha_Final de 4 meses cada uno — el patrón "ene/may/sep" ya documentado no es una carga trimestral de mpro, es que el CFDI mismo se emite así de espaciado) Sí, 100% cuando se busca en el mes que corresponde
16 20% Intereses a prestamista/inversionista persona física (Gr_Comentario literal: INTERES PRESTAMISTA <NOMBRE> <MES> <AÑO>) Mensual (~15 CFDI/mes, mismo RFC puede repetir por varios contratos) Nunca — ni un solo caso en H1 2026 completo (136 CFDI revisados)

Point importante para no repetir el análisis del punto anterior a ciegas: el "residual sin constancia ligada" mezcla dos poblaciones con causas distintas. Para cve_retenc=14 (arrendamiento/honorarios), la ausencia de constancia en un mes dado normalmente solo significa que ese mes no le tocaba emitirse (cada 4 meses) — no es un hueco de datos. Para cve_retenc=16 (intereses), CONSTANCIA_RETENCION simplemente no es el camino — el monto real vive directo en Gasto_Registro_Control/Gasto_Registro_Documento (mismo proveedor, mismo mes, mismo importe — ver Gasto_Registro.Gr_Comentario como ancla de texto), ligado (cuando sí se liga) vía Comprobante_Digital.Cd_Tabla='GASTO_REGISTRO' con el Cd_Monto=0 de siempre (ver gotcha de arriba sobre el "stub").

Confirmado 2026-09-10, revisando en vivo los 6 meses de H1 2026 contra Comprobante_Digital con acceso directo (ver nota de acceso más abajo). De 136 CFDI de este tipo, febrero 2026 es el único mes con 0% de link (los otros 5 meses ligan 100%, mismo día o pocos días después de timbrarse) — pero el mecanismo de por qué un CFDI puntual queda huérfano no es siempre el mismo:

  1. Omisión pura (confirmado en un lote de 15 CFDI, febrero 2026): el gasto SÍ está bien capturado y por el monto correcto en Gasto_Registro (verificado exacto, $254,305.39 en 15 folios, referencias correlativas B1430-B1444), pero nunca se generó la fila en Comprobante_Digital — ni siquiera el stub en $0. Confirmado que el proceso de etiquetado SÍ corría ese mismo día para otros orígenes (72 filas GASTO_REGISTRO de otros folios se etiquetaron el mismo 26-feb-2026) — no fue una caída general del proceso, fue este lote puntual el que se saltó.

  2. CFDI sustituido sin re-ligar (confirmado con un caso real, enero 2026): un CFDI de retención puede traer <retenciones:CfdiRetenRelacionados TipoRelacion="04" UUID="..."/> — sustitución de una retención previa. Caso real: UUID 5608781C-... (retención de noviembre 2025, timbrada tarde el 22-ene-2026) sustituye a 95DF3FD5-... (el CFDI original de noviembre, ya no vigente ante el SAT — no aparece en raw_sat.cfdi_retencion). Comprobante_Digital sigue ligado al UUID viejo (95DF3FD5, folio 01-0033848, etiquetado en dic-2025) — nadie actualizó el link cuando se sustituyó el CFDI. El folio de gasto está bien contabilizado; lo que quedó desactualizado es solo la referencia fiscal. Antes de tratar un CFDI de retención huérfano como "gasto no encontrado", revisar CfdiRetenRelacionados en el XML — puede que ya esté conciliado bajo el UUID que sustituyó.

Total H1 2026, confirmado por dos implementaciones independientes: sumando el mecanismo 1 (omisión, 15 en febrero) más 14 casos equivalentes de enero — 29 constancias de retención por $536,597.04 ($107,319.45 de ISR) que el SAT tiene timbradas y el ERP nunca registró en ningún módulo, cero en marzo-junio. Dos sesiones de Cowork construyeron el mismo cruce SAT-vs-ERP por separado el mismo día (retencion_reconciliation.py en esta sesión, y un cruce equivalente adaptado de un proyecto hermano en otra) y llegaron exacto al mismo número por caminos independientes — confirmación cruzada real, no coincidencia de código copiado. Detalle en conciliacion-cfdi.

Acceso directo a las bases, sin pasar por el bridge de solo lectura: confirmado 2026-09-10 que, con red al segmento 192.168.117.0/24 (VPN mesh) y credenciales de Infisical (proyecto secret-management, workspaceId 2aefdbd1-389c-4fd0-bdb8-a5621af8aac1), se puede conectar directo — psycopg2 a 192.168.117.14:5433 (postgres_dw) y pymssql/pyodbc a 192.168.117.205/.207:1433 (mssql_205/207) — sin pasar por la bridge API de ctunlinux. Útil para sesiones que sí tengan esa red disponible (no todas — ver arquitectura para cuándo aplica el bridge en su lugar).

Acceso a los XML crudos de retención vía SMB: //192.168.117.211/SincronizarXml/TRI970922TL2/XML RETENCIONES/{año}/{año FOLDER}/{ACCIONISTAS|INVERSIONISTAS}/ — con credenciales Infisical SAMBA_SINCRONIZARXML_* (mismo proyecto, env prod). ACCIONISTAS = retenciones tipo dividendos (cve_retenc=14, nomenclatura de archivo MNNNNN.xml); INVERSIONISTAS = intereses a prestamista (cve_retenc=16, nomenclatura TRI970922TL2_B-NNNN_<timestamp timbrado>.xml). Gotcha de archivado: un CFDI que corrige/sustituye un periodo anterior (ver caso arriba) se archiva bajo la carpeta del periodo del gasto original, no la del mes en que se timbró — el archivo de 5608781C (timbrado enero 2026) vive en la carpeta 2025/11 Noviembre 2025/INVERSIONISTAS/, no en 2026/01 Enero 2026/. Buscar por archivo_origen de raw_sat.cfdi_retencion (trae el nombre exacto) en vez de asumir la carpeta por fecha de timbrado.

⚠️ CONTROL_COMBUSTIBLE: la factura consolidada cruza folios de DISTINTAS Poliza_Configuracion, y su timbrado casi nunca cae en el mismo mes que el folio operativo

Dos propiedades del mismo fenómeno — el proveedor de combustible factura de forma consolidada (una factura por periodo/estación, no una por carga), y esa consolidación no respeta las fronteras que usan otras herramientas de este proyecto para acotar el universo.

1. Un solo Cd_Timbre_UUID puede repartirse entre folios de configs distintas. Caso real confirmado: el UUID 7285186E-283F-4058-9FE2-522A6E9545E8 (RFC emisor CIC011107RR1, Cd_Monto=$10,486.62) liga 14 folios reales (07-0081686, 05-0174676…05-0174679, 01-0034629…01-0034636, 23-0006624), y esos folios pertenecen a dos Poliza_Configuracion distintas (0450 y 0427):

SELECT DISTINCT pl.Pl_Configuracion
FROM Gasto_Registro gr
JOIN Poliza_Control plc ON plc.Pc_Tabla='GASTO_REGISTRO' AND plc.Pc_Documento=gr.Gr_Folio
JOIN Poliza pl ON pl.Pl_Folio = plc.Pl_Folio
WHERE gr.Gr_Folio IN ('07-0081686','05-0174676', /* ... */)
-- 0450, 0427

Cualquier reconciliación que reconstruya el universo dentro de una sola config (como reconstruir_config() de 14/15, que solo cubre 0450) suma la suma de este UUID de forma incompleta — el $10,486.62 nunca cuadra si solo se ven los folios de 0450, aunque sumando los 14 folios reales (cruzando ambas configs) sí cuadra exacto. No es un residual de datos, es un límite de alcance de trabajar config por config.

2. El Cd_Timbre_Fecha del CFDI casi nunca cae en el mismo mes que Gasto_Registro.Gr_Fecha. Confirmado indirectamente: al construir el universo de conciliación arrancando por fecha de timbrado del CFDI (el patrón que usa ~/proyectos/conciliacion-master/adjuntar-xml/conciliacion_xml_lib.xml_recibidos(), filtrando Cd_Timbre_Fecha >= :fi AND < :ff), CONTROL_COMBUSTIBLE sale con cero grupos para el mes — el CFDI de esas facturas simplemente no timbra dentro del mismo mes que los folios de gasto que cubre. Arrancando en cambio por folio del periodo (Gr_Fecha, sin filtrar el CFDI por fecha) sí aparecen. Práctico: cualquier conciliación de este origen debe acotar el universo por el folio operativo, nunca por la fecha de timbrado del comprobante.

Ideas todavía no verificadas contra esta base (vienen de proyectos hermanos, no confirmadas aquí)

Investigando ~/proyectos/conciliacion-master/adjuntar-xml/ y ~/proyectos/layout-contabilidad/layout_gastos/ (otras sesiones, mismo problema desde otro ángulo) aparecieron ideas que no se probaron en esta sesión — quedan como hipótesis, no como hallazgo: - CFDI tipo P (REP, complemento de Pagos): Cd_Monto/Total es 0 por diseño del SAT, el monto real vive en el nodo Pagos/Totales/@MontoTotalPagos dentro del XML crudo (Cd_XML). No confirmado que aplique al residual de GASTO_DIRECTO de este proyecto (el residual resultó ser mayormente RETENCIONES, no REP). - CFDI de Vales de Despensa: se timbra por la comisión (Total=$0.01), la dispersión real vive en el complemento ValesDeDespensa/@total. - Para CONTROL_COMBUSTIBLE: Cuenta_X_Pagar.Cxp_Referencia = Grd_Referencia (+ mismo proveedor, sumando por el fan-out ~13%) como cadena documental alterna, validada por otra sesión (layout-contabilidad/layout_gastos/docs/control_combustible_hallazgo.md, 30/30 y 32/32 en dos meses) — no verificado en este proyecto. - CFDI intercompañía: un CFDI cuyo Cd_RFC_Emisor es otra razón social del mismo grupo dentro de la misma TRIVASADB3 puede aparecer como "sin registro" si no se filtra — no se encontraron casos en el universo de este proyecto, pero tampoco se buscó activamente con una query dedicada.

Detalle completo de qué se probó, qué mejoró y qué no en layout-gastos, y en PROGRESS.md de conciliacion-master/layout-gastos/ (fuera de este repo).

⚠️ Complemento implocal:ImpuestosLocales — mpro lo suma al "Importe"/base del gasto, no al impuesto

Confirmado 2026-09-09 (conciliacion-cfdi), hallazgo nuevo — no reportado antes en este repo. El complemento SAT implocal:ImpuestosLocales (namespace http://www.sat.gob.mx/implocal) declara impuestos estatales/municipales (p. ej. ISH — Impuesto Sobre Hospedaje) que el SAT no incluye en cfdi:Impuestos/@TotalImpuestosTrasladados a nivel Comprobante — vive en un nodo aparte del XML:

<cfdi:Complemento>
  <implocal:ImpuestosLocales xmlns:implocal="http://www.sat.gob.mx/implocal"
      TotaldeTraslados="93.36" TotaldeRetenciones="0.00" version="1.0">
    ...
  </implocal:ImpuestosLocales>
</cfdi:Complemento>

Al capturar el gasto en Gasto_Registro, mpro suma este traslado local dentro del "Importe"/base del gasto (Grd_Precio_Neto_Importe vía Gasto_Registro_Control.Grc_Importe), no del impuesto — confirmado exacto en dos CFDI reales: SubTotal($2,074.69) + local($93.36) = Importe mpro($2,168.05); SubTotal($1,453.29) + local($65.40) = Importe mpro($1,518.69). El importe capturado por mpro no aparece literal en ningún atributo único del XML — hay que sumar cfdi:Comprobante/@SubTotal + implocal:ImpuestosLocales/@TotaldeTraslados (y restar @TotaldeRetenciones, si aplica) para reproducirlo. Comparar el Importe/Grc_Importe capturado directamente contra el SubTotal del CFDI, sin este ajuste, produce un descuadre pequeño pero sistemático en cualquier CFDI que traiga el complemento.

Prevalencia confirmada: 14 de 759 CFDI de GASTO_REGISTRO en feb-2026 (1.8%) traen este complemento con traslado > $0 — bajo pero no despreciable. Implementado en conciliacion-cfdi/src/cfdi_parser.py (CfdiAmounts.impuestos_locales_trasladados / impuestos_locales_retenidos, propiedad base_mpro); detalle en conciliacion-cfdi/docs/hallazgos.md punto 18.

⚠️ El IEPS trasladado también se suma a la BASE del gasto — y raw_sat...iva no sirve para detectarlo

Confirmado 2026-09-10 (conciliacion-cfdi). Mismo mecanismo que el complemento implocal de arriba, pero con un impuesto federal: los traslados con Impuesto="003" (IEPS — combustibles, refrescos, botanas, telecomunicaciones) no son acreditables, así que mpro los manda al gasto y quedan dentro del importe capturado, no en el impuesto:

CADENA COMERCIAL OXXO    100.11 + IEPS 3.63 = 103.74  = Grc_Importe capturado
SUPER SAN FRANCISCO      143.73 + IEPS 4.37 = 148.10  = Grc_Importe capturado
GO MART YUC              109.26 + IEPS 8.74 = 118.00  = Grc_Importe capturado
TELEFONOS DE MEXICO   (528.55 - 65.00 desc) + IEPS 9.73 = 473.28 = Grc_Importe capturado

⚠️ raw_sat.cfdi_recibidos.iva no permite separarlo: guarda TotalImpuestosTrasladados, que mezcla IVA e IEPS, y en varios casos ni siquiera coincide con la suma de los traslados del XML (OXXO: iva = 8.76 mientras TotalImpuestosTrasladados = 12.39). Hay que abrir el XML y sumar los cfdi:Traslado con Impuesto <> '002' del nodo cfdi:Impuestos hijo directo del Comprobante — ojo, hay un cfdi:Impuestos por cada cfdi:Concepto y buscar con .// agarra el del primer concepto, que siempre da 0.

⚠️ El Descuento del CFDI no existe como columna en raw_sat.cfdi_recibidos — su subtotal es el BRUTO

Confirmado 2026-09-10 (conciliacion-cfdi). raw_sat.cfdi_recibidos no tiene columna de descuento y su subtotal es el previo al Descuento de nivel Comprobante; mpro captura y postea el importe neto. En CFDI con descuento grande la diferencia es enorme, no marginal:

AT&T COMUNICACIONES   SubTotal 12,472.32 - Descuento 12,120.64 =    351.68 = cargo mpro
AGENCIA COMERCIALIZ.  SubTotal 118,205.26 - Descuento 70,215.11 = 47,990.15 = cargo mpro

Cualquier cruce contra importes de mpro que use subtotal de raw_sat sin abrir el XML descuadra en estos casos. Reuniendo este punto, el del IEPS y el de implocal, la base comparable contra mpro es:

base = (SubTotal - Descuento + IEPS + impuestos_locales) x tipo_de_cambio_del_documento

⚠️ Un mismo REP (CFDI tipo P) puede quedar capturado ligado a DOS documentos distintos — duplicación real, no ambigüedad de formato

Sesión 2026-09-09, investigando el residual de conciliación de CONT-6 (layout-gastos). Confirma con evidencia la hipótesis que quedaba abierta arriba ("CFDI tipo P... no confirmado que aplique") — sí ocurre, aunque en CHEQUE, no en GASTO_DIRECTO.

Caso real: proveedor "JJ REMOLQUES EN RENTA", 2 cheques (01-0086348 enero $1,349,926.80, 01-0086789 febrero $2,474,865.81). Cada cheque tiene su propio REP que cuadra exacto centavo a centavo contra su importe. Pero además, un tercer REP (Cd_Timbre_UUID='2F027671-FFBE-484E-95D2-DB34D0E2496B', XML_PAGOS_MONTO=$674,963.39) aparece en Comprobante_Digital ligado a ambos cheques a la vez (Cd_Documento='01-00863480002' y '01-00867890002', mismo UUID) — cada grupo se ve "inflado" en esa misma cantidad exacta. Es un dato duplicado en la captura de MPro (o un pago real que pertenece a un tercer documento no identificado), no un error de cálculo del lado del análisis. Confirmado parseando el XML: es un REP genuino (Total=$0, monto real en Pagos/@MontoTotalPagos).

Consecuencia práctica para cualquier reconciliación XML↔documento: agrupar por componente conexa (documento↔UUID) por sí solo no filtra este caso — hay que decidir explícitamente qué hacer cuando el mismo UUID aparece bajo Cd_Documento de más de un documento del mismo proveedor/módulo sin que haya una razón de negocio clara (a diferencia de CONTROL_COMBUSTIBLE, donde el mismo UUID sí pertenece legítimamente a 14 folios reales).

⚠️ CFDI de organismos de gobierno (IMSS/INFONAVIT) con Cd_Monto mayor al registrado en Gasto_Registro — NO es el mismo patrón de CONTROL_COMBUSTIBLE

Sesión 2026-09-09. Un CFDI del IMSS (Cd_Timbre_UUID='E9D53EB5-...', RFC emisor IMS421231I45, Total=$2,329,828.61) aparece ligado a un solo documento de Gasto_Registro (folio 01-0035538, sucursal 0001, concepto "CUOTAS AL IMSS") que solo registra $806,484.72 — un 35% del CFDI. A primera vista parece el mismo patrón de factura consolidada de CONTROL_COMBUSTIBLE (arriba), pero al intentar cerrarlo de la misma forma, no cierra:

  • No es el mismo UUID repartido en más folios — confirmado consultando Comprobante_Digital sin filtro de tabla/fecha: este UUID solo tiene 1 fila, ligada a este único documento. (En CONTROL_COMBUSTIBLE el UUID sí se repite en los 14 folios reales — esa es la señal que permite cerrarlo.)
  • Sumar todas las sucursales del mismo corte de fecha tampoco cierra: la cuota IMSS de las 6 sucursales activas el 28-feb-2026 suma $814,789.94. Sumando también los otros 2 conceptos de la misma familia de proveedor (SAR $155,184.52, Cesantía y Vejez $551,885.39) el total sube a $1,521,859.85 — sigue faltando ~$807,968.76.
  • Ampliar la ventana de fecha tampoco: la cuota IMSS sola (mismo proveedor) suma $2.72M en enero-marzo completo, ya más que el CFDI — así que "sumar más meses" no es la respuesta simple.

Catálogo de proveedores identificado en el camino (mismo RFC del IMSS salvo INFONAVIT — útil para futuras exploraciones de nómina/gasto de personal): 0000001253=Cuota IMSS, 0000001254=SAR, 0000001255=Cesantía y Vejez, 0000001262=Ret. Créd. INFONAVIT (vía nómina), 0000001256=INFONAVIT Aportación Patronal (RFC INF7205011ZA).

Conclusión: la diferencia entre el CFDI y lo registrado en Gasto_Registro para pagos a organismos de gobierno no se explica solo con datos de esta base — probablemente el CFDI cubre algo que Trivasa no registra bajo este proveedor/fecha (recargos, actualizaciones, otra razón social del grupo, un periodo de pago que no coincide con el corte contable). Queda como pregunta abierta para Contabilidad, no como bug de código. Lección: no asumir que todo desbalance grande de importe entre CFDI y registro es el mismo patrón de "factura consolidada cruza de alcance" solo por la forma (XML_GRUPO >> MPRO_GRUPO) — hay que confirmar que el UUID de verdad se repite en más documentos antes de darlo por sentado.