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:
1 parent
5e533b7a9f
commit
1c57988f54
20 files changed
+991
-130
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user