Nueva tabla comprobantes (creada en init_db) y módulo seguimiento.py: cada comprobante recibe un código ABR-AAMMDD-NNNNN que conserva si reaparece en otro correo, más su resultado (aceptado/rechazado/error) y el estado de la notificación. El pipeline lo registra por factura sin bloquear el flujo si el seguimiento falla. Las pruebas usan una BD temporal para no tocar emails.db. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
273 lines
11 KiB
Python
273 lines
11 KiB
Python
"""
|
|
Pipeline de procesamiento de facturas de transporte.
|
|
|
|
Cada correo se procesa individualmente en un bucle:
|
|
BD check → descargar adjuntos → descomprimir ZIPs → filtrar PDF/XML
|
|
→ procesar PDFs (lookup) → procesar XMLs (cruzar con PDF)
|
|
→ guardar resultado → construir draft SAP → validar precios
|
|
→ crear draft en SAP (si válido) → notificar → guardar draft JSON
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from datetime import datetime, timedelta
|
|
|
|
import email_utilities
|
|
import outlook_utilities
|
|
import database_utilities
|
|
import adjuntos
|
|
import documentos as docs
|
|
import persistencia
|
|
import draft_sap
|
|
import notificaciones
|
|
import sapb1_manager
|
|
import seguimiento
|
|
from pipeline_utils import log
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# SEGUIMIENTO (códigos y resultado por comprobante)
|
|
# ============================================================================
|
|
def _registrar_comprobante(email: dict, factura: dict) -> dict | None:
|
|
"""
|
|
Registra la factura en ``seguimiento`` y devuelve su registro (con el código
|
|
de seguimiento), o None si falla: el seguimiento nunca debe impedir
|
|
procesar la factura.
|
|
"""
|
|
try:
|
|
registro = seguimiento.registrar_comprobante(
|
|
email["id"], factura.get("ruc_emisor") or "", factura.get("tipo", "01"),
|
|
factura.get("serie", ""), factura.get("numero", ""),
|
|
)
|
|
except Exception as ex:
|
|
log(f"⚠️ No se pudo registrar el comprobante en seguimiento: {ex}", severity=logging.WARNING)
|
|
return None
|
|
|
|
log(f"🔖 Código de seguimiento: {registro['codigo_seguimiento']}")
|
|
if registro["reenvio"]:
|
|
log(f"⚠️ Comprobante ya recibido antes ({registro['veces_recibido']} veces, estado: {registro['estado']})", severity=logging.WARNING)
|
|
return registro
|
|
|
|
|
|
def _registrar_resultado(registro: dict | None, estado: str, **kwargs) -> None:
|
|
"""Guarda el resultado del comprobante; si falla, solo lo registra en el log."""
|
|
if not registro:
|
|
return
|
|
try:
|
|
seguimiento.actualizar_resultado(registro["codigo_seguimiento"], estado, **kwargs)
|
|
except Exception as ex:
|
|
log(f"⚠️ No se pudo guardar el resultado en seguimiento: {ex}", severity=logging.WARNING)
|
|
|
|
|
|
# ============================================================================
|
|
# PASO 1: Autenticación Microsoft Graph
|
|
# ============================================================================
|
|
def step_authenticate() -> str | None:
|
|
try:
|
|
logger.info("Intentando obtener token de acceso...")
|
|
token = outlook_utilities.get_access_token()
|
|
if token:
|
|
logger.info("✅ Token obtenido exitosamente.")
|
|
logger.info(f"Token prefix: {token[:25]}...")
|
|
return token
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"❌ Error: {e}")
|
|
return None
|
|
|
|
|
|
# ============================================================================
|
|
# PASO 2: Descarga de correos
|
|
# ============================================================================
|
|
def step_fetch_emails(token: str, email: str, fecha_inicio: str, fecha_fin: str) -> list[dict]:
|
|
logger.info("=" * 60)
|
|
logger.info(f"[MAIL] {email}")
|
|
try:
|
|
emails = outlook_utilities.get_emails(email, token, fecha_inicio, fecha_fin)
|
|
logger.info(f"Se encontraron {len(emails)} correos.")
|
|
return emails
|
|
except Exception as e:
|
|
logger.error(f"❌ Error: {e}")
|
|
return []
|
|
|
|
|
|
# ============================================================================
|
|
# PROCESAR UN CORREO
|
|
# ============================================================================
|
|
def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folder: str, output_folder: str) -> None:
|
|
"""
|
|
Procesa un correo completo de principio a fin.
|
|
|
|
Flujo (pasos 1-14):
|
|
1-2. Verifica en BD si el correo ya fue procesado; si no, lo guarda.
|
|
3. Descarga adjuntos desde Microsoft Graph.
|
|
4. Descomprime archivos ZIP.
|
|
5. Filtra solo archivos PDF/XML.
|
|
6. Procesa PDFs (extrae datos, renombra, construye cache).
|
|
7. Procesa XMLs (cruza con PDF, asigna guías de remisión).
|
|
8. Guarda resultado JSON con datos extraídos.
|
|
9. Filtra solo facturas tipo 01 para SAP.
|
|
10. Valida precios contra DeliveryNotes en SAP.
|
|
11. Construye payload del draft SAP B1.
|
|
12. Guarda draft JSON para depuración.
|
|
13. Crea el draft en SAP (si los precios coinciden) o notifica error.
|
|
14. Envía email de notificación con el resultado.
|
|
|
|
Args:
|
|
email: Diccionario del correo (debe tener ``id``, ``fecha``,
|
|
``subject``).
|
|
token: Token de acceso Microsoft Graph.
|
|
email_addr: Dirección de correo de la bandeja de entrada.
|
|
download_folder: Carpeta relativa para descargar adjuntos.
|
|
output_folder: Carpeta relativa para guardar resultados.
|
|
|
|
Returns:
|
|
None. Los resultados se persisten a disco (JSON) y se envían
|
|
notificaciones por email.
|
|
"""
|
|
|
|
|
|
# ========================================================================
|
|
# FLUJO DEL CORREO
|
|
# ========================================================================
|
|
|
|
logger.info(f"📬 [{email['fecha']}] {email['subject'][:80]}...")
|
|
log("📂 Adjuntos:")
|
|
|
|
# 1-2. BD: verificar existencia y guardar
|
|
exists = database_utilities.get_existing_ids([email["id"]])
|
|
if email["id"] in exists:
|
|
log("⏭️ Correo ya procesado, saltando")
|
|
return
|
|
database_utilities.save_emails([email])
|
|
|
|
# 3. Descargar adjuntos
|
|
lista_adjuntos = adjuntos.descargar_adjuntos(email, email_addr, token, download_folder)
|
|
if not lista_adjuntos:
|
|
log("⏭️ Correo sin adjuntos")
|
|
return
|
|
|
|
# 4. Descomprimir ZIPs
|
|
email_utilities.descomprimir_adjuntos([email], download_folder)
|
|
|
|
# 5. Filtrar PDF/XML y limpiar si no hay válidos
|
|
if not adjuntos.filtrar_y_limpiar(email, download_folder):
|
|
return
|
|
|
|
# 6. Procesar PDFs
|
|
pdf_cache = docs.procesar_pdfs(email, download_folder)
|
|
|
|
# 7. Procesar XMLs
|
|
documentos = docs.procesar_xmls(email, download_folder, pdf_cache)
|
|
if not documentos:
|
|
log("⏭️ No se extrajeron documentos XML")
|
|
return
|
|
|
|
# 8. Completar guías faltantes desde PDF
|
|
n_guias = docs.completar_guias(documentos, pdf_cache)
|
|
if n_guias:
|
|
log(f"🔄 {n_guias} guías completadas desde PDF")
|
|
|
|
# 9. Validar que toda guía tenga tipo, serie y número
|
|
docs.validar_guias(documentos)
|
|
|
|
# 10. Guardar resultado con todos los documentos
|
|
paths_resultado = persistencia.guardar_resultado(documentos, email.get("datos_pdf", []), output_folder)
|
|
email["resultado_paths"] = paths_resultado
|
|
|
|
# 11. Filtrar solo facturas para SAP
|
|
facturas = [d for d in documentos if d.get("tipo") == "01"]
|
|
if not facturas:
|
|
log("⏭️ No hay facturas (tipo 01) para SAP")
|
|
return
|
|
|
|
for factura in facturas:
|
|
serie = factura.get("serie", "?")
|
|
numero = factura.get("numero", "?")
|
|
|
|
log(f"📄 Factura: {serie}-{numero}")
|
|
registro = _registrar_comprobante(email, factura)
|
|
|
|
# 12. Validar precios (consulta SAP DNs, NO requiere draft)
|
|
todas_coinciden, resultados_val = draft_sap.validate_prices(factura)
|
|
validation_table = notificaciones.build_validation_html(resultados_val)
|
|
|
|
# 12.5 Comparar valor referencial del XML vs SAP (Sp_ObtenerValorReferencial)
|
|
guias_valor_ref = draft_sap.obtener_valor_referencial_sap(factura)
|
|
valor_referencial_table = notificaciones.build_valor_referencial_html(factura, guias_valor_ref)
|
|
|
|
# 13. Construir draft (usa LineTotal de los DNs, no del XML)
|
|
draft = draft_sap.build_sap_draft(factura, resultados_val)
|
|
|
|
adjuntos_email = [p for p in [factura.get("ruta_xml"), factura.get("ruta_pdf")] if p]
|
|
for p in email.get("resultado_paths", []):
|
|
if p and os.path.exists(p) and p not in adjuntos_email:
|
|
adjuntos_email.append(p)
|
|
|
|
# 14. Guardar draft JSON (siempre, para depuración)
|
|
persistencia.guardar_draft_json(draft, serie, numero, output_folder)
|
|
|
|
# 15. Crear draft en SAP o generar HTML
|
|
if todas_coinciden:
|
|
try:
|
|
sap_result = sapb1_manager.create_draft(draft)
|
|
doc_entry = sap_result.get("DocEntry")
|
|
log(f"✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
|
|
_registrar_resultado(registro, "aceptado", doc_entry=doc_entry)
|
|
html_body = notificaciones.build_notification_body("created", factura, validation_table, valor_referencial_table, doc_entry=doc_entry)
|
|
except Exception as ex:
|
|
log(f"❌ Error creando draft {serie}-{numero}: {ex}", severity=logging.ERROR)
|
|
_registrar_resultado(registro, "error", observaciones=[{"codigo": "error_sap", "detalle": str(ex)}])
|
|
html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=ex)
|
|
else:
|
|
log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló")
|
|
_registrar_resultado(registro, "rechazado", observaciones=[{"codigo": "precios_no_coinciden"}])
|
|
html_body = notificaciones.build_notification_body("skipped", factura, validation_table, valor_referencial_table)
|
|
|
|
# 16. Enviar notificación al remitente original, con copia al equipo
|
|
if token:
|
|
destinatario = email.get("sender")
|
|
if destinatario and destinatario != "Desconocido":
|
|
outlook_utilities.send_email(
|
|
token, email_addr,
|
|
[destinatario],
|
|
f"Draft SAP B1 - {serie}-{numero}",
|
|
html_body, adjuntos_email,
|
|
cc_recipients=["jlopez@aberio.com.pe", "gcalle@pucp.pe"],
|
|
)
|
|
else:
|
|
log("⚠️ No se pudo determinar el remitente, notificación no enviada", severity=logging.WARNING)
|
|
|
|
|
|
# ============================================================================
|
|
# PIPELINE COMPLETO
|
|
# ============================================================================
|
|
def run_pipeline(dias: int, EMAIL: str, DOWNLOAD_FOLDER: str, OUTPUT_FOLDER: str) -> None:
|
|
"""Ejecuta el pipeline completo: autentica, descarga correos y procesa cada uno."""
|
|
|
|
database_utilities.init_db()
|
|
|
|
hoy = datetime.now()
|
|
ayer = hoy - timedelta(days=dias)
|
|
fecha_inicio = ayer.strftime("%Y-%m-%d")
|
|
fecha_fin = hoy.strftime("%Y-%m-%d")
|
|
logger.info(f"Rango de búsqueda: {fecha_inicio} al {fecha_fin}")
|
|
|
|
# 1. Autenticar
|
|
token = step_authenticate()
|
|
if not token:
|
|
logger.error("❌ No se pudo autenticar. Abortando.")
|
|
return
|
|
|
|
# 2. Descargar correos
|
|
emails = step_fetch_emails(token, EMAIL, fecha_inicio, fecha_fin)
|
|
if not emails:
|
|
logger.info("No se encontraron correos.")
|
|
return
|
|
|
|
# 3. Bucle por cada correo
|
|
for email in emails:
|
|
_procesar_un_correo(email, token, EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER)
|