- sapb1_manager.buscar_factura_existente busca la factura (proveedor, tipo, serie, número) en facturas de compra no anuladas y en drafts; si existe no se crea otro draft y se responde "ya está registrada" sin pisar el registro de una aceptación anterior. - SapNoDisponible (subclase de ConnectionError) distingue una caída (timeout, sin conexión, HTTP 5xx) de un rechazo normal de SAP (HTTP 4xx). Una caída de Service Layer con HANA sin resultados ya no se lee como "guía no existe". - Con SAP o HANA caídos se avisa una sola vez al proveedor con la plantilla "En cola" y el correo se reintenta en los ciclos siguientes (hasta 24), sin repetir avisos; al agotarse se avisa al equipo. - La lógica por factura pasa a _procesar_factura; lo ya resuelto dentro de un correo no se repite al reintentarlo. - seguimiento: columna intentos (con migración), registrar_intento y el estado de notificación en_espera. - database_utilities.DB_NAME pasa a ser absoluto (junto al código). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
438 lines
19 KiB
Python
438 lines
19 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 observaciones
|
|
import respuesta_proveedor
|
|
import sapb1_manager
|
|
import seguimiento
|
|
from pipeline_utils import log
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Veces que se reintenta un comprobante mientras SAP está fuera de línea (un intento por ciclo del demonio)
|
|
MAX_REINTENTOS = 24
|
|
|
|
|
|
# ============================================================================
|
|
# 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,
|
|
)
|
|
|
|
|
|
def _contar_intento(registro: dict | None) -> int | None:
|
|
"""Suma un intento al comprobante (SAP fuera de línea). None si no se puede llevar la cuenta."""
|
|
if not registro:
|
|
return None
|
|
try:
|
|
return seguimiento.registrar_intento(registro["codigo_seguimiento"])
|
|
except Exception as ex:
|
|
log(f"⚠️ No se pudo contar el reintento en seguimiento: {ex}", severity=logging.WARNING)
|
|
return None
|
|
|
|
|
|
def _procesar_factura(email: dict, token: str, email_addr: str, factura: dict, output_folder: str) -> bool:
|
|
"""
|
|
Valida una factura contra SAP, crea el draft si corresponde y notifica el resultado.
|
|
|
|
Resultados posibles: ``aceptado`` (draft creado), ``rechazado`` (precios, guías o factura ya
|
|
registrada en SAP), ``error`` (falla nuestra: solo se avisa al equipo) o ``fuera_de_linea``
|
|
(SAP o HANA no responden: se avisa una vez al proveedor y se reintenta).
|
|
|
|
Returns:
|
|
True si hay que reintentar el correo en el próximo ciclo (SAP fuera de línea).
|
|
"""
|
|
serie = factura.get("serie", "?")
|
|
numero = factura.get("numero", "?")
|
|
|
|
log(f"📄 Factura: {serie}-{numero}")
|
|
registro = _registrar_comprobante(email, factura)
|
|
|
|
# Al reintentar un correo, lo que ya se resolvió no se repite (ni se responde dos veces)
|
|
if registro and not registro["reenvio"] and registro["estado"] in ("aceptado", "rechazado"):
|
|
log(f"⏭️ {serie}-{numero} ya se resolvió al procesar este correo ({registro['estado']}): no se repite")
|
|
return False
|
|
|
|
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 | fuera_de_linea
|
|
detalle_error = None
|
|
existente = None
|
|
todas_coinciden = False
|
|
lista_obs: list = []
|
|
resultados_val: list = []
|
|
draft: dict = {}
|
|
doc_entry = None
|
|
validation_table = valor_referencial_table = ""
|
|
|
|
try:
|
|
# 11.5 ¿La factura ya existe en SAP (factura de compra o draft)?
|
|
existente = sapb1_manager.buscar_factura_existente(
|
|
factura.get("ruc_emisor") or "", factura.get("tipo", "01"), serie, numero,
|
|
)
|
|
if existente:
|
|
lista_obs = [observaciones.factura_ya_registrada(f"{serie}-{numero}")]
|
|
else:
|
|
# 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()):
|
|
raise sapb1_manager.SapNoDisponible("SAP HANA no responde: no se puede confirmar que las guías no existan")
|
|
except sapb1_manager.SapNoDisponible as ex:
|
|
estado, detalle_error = "fuera_de_linea", str(ex)
|
|
except Exception as ex:
|
|
detalle_error = f"{type(ex).__name__}: {ex}"
|
|
|
|
# 15. Crear el draft en SAP o decidir el resultado
|
|
if estado is None:
|
|
if detalle_error:
|
|
estado = "error"
|
|
elif existente or not todas_coinciden:
|
|
estado = "rechazado"
|
|
else:
|
|
try:
|
|
doc_entry = sapb1_manager.create_draft(draft).get("DocEntry")
|
|
estado = "aceptado"
|
|
except sapb1_manager.SapNoDisponible as ex:
|
|
estado, detalle_error = "fuera_de_linea", str(ex)
|
|
except Exception as ex:
|
|
estado, detalle_error = "error", str(ex)
|
|
|
|
if 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)
|
|
elif estado == "rechazado":
|
|
motivo = "la factura ya existe en SAP" if existente else "validación falló"
|
|
log(f"⏭️ Draft NO creado: {serie}-{numero} — {motivo}")
|
|
if not (existente and registro and registro["estado"] == "aceptado"): # no pisar el registro de la aceptación original
|
|
_registrar_resultado(registro, "rechazado", observaciones=[o.a_dict() for o in lista_obs])
|
|
html_body = notificaciones.build_notification_body(
|
|
"duplicate" if existente else "skipped", factura, validation_table, valor_referencial_table, existente=existente)
|
|
else: # error | fuera_de_linea
|
|
log(f"❌ {'SAP fuera de línea' if estado == 'fuera_de_linea' else 'Error'} en {serie}-{numero}: {detalle_error}",
|
|
severity=logging.ERROR)
|
|
html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=detalle_error)
|
|
|
|
# SAP fuera de línea: se cuenta el intento y se avisa solo la primera vez
|
|
reintentar = False
|
|
primera_vez = agotado = False
|
|
if estado == "fuera_de_linea":
|
|
intentos = _contar_intento(registro)
|
|
if intentos is None: # sin seguimiento no se puede reintentar con seguridad
|
|
estado = "error"
|
|
else:
|
|
primera_vez, agotado = intentos == 1, intentos >= MAX_REINTENTOS
|
|
reintentar = not agotado
|
|
_registrar_resultado(registro, "error", observaciones=[
|
|
{"codigo": "servicio_no_disponible", "detalle": detalle_error, "intentos": intentos}])
|
|
log(f"🔁 Intento {intentos} de {MAX_REINTENTOS}" + (": se agotaron los reintentos" if agotado else ""),
|
|
severity=logging.WARNING)
|
|
if estado == "error":
|
|
_registrar_resultado(registro, "error", observaciones=[{"codigo": "error_interno", "detalle": detalle_error}])
|
|
|
|
# 16. Notificar: correo técnico solo al equipo; respuesta con plantilla al remitente
|
|
if not token:
|
|
return reintentar
|
|
asunto_tecnico = f"Draft SAP B1 - {serie}-{numero}"
|
|
if estado == "fuera_de_linea":
|
|
asunto_tecnico = ("Reintentos agotados - " if agotado else "SAP fuera de línea - ") + asunto_tecnico
|
|
if estado != "fuera_de_linea" or primera_vez or agotado: # no repetir el aviso en cada reintento
|
|
respuesta_proveedor.enviar_a_equipo(
|
|
token, email_addr, asunto_tecnico,
|
|
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,
|
|
)
|
|
elif estado == "fuera_de_linea":
|
|
if primera_vez:
|
|
respuesta_proveedor.responder(
|
|
token, email_addr, email, "fuera-de-linea",
|
|
respuesta_proveedor.contexto_fuera_de_linea(email, registro, factura), registro, estado_exito="en_espera",
|
|
)
|
|
elif agotado:
|
|
respuesta_proveedor.omitir_respuesta(registro, "se agotaron los reintentos: lo revisa el equipo")
|
|
else:
|
|
respuesta_proveedor.omitir_respuesta(registro, "error interno: se avisó solo al equipo")
|
|
|
|
return reintentar
|
|
|
|
|
|
# ============================================================================
|
|
# 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
|
|
|
|
reintentar = False
|
|
for factura in facturas:
|
|
if _procesar_factura(email, token, email_addr, factura, output_folder):
|
|
reintentar = True
|
|
|
|
if reintentar:
|
|
# SAP fuera de línea: se olvida el correo para que el próximo ciclo lo procese de nuevo
|
|
try:
|
|
database_utilities.delete_email_by_id(email["id"])
|
|
log("🔁 Correo en cola: se reintentará en el próximo ciclo")
|
|
except Exception as ex:
|
|
log(f"⚠️ No se pudo dejar el correo en cola: {ex}", 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)
|