se valida si la factura ya existe en SAP y se avisa cuando SAP está fuera de línea

- 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>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-22 12:08:38 -05:00
1 parent 4deb9d4e4d
commit f0ad6aa78e
20 files changed
+991 -130

No files matched your search

+179 -86
View File
@@ -28,6 +28,9 @@ 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)
@@ -90,6 +93,174 @@ def _rechazar_correo(email: dict, token: str, email_addr: str, lista_obs: list)
)
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
# ============================================================================
@@ -221,96 +392,18 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
[observaciones.sin_factura(sorted({d.get("tipo_descripcion", "?") for d in documentos}))])
return
reintentar = False
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 = ""
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:
# 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"
database_utilities.delete_email_by_id(email["id"])
log("🔁 Correo en cola: se reintentará en el próximo ciclo")
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")
log(f"⚠️ No se pudo dejar el correo en cola: {ex}", severity=logging.WARNING)
# ============================================================================