Files
descarga_factura_transporte/pipeline.py
T

344 lines
15 KiB
Python
Raw Normal View History

"""
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 observaciones
import respuesta_proveedor
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)
def _registrar_correo(email: dict) -> dict | None:
"""Como ``_registrar_comprobante``, para un correo sin comprobante identificable."""
try:
registro = seguimiento.registrar_correo(email["id"])
except Exception as ex:
log(f"⚠️ No se pudo registrar el correo en seguimiento: {ex}", severity=logging.WARNING)
return None
log(f"🔖 Código de seguimiento: {registro['codigo_seguimiento']}")
return registro
def _rechazar_correo(email: dict, token: str, email_addr: str, lista_obs: list) -> None:
"""
Rechaza un correo que no trae ningún comprobante válido: registra el motivo y
responde al remitente con la plantilla de rechazo (si las protecciones lo permiten).
"""
log(f"❌ Correo rechazado: {lista_obs[0].titulo}", severity=logging.WARNING)
registro = _registrar_correo(email)
_registrar_resultado(registro, "rechazado", observaciones=[o.a_dict() for o in lista_obs])
if not registro or not token:
return
respuesta_proveedor.responder(
token, email_addr, email, "rechazado",
respuesta_proveedor.contexto_rechazo_correo(email, registro, lista_obs), registro, sin_comprobante=True,
)
# ============================================================================
# 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") # no se responde: no hubo intento de enviar un comprobante
return
n_recibidos = len(lista_adjuntos)
# 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):
_rechazar_correo(email, token, email_addr, [observaciones.sin_pdf_xml(n_recibidos)])
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")
if any(a.lower().endswith(".xml") for a in email["adjuntos"]):
motivo = observaciones.sin_factura([])
else:
motivo = observaciones.falta_xml(sum(a.lower().endswith(".pdf") for a in email["adjuntos"]))
_rechazar_correo(email, token, email_addr, [motivo])
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")
_rechazar_correo(email, token, email_addr,
[observaciones.sin_factura(sorted({d.get("tipo_descripcion", "?") for d in documentos}))])
return
for factura in facturas:
serie = factura.get("serie", "?")
numero = factura.get("numero", "?")
log(f"📄 Factura: {serie}-{numero}")
registro = _registrar_comprobante(email, factura)
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)
estado = None # aceptado | rechazado | error
detalle_error = None
lista_obs: list = []
resultados_val: list = []
draft: dict = {}
validation_table = valor_referencial_table = ""
try:
# 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)
# 14. Guardar draft JSON (siempre, para depuración)
persistencia.guardar_draft_json(draft, serie, numero, output_folder)
detalle_error = draft_sap.hay_error_de_consulta(resultados_val)
if not todas_coinciden:
lista_obs = observaciones.desde_validacion(factura, resultados_val)
# "guía no encontrada" también es lo que se ve si HANA está caído: no culpar al proveedor
if (not detalle_error and any(o.codigo == "guia_no_encontrada" for o in lista_obs)
and not draft_sap.hana_disponible()):
detalle_error = "SAP HANA no responde: no se puede confirmar que las guías no existan"
except Exception as ex:
detalle_error = f"{type(ex).__name__}: {ex}"
# 15. Crear draft en SAP o generar el resultado
if detalle_error:
estado = "error"
log(f"❌ Error validando {serie}-{numero}: {detalle_error}", severity=logging.ERROR)
elif todas_coinciden:
try:
sap_result = sapb1_manager.create_draft(draft)
doc_entry = sap_result.get("DocEntry")
estado = "aceptado"
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:
estado, detalle_error = "error", str(ex)
log(f"❌ Error creando draft {serie}-{numero}: {ex}", severity=logging.ERROR)
else:
estado = "rechazado"
log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló")
_registrar_resultado(registro, "rechazado", observaciones=[o.a_dict() for o in lista_obs])
html_body = notificaciones.build_notification_body("skipped", factura, validation_table, valor_referencial_table)
if estado == "error":
_registrar_resultado(registro, "error", observaciones=[{"codigo": "error_interno", "detalle": detalle_error}])
html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=detalle_error)
# 16. Notificar: correo técnico solo al equipo; respuesta con plantilla al remitente
if not token:
continue
respuesta_proveedor.enviar_a_equipo(
token, email_addr, f"Draft SAP B1 - {serie}-{numero}",
respuesta_proveedor.encabezado_tecnico(email, registro) + html_body, adjuntos_email,
)
if registro is None:
log("⚠️ Sin código de seguimiento: no se responde al proveedor", severity=logging.WARNING)
elif estado == "aceptado":
respuesta_proveedor.responder(
token, email_addr, email, "aceptado",
respuesta_proveedor.contexto_aceptado(email, registro, factura, resultados_val, draft), registro,
)
elif estado == "rechazado":
respuesta_proveedor.responder(
token, email_addr, email, "rechazado",
respuesta_proveedor.contexto_rechazo_factura(email, registro, factura, resultados_val, lista_obs), registro,
)
else:
respuesta_proveedor.omitir_respuesta(registro, "error interno: se avisó solo al equipo")
# ============================================================================
# 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)