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

+45
View File
@@ -233,3 +233,48 @@ def test_valor_referencial_consulta_una_vez_por_guia(monkeypatch):
assert llamadas == [("T001", "1"), ("T001", "2")]
assert [r["valor_sap"] for r in resultado] == [7.5, 7.5]
# ============================================================================
# Caídas de SAP y errores de consulta
# ============================================================================
def test_validate_prices_deja_pasar_una_caida_de_sap(sap, monkeypatch):
def cae(**k):
raise draft_sap.sapb1_manager.SapNoDisponible("sin conexión")
monkeypatch.setattr(draft_sap.sapb1_manager, "get_delivery_notes", cae)
factura = {"lineas": [{"cantidad": 1, "total_linea": 100.0, "guias_remision": [_guia()]}]}
with pytest.raises(draft_sap.sapb1_manager.SapNoDisponible): # no se disfraza de "error de la guía"
draft_sap.validate_prices(factura)
def test_validate_prices_marca_el_error_de_consulta_de_la_guia(sap, monkeypatch):
def falla(**k):
raise ValueError("respuesta rara de SAP")
monkeypatch.setattr(draft_sap.sapb1_manager, "get_delivery_notes", falla)
factura = {"lineas": [{"cantidad": 1, "total_linea": 100.0, "guias_remision": [_guia()]}]}
_, resultados = draft_sap.validate_prices(factura)
assert resultados[0]["guias_lt"][0]["error"] == "respuesta rara de SAP"
assert draft_sap.hay_error_de_consulta(resultados) == "respuesta rara de SAP"
def test_hay_error_de_consulta_sin_errores():
assert draft_sap.hay_error_de_consulta([]) is None
assert draft_sap.hay_error_de_consulta([{"guias_lt": [{"guia": {}, "line_total": 0.0}]}, {"guias_lt": []}]) is None
def test_hana_disponible(monkeypatch):
cerrada = []
class Conexion:
def close(self):
cerrada.append(True)
monkeypatch.setattr(draft_sap.hana_manager, "get_connection", lambda: Conexion())
assert draft_sap.hana_disponible() is True and cerrada == [True] # abre y cierra: no deja conexiones colgando
monkeypatch.setattr(draft_sap.hana_manager, "get_connection", lambda: None)
assert draft_sap.hana_disponible() is False
+8 -1
View File
@@ -85,7 +85,7 @@ def test_motivos_de_correo_sin_comprobante():
def test_todas_las_observaciones_tienen_titulo_y_como_corregir():
muestras = [
obs.precio_no_coincide(1, 1, 2, ["T001-1"], True), obs.guia_no_encontrada("T001-1"),
obs.linea_sin_guia(1, ""), obs.cantidad_cero(1), obs.sin_pdf_xml(0), obs.falta_xml(1), obs.sin_factura([]),
obs.linea_sin_guia(1, ""), obs.cantidad_cero(1), obs.factura_ya_registrada("F002-1"), obs.sin_pdf_xml(0), obs.falta_xml(1), obs.sin_factura([]),
]
for o in muestras:
assert o.titulo and o.como_corregir and o.codigo
@@ -96,3 +96,10 @@ def test_a_dict_es_serializable():
d = obs.guia_no_encontrada("T001-9").a_dict()
assert d == {"codigo": "guia_no_encontrada", "titulo": "No encontramos la guía T001-9",
"filas": [{"etiqueta": "Guía", "valor": "T001-9"}], "como_corregir": d["como_corregir"]}
def test_factura_ya_registrada():
o = obs.factura_ya_registrada("F002-486")
assert o.codigo == "factura_ya_registrada" and o.filas == (("Comprobante", "F002-486"),)
assert "No necesita enviarla otra vez" in o.como_corregir
+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
+10 -1
View File
@@ -114,7 +114,7 @@ def test_todos_los_escenarios_se_renderizan_completos(escenario):
assert html.lstrip().startswith("<!DOCTYPE html>")
@pytest.mark.parametrize("nombre", ["aceptado", "rechazado"])
@pytest.mark.parametrize("nombre", ["aceptado", "rechazado", "fuera-de-linea"])
def test_los_ejemplos_cubren_las_variables_de_la_plantilla(nombre):
"""Si la plantilla pide algo nuevo (o un ejemplo sobra), el desfase se nota aquí."""
usados = plantillas.nombres_usados(plantillas.cargar_plantilla(nombre))
@@ -177,3 +177,12 @@ def test_rechazo_con_comprobante_si_muestra_guias_ruc_y_fila_comprobante():
assert "Guías revisadas" in html and "RUC 20537929520" in html and "Comprobante</td>" in html
def test_fuera_de_linea_dice_que_quedo_en_cola_y_no_pide_reenviar():
html = plantillas.renderizar_plantilla("fuera-de-linea", _ejemplos()["fuera_de_linea"]["contexto"])
assert "EN COLA" in html and "Recibimos su factura" in html
assert "temporalmente fuera de línea" in html and "No necesita reenviarla" in html
assert "Guías revisadas" not in html and "Pago estimado" not in html and "ACEPTADO" not in html
assert "ABR-261019-00048" in html and "{{" not in html
+25
View File
@@ -268,3 +268,28 @@ def test_encabezado_tecnico(registro):
def test_enviar_a_equipo(graph):
assert rp.enviar_a_equipo("t", BUZON, "Asunto", "<p/>", ["a.xml"]) is True
assert graph[0]["para"] == ["eq@aberio.com"]
def test_contexto_fuera_de_linea_renderiza(registro):
contexto = rp.contexto_fuera_de_linea(EMAIL, registro, FACTURA)
assert contexto["asunto"] == f"En cola · Factura F002-486 · {registro['codigo_seguimiento']}"
html = plantillas.renderizar_plantilla("fuera-de-linea", contexto)
assert "{{" not in html and "SÁNCHEZ &amp; HIJOS" in html
def test_responder_con_estado_de_exito_en_espera(graph, registro, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
estado = rp.responder("t", BUZON, EMAIL, "fuera-de-linea", rp.contexto_fuera_de_linea(EMAIL, registro, FACTURA),
registro, estado_exito="en_espera")
assert estado == "en_espera" and graph[0]["para"] == ["prov@empresa.com"]
assert seguimiento.obtener(registro["codigo_seguimiento"])["notificacion_estado"] == "en_espera"
def test_si_el_aviso_falla_queda_fallida_aunque_el_exito_sea_en_espera(graph, registro):
graph.ok = False
assert rp.responder("t", BUZON, EMAIL, "fuera-de-linea", rp.contexto_fuera_de_linea(EMAIL, registro, FACTURA),
registro, estado_exito="en_espera") == "fallida"
+185
View File
@@ -0,0 +1,185 @@
import pytest
import requests
import hana_manager
import sapb1_manager as s
class _Resp:
def __init__(self, status=200, body=None, text=""):
self.status_code, self._body, self.text = status, body if body is not None else {}, text
self.content = b"x"
self.cookies = {}
def json(self):
return self._body
@pytest.fixture(autouse=True)
def sesion_abierta(monkeypatch):
monkeypatch.setitem(s._sap_session, "headers", {"Cookie": "B1SESSION=x"})
# ============================================================================
# Clasificación de errores: caída vs rechazo de SAP
# ============================================================================
@pytest.mark.parametrize("error", [requests.exceptions.ConnectTimeout("t"), requests.exceptions.ReadTimeout("t"),
requests.exceptions.ConnectionError("c")])
def test_timeout_o_sin_conexion_es_sap_no_disponible(monkeypatch, error):
def falla(**k):
raise error
monkeypatch.setattr(s.requests, "request", lambda **k: falla(**k))
with pytest.raises(s.SapNoDisponible):
s.forward_to_sapb1("GET", "Drafts")
@pytest.mark.parametrize("status", [500, 502, 503])
def test_http_5xx_es_sap_no_disponible(monkeypatch, status):
monkeypatch.setattr(s.requests, "request", lambda **k: _Resp(status, {"error": {"message": {"value": "caído"}}}))
with pytest.raises(s.SapNoDisponible, match=f"HTTP {status}"):
s.forward_to_sapb1("GET", "Drafts")
def test_http_4xx_es_un_rechazo_no_una_caida(monkeypatch):
monkeypatch.setattr(s.requests, "request", lambda **k: _Resp(400, {"error": {"message": {"value": "ItemCode inexistente"}}}))
with pytest.raises(ConnectionError, match="ItemCode inexistente") as ex:
s.forward_to_sapb1("POST", "Drafts", json_data={})
assert not isinstance(ex.value, s.SapNoDisponible)
def test_sap_no_disponible_sigue_siendo_connection_error():
assert issubclass(s.SapNoDisponible, ConnectionError)
def test_login_sin_conexion_es_caida(monkeypatch):
def falla(*a, **k):
raise requests.exceptions.ConnectTimeout("t")
monkeypatch.setattr(s.requests, "post", falla)
with pytest.raises(s.SapNoDisponible):
s.login()
def test_login_5xx_es_caida_pero_credenciales_malas_no(monkeypatch):
monkeypatch.setattr(s.requests, "post", lambda *a, **k: _Resp(503, text="down"))
with pytest.raises(s.SapNoDisponible):
s.login()
monkeypatch.setattr(s.requests, "post", lambda *a, **k: _Resp(401, text="Invalid credentials"))
with pytest.raises(ConnectionError) as ex:
s.login()
assert not isinstance(ex.value, s.SapNoDisponible)
# ============================================================================
# Guías: una caída no se confunde con "no existe"
# ============================================================================
def _forward(respuestas):
"""forward_to_sapb1 simulado: ``respuestas`` es una función (metodo, entidad) -> dict | excepción."""
def forward(method, endpoint_path, **k):
r = respuestas(method, endpoint_path)
if isinstance(r, Exception):
raise r
return _Resp(200, r)
return forward
def test_sl_caido_y_hana_sin_resultados_es_caida_no_guia_inexistente(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: s.SapNoDisponible("sin conexión")))
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [])
with pytest.raises(s.SapNoDisponible):
s.get_delivery_notes("09", "T001", "1")
def test_sl_caido_pero_hana_encuentra_la_guia_sigue_adelante(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: s.SapNoDisponible("sin conexión")))
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [{"DocEntry": 7, "Tabla": "ODLN"}])
entradas = s._get_delivery_entries("09", "T001", "1")
assert entradas == [{"DocEntry": 7, "Tabla": "ODLN", "Entidad": "DeliveryNotes"}]
def test_guia_que_de_verdad_no_existe_devuelve_lista_vacia(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: {"value": []}))
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [])
assert s.get_delivery_notes("09", "T001", "1") == []
def test_un_rechazo_normal_de_sl_con_hana_vacio_no_es_caida(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: ConnectionError("HTTP 400 - mal filtro")))
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [])
assert s.get_delivery_notes("09", "T001", "1") == []
def test_una_caida_al_leer_el_documento_se_propaga(monkeypatch):
def respuestas(metodo, entidad):
if entidad == "DeliveryNotes":
return {"value": [{"DocEntry": 7}]}
return s.SapNoDisponible("se cayó a mitad") # GET DeliveryNotes(7)
monkeypatch.setattr(s, "forward_to_sapb1", _forward(respuestas))
with pytest.raises(s.SapNoDisponible):
s.get_delivery_notes("09", "T001", "1")
# ============================================================================
# buscar_factura_existente
# ============================================================================
class _Grabadora:
def __init__(self, existentes):
self.llamadas, self.existentes = [], existentes
def __call__(self, method, endpoint_path, **k):
self.llamadas.append((endpoint_path, k["params"]))
valor = self.existentes.get(endpoint_path)
if isinstance(valor, Exception):
raise valor
return _Resp(200, {"value": [valor] if valor else []})
def test_encuentra_la_factura_de_compra(monkeypatch):
graba = _Grabadora({"PurchaseInvoices": {"DocEntry": 10, "DocNum": 5}})
monkeypatch.setattr(s, "forward_to_sapb1", graba)
assert s.buscar_factura_existente("20537929520", "01", "F002", "486") == \
{"entidad": "PurchaseInvoices", "DocEntry": 10, "DocNum": 5}
entidad, params = graba.llamadas[0]
assert len(graba.llamadas) == 1 # no hace falta mirar los drafts
for trozo in ("CardCode eq 'PL20537929520'", "U_BPP_MDTD eq '01'", "U_BPP_MDSD eq 'F002'",
"U_BPP_MDCD eq '486'", "Cancelled eq 'tNO'"):
assert trozo in params["$filter"]
def test_si_no_es_factura_busca_en_los_drafts(monkeypatch):
graba = _Grabadora({"Drafts": {"DocEntry": 3, "DocNum": 3}})
monkeypatch.setattr(s, "forward_to_sapb1", graba)
assert s.buscar_factura_existente("20537929520", "01", "F002", "486")["entidad"] == "Drafts"
assert [l[0] for l in graba.llamadas] == ["PurchaseInvoices", "Drafts"]
assert "DocObjectCode eq 'oPurchaseInvoices'" in graba.llamadas[1][1]["$filter"]
def test_si_no_existe_devuelve_none(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _Grabadora({}))
assert s.buscar_factura_existente("20537929520", "01", "F002", "486") is None
def test_las_comillas_se_escapan_en_el_filtro(monkeypatch):
graba = _Grabadora({})
monkeypatch.setattr(s, "forward_to_sapb1", graba)
s.buscar_factura_existente("20537929520", "01", "F0'02", "4' or '1'='1")
filtro = graba.llamadas[0][1]["$filter"]
assert "U_BPP_MDSD eq 'F0''02'" in filtro and "U_BPP_MDCD eq '4'' or ''1''=''1'" in filtro
def test_una_caida_al_buscar_se_propaga(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _Grabadora({"PurchaseInvoices": s.SapNoDisponible("caído")}))
with pytest.raises(s.SapNoDisponible):
s.buscar_factura_existente("20537929520", "01", "F002", "486")
+42
View File
@@ -210,3 +210,45 @@ def test_init_db_agrega_la_columna_a_una_tabla_creada_antes():
seguimiento.registrar_notificacion("ABR-261001-00001", "enviada", destinatario="a@x.com")
assert seguimiento.obtener("ABR-261001-00001")["notificacion_destinatario"] == "a@x.com"
def test_registrar_intento_cuenta_de_uno_en_uno():
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
assert r["intentos"] == 0
assert seguimiento.registrar_intento(r["codigo_seguimiento"]) == 1
assert seguimiento.registrar_intento(r["codigo_seguimiento"]) == 2
assert seguimiento.obtener(r["codigo_seguimiento"])["intentos"] == 2
def test_registrar_intento_de_un_codigo_inexistente():
with pytest.raises(LookupError):
seguimiento.registrar_intento("ABR-000000-99999")
def test_en_espera_es_un_estado_de_notificacion_valido():
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
seguimiento.registrar_notificacion(r["codigo_seguimiento"], "en_espera", destinatario="a@x.com")
assert seguimiento.obtener(r["codigo_seguimiento"])["notificacion_estado"] == "en_espera"
def test_init_db_agrega_la_columna_intentos_a_una_tabla_anterior():
conn = sqlite3.connect(database_utilities.DB_NAME)
conn.executescript("""
DROP TABLE comprobantes;
CREATE TABLE comprobantes (
id INTEGER PRIMARY KEY AUTOINCREMENT, codigo_seguimiento TEXT UNIQUE, email_id TEXT NOT NULL,
ruc_emisor TEXT, tipo TEXT, serie TEXT, numero TEXT, estado TEXT NOT NULL DEFAULT 'pendiente',
observaciones TEXT, doc_entry INTEGER, veces_recibido INTEGER NOT NULL DEFAULT 1,
notificacion_estado TEXT NOT NULL DEFAULT 'pendiente', notificacion_error TEXT, notificacion_fecha TEXT,
notificacion_destinatario TEXT, creado_en TEXT NOT NULL, actualizado_en TEXT NOT NULL);
INSERT INTO comprobantes (codigo_seguimiento, email_id, creado_en, actualizado_en)
VALUES ('ABR-261001-00001', 'viejo', '2026-10-01 10:00:00', '2026-10-01 10:00:00');
""")
conn.commit()
conn.close()
database_utilities.init_db()
assert seguimiento.obtener("ABR-261001-00001")["intentos"] == 0 # el valor por defecto se aplica a lo existente
assert seguimiento.registrar_intento("ABR-261001-00001") == 1