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

+177 -7
View File
@@ -38,6 +38,9 @@ class Entorno:
self.sap_lanza = None # excepción que lanza get_delivery_notes
self.error_al_crear = None
self.hana_disponible = True
self.existente = None # dict: la factura ya existe en SAP
self.sap_caido = False # SAP no responde (en la búsqueda de la factura)
self.sap_caido_en_validacion = False
self.archivos = None # None: usa los del caso 001; dict {nombre: bytes}: esos archivos
self.drafts = []
self.correos = []
@@ -45,11 +48,12 @@ class Entorno:
@property
def tecnicos(self):
return [c for c in self.correos if c["asunto"].startswith("Draft SAP B1")]
return [c for c in self.correos if "Draft SAP B1" in c["asunto"]]
@property
def respuestas(self):
return [c for c in self.correos if " · " in c["asunto"] and ("Aceptado" in c["asunto"] or "Rechazado" in c["asunto"])]
return [c for c in self.correos if " · " in c["asunto"]
and any(p in c["asunto"] for p in ("Aceptado", "Rechazado", "En cola"))]
@pytest.fixture
@@ -102,7 +106,18 @@ def entorno(tmp_path, monkeypatch):
monkeypatch.setattr(outlook_utilities, "download_attachments", descargar)
monkeypatch.setattr(outlook_utilities, "send_email", enviar)
monkeypatch.setattr(sapb1_manager, "get_zonas", lambda: [{"Code": "Z1", "Name": "ZONA1"}])
def buscar_existente(ruc, tipo, serie, numero):
if e.sap_caido:
raise sapb1_manager.SapNoDisponible("No se pudo conectar con SAP B1")
return e.existente
def zonas():
if e.sap_caido_en_validacion:
raise sapb1_manager.SapNoDisponible("Timeout en Drafts")
return [{"Code": "Z1", "Name": "ZONA1"}]
monkeypatch.setattr(sapb1_manager, "buscar_factura_existente", buscar_existente)
monkeypatch.setattr(sapb1_manager, "get_zonas", zonas)
monkeypatch.setattr(sapb1_manager, "get_delivery_notes", delivery_notes)
monkeypatch.setattr(sapb1_manager, "obtener_condicion_pago", lambda c: {"NumberOfAdditionalDays": 30})
monkeypatch.setattr(sapb1_manager, "create_draft", crear_draft)
@@ -207,18 +222,20 @@ def test_guia_inexistente_rechaza_con_guia_no_encontrada(entorno, monkeypatch):
assert entorno.drafts == []
def test_guia_inexistente_con_hana_caido_es_error_interno_y_no_se_le_culpa_al_proveedor(entorno, monkeypatch):
def test_guia_inexistente_con_hana_caido_es_servicio_fuera_de_linea_y_no_rechazo(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.guia_existe = False
entorno.hana_disponible = False
_procesar(entorno)
assert entorno.respuestas == [] # nada al proveedor
assert entorno.drafts == []
(aviso,) = entorno.respuestas # no se rechaza: se avisa que está en cola
assert aviso["asunto"].startswith("En cola · Factura F002-2450") and "No encontramos la guía" not in aviso["html"]
(tecnico,) = entorno.tecnicos
assert "HANA" in tecnico["html"]
assert "HANA" in tecnico["html"] and tecnico["asunto"].startswith("SAP fuera de línea")
(registro,) = _registros()
assert registro["estado"] == "error" and registro["notificacion_estado"] == "omitida"
assert registro["estado"] == "error" and registro["notificacion_estado"] == "en_espera"
def test_error_consultando_sap_es_error_interno_y_no_rechazo(entorno, monkeypatch):
@@ -413,3 +430,156 @@ def test_un_fallo_inesperado_en_una_factura_no_tumba_el_ciclo(entorno, monkeypat
assert entorno.drafts == [] and entorno.respuestas == []
assert "ZeroDivisionError" in entorno.tecnicos[0]["html"]
assert _registros()[0]["estado"] == "error"
# ============================================================================
# La factura ya existe en SAP
# ============================================================================
def _ids_de_correos_guardados():
conn = sqlite3.connect(database_utilities.DB_NAME)
try:
return [f[0] for f in conn.execute("SELECT id FROM emails")]
finally:
conn.close()
def test_factura_que_ya_existe_en_sap_no_crea_otro_draft_y_se_rechaza(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.existente = {"entidad": "PurchaseInvoices", "DocEntry": 55, "DocNum": 9}
_procesar(entorno)
assert entorno.drafts == []
(respuesta,) = entorno.respuestas
assert respuesta["para"] == [SENDER] and respuesta["asunto"].startswith("Rechazado · Factura F002-2450")
assert "Esta factura ya está registrada en nuestro sistema" in respuesta["html"]
(tecnico,) = entorno.tecnicos
assert "ya existe en SAP" in tecnico["html"] and "PurchaseInvoices 55" in tecnico["html"]
assert not any(n.startswith("draft_sapb1") for n in os.listdir(entorno.salida)) # ni se armó el draft
assert _registros()[0]["estado"] == "rechazado"
def test_el_reenvio_de_una_factura_aceptada_no_pisa_el_registro_original(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
_procesar(entorno) # se acepta: draft 777
entorno.existente = {"entidad": "Drafts", "DocEntry": 777, "DocNum": 3}
_procesar(entorno, id_="otro-correo", carpeta=entorno.descarga.parent / "descarga2")
(registro,) = _registros()
assert (registro["estado"], registro["doc_entry"], registro["veces_recibido"]) == ("aceptado", 777, 2)
assert len(entorno.drafts) == 1 # no se creó un segundo draft
assert "ya está registrada" in entorno.respuestas[-1]["html"]
def test_reprocesar_el_mismo_correo_ya_resuelto_no_repite_nada(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
_procesar(entorno)
enviados = len(entorno.correos)
_procesar(entorno) # mismo correo otra vez
assert len(entorno.drafts) == 1 and len(entorno.correos) == enviados
# ============================================================================
# SAP fuera de línea
# ============================================================================
def test_sap_fuera_de_linea_avisa_al_proveedor_y_deja_el_correo_en_cola(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.sap_caido = True
_procesar(entorno)
assert entorno.drafts == []
(aviso,) = entorno.respuestas
assert aviso["para"] == [SENDER] and aviso["asunto"].startswith("En cola · Factura F002-2450 · ABR-")
assert "Recibimos su factura" in aviso["html"] and "temporalmente fuera de línea" in aviso["html"]
assert "No necesita reenviarla" in aviso["html"] and "{{" not in aviso["html"]
(tecnico,) = entorno.tecnicos
assert tecnico["para"] == EQUIPO and tecnico["asunto"].startswith("SAP fuera de línea - Draft SAP B1")
(registro,) = _registros()
assert (registro["estado"], registro["notificacion_estado"], registro["intentos"]) == ("error", "en_espera", 1)
(obs,) = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"]
assert obs["codigo"] == "servicio_no_disponible" and obs["intentos"] == 1
assert "abc" not in _ids_de_correos_guardados() # olvidado: el próximo ciclo lo vuelve a tomar
def test_los_reintentos_no_repiten_el_aviso_y_al_volver_sap_se_responde(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.sap_caido = True
_procesar(entorno)
_procesar(entorno) # el ciclo siguiente: SAP sigue caído
assert len(entorno.respuestas) == 1 and len(entorno.tecnicos) == 1 # un solo aviso, no uno por ciclo
assert _registros()[0]["intentos"] == 2 and "abc" not in _ids_de_correos_guardados()
entorno.sap_caido = False
_procesar(entorno) # SAP volvió
asuntos = [c["asunto"] for c in entorno.respuestas]
assert asuntos[0].startswith("En cola") and asuntos[1].startswith("Aceptado") and len(asuntos) == 2
(registro,) = _registros()
assert (registro["estado"], registro["doc_entry"], registro["notificacion_estado"]) == ("aceptado", 777, "enviada")
assert len(entorno.drafts) == 1
assert "abc" in _ids_de_correos_guardados() # ya resuelto: no se vuelve a tomar
def test_se_agotan_los_reintentos_y_se_avisa_al_equipo(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
monkeypatch.setattr(pipeline, "MAX_REINTENTOS", 2)
entorno.sap_caido = True
_procesar(entorno)
_procesar(entorno)
assert [c["asunto"].split(" - ")[0] for c in entorno.tecnicos] == ["SAP fuera de línea", "Reintentos agotados"]
assert len(entorno.respuestas) == 1 # el proveedor solo recibió el aviso inicial
assert "abc" in _ids_de_correos_guardados() # deja de reintentarse
registro = _registros()[0]
assert registro["notificacion_estado"] == "omitida" and "reintentos" in registro["notificacion_error"]
@pytest.mark.parametrize("como_falla", ["validacion", "crear_draft"])
def test_la_caida_en_cualquier_paso_es_fuera_de_linea(entorno, monkeypatch, como_falla):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
if como_falla == "validacion":
entorno.sap_caido_en_validacion = True
else:
entorno.error_al_crear = sapb1_manager.SapNoDisponible("HTTP 503 - Service Unavailable")
_procesar(entorno)
assert entorno.drafts == []
(aviso,) = entorno.respuestas
assert aviso["asunto"].startswith("En cola · Factura F002-2450")
assert "abc" not in _ids_de_correos_guardados()
def test_un_rechazo_normal_de_sap_no_es_una_caida(entorno, monkeypatch):
"""HTTP 400 al crear el draft: es un problema del dato o de SAP, no se reintenta ni se le dice nada al proveedor."""
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.error_al_crear = ConnectionError("Error en SAP POST Drafts: HTTP 400 - ItemCode inexistente")
_procesar(entorno)
assert entorno.respuestas == []
assert "HTTP 400" in entorno.tecnicos[0]["html"]
assert _registros()[0]["estado"] == "error" and "abc" in _ids_de_correos_guardados()
def test_sin_seguimiento_una_caida_no_se_reintenta(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.sap_caido = True
def roto(*a, **k):
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(seguimiento, "registrar_comprobante", roto)
_procesar(entorno)
assert entorno.respuestas == [] and len(entorno.tecnicos) == 1
assert "abc" in _ids_de_correos_guardados() # sin la cuenta de intentos no se arriesga un bucle