consulta-xmls
Objetivo
App de solo consulta para contabilidad: XMLs (CFDI 4.0) timbrados del mes sin
registro ligado en MPRO (match por UUID), más panel de historial de proveedor.
Documentado también en Stack de BI — es el
lado //.211/SincronizarXml ──py──► raw_sat del diagrama de flujo de datos.
Dónde vive el código
Dos piezas separadas, en dos lugares distintos (split 2026-08-31 — antes
vivían juntas, fuera de git, en ~/por_ordenar/exploracion/consulta_xmls_gastos/):
- El ELT (carga de XML → Postgres):
trivasa-bi-core/raw_sat_xml/, en git. Ya no es exploración — es la rama//.211/SincronizarXml ──py──► raw_satdel diagrama de flujo de datos, documentada como parte de la arquitectura desde antes en Stack de BI pero nunca colocada físicamente ahí. Se movió en vez de solo repuntar rutas porque ya tenía cron + mount persistentes — dejó de ser "prototipo descartable" hace tiempo. Tres loaders independientes, una tabla cada uno (extendido 2026-09-02, originalmente solo recibidos): cargar_cfdi_recibidos.py→raw_sat.cfdi_recibidos. CarpetaXML RECIBIDOS, convención de carpetas estable ({año}/{año}_{mes}/ {año}_{mes}_AC).cargar_cfdi_emitidos.py→raw_sat.cfdi_emitidos. CarpetaXML EMITIDOS— mezcla CFDI 3.3 y 4.0 (namespace detectado por archivo, no fijo) y perdió la convención de carpetas desde 2025 (ver--anioen el docstring del script para el backfill histórico). Excluye recibos de nómina (carpeta_CAen años viejos, detectado por contenido — complementoNomina— en años sin ese split): son otro tipo de negocio, no ingreso, decisión explícita 2026-09-02.cargar_cfdi_retencion.py→raw_sat.cfdi_retencion. CarpetaXML RETENCIONES— esquema de SAT distinto (CFDI de Retenciones e Información de Pagos, namespaceretencionpago/1o/2, nocfdi:Comprobante), caso de negocio real: ISR retenido sobre intereses pagados a accionistas/inversionistas. Volumen chico (~2,400 XML en 10 años) así que no filtra por periodo — recorre toda la carpeta cada corrida, costo trivial y se auto-repara si la carpeta cambia de convención otra vez.mnt_xml/— punto de montaje del recurso de red (ver abajo), gitignored, compartido por los tres loaders (mismo mount, subcarpetas distintas).- Credenciales de Postgres:
shared/db_credentials.py→db_credentials.postgres_dw()(accessor agregado 2026-08-31 al mismo módulo que ya usan los 6dlt/load_*.py— leedlt/.dlt/secrets.toml, mismo patrón que el resto del repo, sin duplicar una copia propia). Ver Credenciales y conexiones. - La webapp de consulta (UI de pendientes): sigue en
~/por_ordenar/exploracion/consulta_xmls_gastos/enctunlinux— fuera de git, por convención de este repo (verCLAUDE.md, regla de promoción). Inactiva, fuera de alcance de la reactivación 2026-08-31 (decisión explícita: solo se reactivó el ELT). Componentes: webapp/(Flask + waitress) — UI de consulta de pendientes, puerto8600, solo red local (http://192.168.117.7:8600, no pasa por el tunnel público).scripts/logic.py/scripts/db.py— lógica de match, usaconnection_207(ver abajo) para cruzar contra MPRO en vivo.- Nada de esto se ha promovido todavía a
docs/proyectos/consulta-xmls/scripts/entrivasa-context— sigue pendiente si se retoma.
Arquitectura
Fuente de los XML — mount CIFS, solo lectura:
- Servidor: //192.168.117.211/SincronizarXml
- Ruta real dentro del share: TRI970922TL2/XML RECIBIDOS/{año}/{año}_{mes}/{año}_{mes}_AC/*.xml
- Credenciales: Infisical, proyecto secret-management (mismo proyecto
compartido de todo el homelab, workspaceId 2aefdbd1-389c-4fd0-bdb8-a5621af8aac1),
env prod — SAMBA_SINCRONIZARXML_USERNAME, SAMBA_SINCRONIZARXML_PASSWORD,
SAMBA_SINCRONIZARXML_DOMAIN. Antes vivían en texto plano en
~/.smbcredentials/sincronizarxml (permisos 600, root-only) — ese archivo
sigue siendo la fuente que /etc/fstab necesita leer directamente (fstab no
puede llamar a Infisical), así que Infisical es el respaldo/documentación
de esas credenciales, no reemplaza el archivo local que el mount necesita.
- Gotcha de negocio: el UUID en el nombre del archivo tiene casing
inconsistente (mayúsculas/minúsculas mezcladas) — normalizar siempre a
mayúsculas al comparar.
Destino: Postgres :5433, schema raw_sat (ver
Stack de BI; cfdi_recibidos vivió hasta
2026-07-29 en un schema contabilidad propio — se movió porque raw quedó
reservado para datos de fuente externa a MPRO). Tres tablas, una por loader:
cfdi_recibidos, cfdi_emitidos, cfdi_retencion — ver arriba.
Orquestación:
- Carga diaria: crontab de ealcocer, tres líneas escalonadas (0 5
recibidos, 5 5 emitidos, 10 5 retención — 5 min de por medio para no
pegarle al mismo mount CIFS a la vez). Recibidos y emitidos procesan mes
actual + mes anterior (por si llegan XMLs tarde); retención recorre toda
la carpeta cada vez (volumen chico, ver arriba). Las tres corren envueltas
en trivasa-bi-core/observability/checks/run_tracked.sh (mismo wrapper
que los dlt/load_*.py) — aparecen en el dashboard elt-health de
Perses, job=pipeline_run, stage=raw_sat_xml / raw_sat_xml_emitidos /
raw_sat_xml_retencion.
- Webapp: inactiva (ver arriba) — consulta-xmls-gastos.service sigue
disabled desde 2026-08-28, fuera de alcance de la reactivación
2026-08-31. Cuando se retome: refresco de XMLs pensado como manual (botón
en la UI), no cron — decisión explícita, no hay urgencia de frescura y así
se mantiene control del usuario.
- Las tablas de match (Comprobante_Digital, Pago_Cxp_Comprobante) sí se
cargan vía dlt a raw.* en trivasa-bi-core/dlt/, con cron propio —
ver load_comprobante_digital.py. Ese pipeline está sano y es independiente
de este.
Decisiones de arquitectura (resumen)
Prototipo sin dlt/dbt completo (mismo patrón que layout-gastos y
consumo_interno_trazabilidad) — se descartó por sobre-ingeniería para este
alcance. Excepción: las tablas de match de UUID sí van por dlt (se consultan
en cada refresh, se benefician de carga incremental). El resto (historial de
proveedor, Compra_Encabezado) sigue siendo consulta en vivo contra .207 —
impacto trivial confirmado.
Fuentes de UUID para el match de pendientes:
- Gastos: Pago_Cxp_Comprobante.Pcc_Timbre_UUID
- Compras: Comprobante_Digital.Cd_Timbre_UUID (filtrar cd_tabla = 'COMPRA')
Decisiones de negocio: RFC con múltiples Pv_Cve_Proveedor combina historial
de todos los códigos que matcheen; historial de proveedor cubre últimos 24
meses.
Gotchas
La carpeta XML RETENCIONES tiene subcarpetas por concepto, y archiva por periodo del GASTO, no por fecha de timbrado
Confirmado 2026-09-10 (conciliacion-cfdi), navegando el share directo vía smbclient (credenciales Infisical SAMBA_SINCRONIZARXML_*, mismo proyecto/env prod que arriba). Dentro de TRI970922TL2/XML RETENCIONES/{año}/{año}_{mes}/ hay dos subcarpetas, no archivos sueltos:
ACCIONISTAS/— retenciones de dividendos (cve_retenc=14enraw_sat.cfdi_retencion), nomenclatura de archivoMNNNNN.xml(ej.M20432.xml).INVERSIONISTAS/— retenciones de intereses a prestamista (cve_retenc=16), nomenclaturaTRI970922TL2_B-NNNN_<timestamp timbrado>.xml.
Un CFDI que corrige/sustituye una retención de un periodo anterior se archiva bajo la carpeta de ese periodo original, no bajo el mes en que se timbró. Caso real: un CFDI de intereses de noviembre 2025, timbrado tarde (enero 2026, sustituyendo una retención cancelada del mismo periodo — ver el gotcha de CfdiRetenRelacionados en calidad de datos), vive en 2025/11 Noviembre 2025/INVERSIONISTAS/, no en 2026/01 Enero 2026/. Como el loader recorre toda la carpeta sin filtrar por periodo (ver arriba), esto no le afecta — pero si algo busca el archivo por convención de nombre/fecha de timbrado en vez de usar archivo_origen (columna ya cargada en raw_sat.cfdi_retencion con el nombre real), no lo va a encontrar donde lo esperaría.
El mount CIFS a veces trae una página HTML de error en vez del XML
Confirmado 2026-09-03: cargar_cfdi_recibidos.py fallaba parseando 23 de
286 archivos del periodo con Entity 'uacute' not defined — parecía XML
corrupto, pero el contenido real es una página HTML
(Ha ocurrido un error al procesar su última acción...) que
SincronizarXml (.211) guardó con extensión .xml cuando el origen
SAT/PAC falló (sesión vencida / error del lado de ellos, no confirmado
cuál). El mount es de solo lectura — no hay nada que arreglar del lado de
este repo salvo detectar el caso: parse_cfdi() ahora revisa los
primeros 512 bytes en busca de <!doctype html/<html antes de
intentar el parseo XML, y tira un error explícito en vez del genérico de
lxml. Sigue contando como fallido — no hay CFDI real que recuperar de ese
archivo, solo se ahorra tener que re-diagnosticarlo desde cero la próxima
vez. Si el volumen de este caso crece, vale la pena preguntar del lado de
quien administra SincronizarXml en .211 qué está fallando ahí.
Buscar un complemento opcional por etree.QName(el).localname sobre .iter() truena si el XML trae un <!-- comentario -->
Confirmado 2026-09-10, al agregar la extracción del complemento
implocal:ImpuestosLocales (buscado por localname, no por prefijo, para no
depender de qué namespace use el XML — ver
conciliacion-cfdi). comprobante.iter()
de lxml, sin argumento, recorre todos los nodos del árbol, incluidos
Comment/ProcessingInstruction — su .tag no es un str sino una
función interna de lxml, y etree.QName(el) truena con
Invalid input tag of type <class '...cython_function_or_method'> en vez
de un error de XML. 10 archivos reales de agosto 2026 (con comentarios
<!-- ... --> legítimos en el XML) fallaron así, contados como "fallidos"
en el log — no eran XML corruptos. Corregido con comprobante.iter("*")
(el argumento "*" filtra a solo elementos reales) en los dos loaders que
recorren el árbol completo (cargar_cfdi_recibidos.py,
cargar_cfdi_emitidos.py).
El mount CIFS rota/purga XMLs viejos — filas huérfanas en Postgres, limpieza automática desde 2026-09-10
Confirmado 2026-09-10: 171 filas en raw_sat.cfdi_recibidos (periodo 2026)
tenían subtotal/iva/total cargados correctamente (de una corrida de
julio/agosto) pero su archivo_origen ya no existe en ninguna carpeta del
mount hoy — no es el gotcha de "HTML guardado como .xml" (ese tumba
también subtotal/total) ni el de "UUID de archivo ≠ UUID de contenido"
(el nombre de archivo coincidía exacto). El mount simplemente deja de
tener el XML con el tiempo. Los loaders son upsert-only a propósito (nunca
truncan/borran solos, para no perder histórico si el mount tiene un hueco
transitorio) — así que nunca se auto-corregían.
Se agregó raw_sat_xml/maintenance.py (mismo patrón que
dlt/maintenance.py: diff contra la fuente real, re-confirmar, solo
entonces borrar, con límite de seguridad MAX_BORRAR_POR_CORRIDA=500 antes
de borrar de verdad — si el mount no montó bien y "no hay archivos" por
error transitorio, el número de candidatos se dispara muy por encima de lo
esperado y ahí no se borra nada solo). El diff busca el archivo en toda
la carpeta del año, recursivo, no solo en la carpeta del mes de periodo
(que viene de fecha_emision, no de qué carpeta se escaneó) — un CFDI que
llega tarde a otra carpeta del mismo año no se marca huérfano por error.
observability/checks/check_raw_volume.py ahora trata
raw_sat.cfdi_recibidos/cfdi_emitidos como caso especial: en vez de
comparar contra el conteo de la corrida anterior (que nunca detecta
huérfanas — no bajan el total de golpe), compara filas del año en curso
contra archivos que existen hoy en esa carpeta del mount. Si no da pass,
AUTOHEAL llama a borrar_huerfanos_recibidos/emitidos automáticamente
(mismo patrón ya aprobado para comprobante_digital en
check_raw_freshness.py) — evento aparte en Loki
(job=raw_volume_autoheal) para que quede visible en el dashboard.
Estado
Ver PROGRESS.md — el ELT se reactivó 2026-08-31, promovido a
trivasa-bi-core/raw_sat_xml/ (ya no en la ruta rota de antes). La webapp
de consulta sigue inactiva, fuera de alcance de esa reactivación.