2026-10-15 00:14:41 -05:00
|
|
|
"""
|
2026-10-22 08:55:54 -05:00
|
|
|
Flujo completo de ``pipeline._procesar_un_correo`` con SAP, HANA, Outlook y Graph
|
|
|
|
|
simulados. Usa el correo de prueba 001 (factura F002-2450, una línea de 624.38
|
|
|
|
|
con la guía T001-128912).
|
|
|
|
|
|
|
|
|
|
Cada factura genera dos correos: el **técnico**, solo para el equipo (asunto
|
|
|
|
|
"Draft SAP B1 - …"), y la **respuesta al proveedor** con la plantilla de aceptado o
|
|
|
|
|
rechazado (asunto "Aceptado · …" / "Rechazado · …").
|
2026-10-15 00:14:41 -05:00
|
|
|
"""
|
|
|
|
|
import glob
|
2026-10-19 23:15:23 -05:00
|
|
|
import logging
|
2026-10-15 00:14:41 -05:00
|
|
|
import os
|
|
|
|
|
import shutil
|
2026-10-19 23:15:23 -05:00
|
|
|
import sqlite3
|
2026-10-15 00:14:41 -05:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
import database_utilities
|
|
|
|
|
import hana_manager
|
|
|
|
|
import outlook_utilities
|
|
|
|
|
import pipeline
|
|
|
|
|
import sapb1_manager
|
2026-10-19 23:15:23 -05:00
|
|
|
import seguimiento
|
2026-10-15 00:14:41 -05:00
|
|
|
from conftest import FIXTURES
|
|
|
|
|
|
|
|
|
|
CASO = "001_correo"
|
|
|
|
|
TOTAL_LINEA = 624.38
|
2026-10-22 08:55:54 -05:00
|
|
|
SENDER = "proveedor@empresa.com"
|
|
|
|
|
EQUIPO = ["equipo1@aberio.com", "equipo2@aberio.com"]
|
2026-10-15 00:14:41 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Entorno:
|
2026-10-22 08:55:54 -05:00
|
|
|
"""Registra lo que el pipeline le pide a los servicios externos y permite alterar sus respuestas."""
|
2026-10-15 00:14:41 -05:00
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.line_total_sap = TOTAL_LINEA
|
2026-10-22 08:55:54 -05:00
|
|
|
self.guia_existe = True # False: SAP no devuelve ningún documento para la guía
|
|
|
|
|
self.sap_lanza = None # excepción que lanza get_delivery_notes
|
2026-10-15 00:14:41 -05:00
|
|
|
self.error_al_crear = None
|
2026-10-22 08:55:54 -05:00
|
|
|
self.hana_disponible = True
|
2026-10-22 12:08:38 -05:00
|
|
|
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
|
2026-10-22 08:55:54 -05:00
|
|
|
self.archivos = None # None: usa los del caso 001; dict {nombre: bytes}: esos archivos
|
2026-10-15 00:14:41 -05:00
|
|
|
self.drafts = []
|
|
|
|
|
self.correos = []
|
|
|
|
|
self.descargas = 0
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
@property
|
|
|
|
|
def tecnicos(self):
|
2026-10-22 12:08:38 -05:00
|
|
|
return [c for c in self.correos if "Draft SAP B1" in c["asunto"]]
|
2026-10-22 08:55:54 -05:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def respuestas(self):
|
2026-10-22 12:08:38 -05:00
|
|
|
return [c for c in self.correos if " · " in c["asunto"]
|
|
|
|
|
and any(p in c["asunto"] for p in ("Aceptado", "Rechazado", "En cola"))]
|
2026-10-22 08:55:54 -05:00
|
|
|
|
2026-10-15 00:14:41 -05:00
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def entorno(tmp_path, monkeypatch):
|
|
|
|
|
e = Entorno()
|
|
|
|
|
monkeypatch.setenv("RUTA_RED_FACTURACION", "")
|
2026-10-22 08:55:54 -05:00
|
|
|
monkeypatch.setenv("NOTIFICACION_EQUIPO", ",".join(EQUIPO))
|
2026-10-15 00:14:41 -05:00
|
|
|
|
|
|
|
|
monkeypatch.setattr(database_utilities, "get_existing_ids", lambda ids: [])
|
|
|
|
|
|
|
|
|
|
def descargar(addr, id_, token, carpeta):
|
|
|
|
|
e.descargas += 1
|
2026-10-22 08:55:54 -05:00
|
|
|
os.makedirs(carpeta, exist_ok=True) # como el download_attachments real
|
2026-10-15 00:14:41 -05:00
|
|
|
nombres = []
|
2026-10-22 08:55:54 -05:00
|
|
|
if e.archivos is None:
|
|
|
|
|
for ruta in glob.glob(os.path.join(FIXTURES, CASO, "*")):
|
|
|
|
|
if ruta.lower().endswith((".pdf", ".xml")):
|
|
|
|
|
shutil.copy(ruta, carpeta)
|
|
|
|
|
nombres.append(os.path.basename(ruta))
|
|
|
|
|
else:
|
|
|
|
|
for nombre, contenido in e.archivos.items():
|
|
|
|
|
with open(os.path.join(carpeta, nombre), "wb") as f:
|
|
|
|
|
f.write(contenido)
|
|
|
|
|
nombres.append(nombre)
|
2026-10-15 00:14:41 -05:00
|
|
|
return nombres
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None, bcc_recipients=None):
|
|
|
|
|
e.correos.append({"para": para, "asunto": asunto, "html": html, "cc": cc_recipients,
|
|
|
|
|
"bcc": bcc_recipients, "adjuntos": adjuntos})
|
2026-10-15 00:14:41 -05:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def crear_draft(draft):
|
|
|
|
|
if e.error_al_crear:
|
|
|
|
|
raise e.error_al_crear
|
|
|
|
|
e.drafts.append(draft)
|
|
|
|
|
return {"DocEntry": 777}
|
|
|
|
|
|
|
|
|
|
def delivery_notes(**k):
|
2026-10-22 08:55:54 -05:00
|
|
|
if e.sap_lanza:
|
|
|
|
|
raise e.sap_lanza
|
|
|
|
|
if not e.guia_existe:
|
|
|
|
|
return []
|
2026-10-15 00:14:41 -05:00
|
|
|
return [{"Series": "1", "DocNum": "9", "U_IDC_DESTINO": "Z1", "DocumentLines": [
|
|
|
|
|
{"ItemCode": "FL10007", "LineTotal": e.line_total_sap, "PriceAfterVAT": 1.3396,
|
|
|
|
|
"Quantity": 466, "WarehouseCode": "A-CENTRA", "Weight1": 1}]}]
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
class ConexionHana:
|
|
|
|
|
def close(self):
|
|
|
|
|
pass
|
|
|
|
|
|
2026-10-15 00:14:41 -05:00
|
|
|
monkeypatch.setattr(outlook_utilities, "download_attachments", descargar)
|
|
|
|
|
monkeypatch.setattr(outlook_utilities, "send_email", enviar)
|
2026-10-22 12:08:38 -05:00
|
|
|
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)
|
2026-10-15 00:14:41 -05:00
|
|
|
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)
|
|
|
|
|
monkeypatch.setattr(hana_manager, "obtener_valor_referencial", lambda s, n: 0.0)
|
2026-10-22 08:55:54 -05:00
|
|
|
monkeypatch.setattr(hana_manager, "get_connection", lambda: ConexionHana() if e.hana_disponible else None)
|
2026-10-15 00:14:41 -05:00
|
|
|
|
|
|
|
|
e.descarga = tmp_path / "descarga"
|
|
|
|
|
e.salida = tmp_path / "salida"
|
|
|
|
|
return e
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
def _procesar(e, sender=SENDER, subject="Factura", id_="abc", es_automatico=False, carpeta=None):
|
|
|
|
|
email = {"id": id_, "fecha": "2026-10-14 10:35:00", "subject": subject, "sender": sender,
|
|
|
|
|
"has_attach": True, "es_automatico": es_automatico}
|
|
|
|
|
pipeline._procesar_un_correo(email, "token", "buzon@aberio.com", str(carpeta or e.descarga), str(e.salida))
|
2026-10-15 00:14:41 -05:00
|
|
|
return email
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
def _registros():
|
|
|
|
|
conn = sqlite3.connect(database_utilities.DB_NAME)
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
try:
|
|
|
|
|
return [dict(f) for f in conn.execute("SELECT * FROM comprobantes ORDER BY id")]
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Aceptado
|
|
|
|
|
# ============================================================================
|
|
|
|
|
def test_aceptado_crea_draft_y_responde_con_la_plantilla(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
|
|
|
|
|
2026-10-15 00:14:41 -05:00
|
|
|
_procesar(entorno)
|
|
|
|
|
|
|
|
|
|
assert len(entorno.drafts) == 1
|
|
|
|
|
draft = entorno.drafts[0]
|
2026-10-22 08:55:54 -05:00
|
|
|
assert draft["CardCode"] == "PL10060028686" and draft["NumAtCard"] == "01F002-2450"
|
2026-10-15 00:14:41 -05:00
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
(respuesta,) = entorno.respuestas
|
|
|
|
|
assert respuesta["para"] == [SENDER] # al que envió el correo
|
|
|
|
|
assert respuesta["bcc"] == EQUIPO # el equipo en copia oculta
|
|
|
|
|
assert respuesta["cc"] is None and respuesta["adjuntos"] is None
|
|
|
|
|
assert respuesta["asunto"].startswith("Aceptado · Factura F002-2450 · ABR-")
|
|
|
|
|
assert "Registramos su factura" in respuesta["html"]
|
|
|
|
|
assert "09T001-128912" not in respuesta["html"] and "T001-128912" in respuesta["html"]
|
|
|
|
|
assert "S/ 624.38" in respuesta["html"]
|
|
|
|
|
assert "Pago estimado" in respuesta["html"] # hay condición de pago de 30 días
|
2026-10-15 00:14:41 -05:00
|
|
|
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
def test_el_correo_tecnico_va_solo_al_equipo_nunca_al_remitente(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
|
|
|
|
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
|
|
|
|
(tecnico,) = entorno.tecnicos
|
|
|
|
|
assert tecnico["para"] == EQUIPO
|
|
|
|
|
assert SENDER not in tecnico["para"] and not tecnico["cc"] and not tecnico["bcc"]
|
|
|
|
|
assert any(a.endswith(".xml") for a in tecnico["adjuntos"]) # lleva los adjuntos internos
|
|
|
|
|
assert f"<b>Remitente:</b> {SENDER}" in tecnico["html"] and "ABR-" in tecnico["html"]
|
|
|
|
|
assert SENDER not in [d for c in entorno.respuestas for d in c["para"] if c["asunto"].startswith("[")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_modo_prueba_es_el_predeterminado_y_no_escribe_al_remitente(entorno):
|
|
|
|
|
_procesar(entorno) # NOTIFICACION_MODO sin definir
|
|
|
|
|
|
|
|
|
|
(respuesta,) = entorno.respuestas
|
|
|
|
|
assert respuesta["para"] == EQUIPO
|
|
|
|
|
assert respuesta["bcc"] is None
|
|
|
|
|
assert respuesta["asunto"].startswith(f"[PRUEBA → {SENDER}] Aceptado · Factura F002-2450")
|
|
|
|
|
assert all(SENDER not in c["para"] for c in entorno.correos) # a nadie fuera del equipo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Rechazado
|
|
|
|
|
# ============================================================================
|
|
|
|
|
def test_precios_distintos_rechaza_y_muestra_ambos_importes(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
2026-10-15 00:14:41 -05:00
|
|
|
entorno.line_total_sap = TOTAL_LINEA + 50
|
|
|
|
|
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
|
|
|
|
assert entorno.drafts == []
|
2026-10-22 08:55:54 -05:00
|
|
|
(respuesta,) = entorno.respuestas
|
|
|
|
|
assert respuesta["para"] == [SENDER]
|
|
|
|
|
assert respuesta["asunto"].startswith("Rechazado · Factura F002-2450")
|
|
|
|
|
html = respuesta["html"]
|
|
|
|
|
assert "No pudimos registrar su factura" in html and "1 observación" in html
|
|
|
|
|
assert "El importe no coincide con nuestro registro" in html
|
|
|
|
|
assert "S/ 624.38" in html and "S/ 674.38" in html # importe de la factura y esperado
|
|
|
|
|
assert "No coincide" in html
|
2026-10-15 00:14:41 -05:00
|
|
|
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
def test_guia_inexistente_rechaza_con_guia_no_encontrada(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
|
|
|
|
entorno.guia_existe = False
|
|
|
|
|
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
|
|
|
|
(respuesta,) = entorno.respuestas
|
|
|
|
|
assert "No encontramos la guía T001-128912" in respuesta["html"]
|
|
|
|
|
assert entorno.drafts == []
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 12:08:38 -05:00
|
|
|
def test_guia_inexistente_con_hana_caido_es_servicio_fuera_de_linea_y_no_rechazo(entorno, monkeypatch):
|
2026-10-22 08:55:54 -05:00
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
|
|
|
|
entorno.guia_existe = False
|
|
|
|
|
entorno.hana_disponible = False
|
|
|
|
|
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
2026-10-22 12:08:38 -05:00
|
|
|
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"]
|
2026-10-22 08:55:54 -05:00
|
|
|
(tecnico,) = entorno.tecnicos
|
2026-10-22 12:08:38 -05:00
|
|
|
assert "HANA" in tecnico["html"] and tecnico["asunto"].startswith("SAP fuera de línea")
|
2026-10-22 08:55:54 -05:00
|
|
|
(registro,) = _registros()
|
2026-10-22 12:08:38 -05:00
|
|
|
assert registro["estado"] == "error" and registro["notificacion_estado"] == "en_espera"
|
2026-10-22 08:55:54 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_error_consultando_sap_es_error_interno_y_no_rechazo(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
|
|
|
|
entorno.sap_lanza = RuntimeError("sin conexión")
|
|
|
|
|
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
|
|
|
|
assert entorno.respuestas == [] and entorno.drafts == []
|
|
|
|
|
assert len(entorno.tecnicos) == 1
|
|
|
|
|
assert _registros()[0]["estado"] == "error"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_error_al_crear_draft_avisa_solo_al_equipo(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
2026-10-15 00:14:41 -05:00
|
|
|
entorno.error_al_crear = RuntimeError("Service Layer caído")
|
|
|
|
|
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
assert entorno.respuestas == []
|
|
|
|
|
(tecnico,) = entorno.tecnicos
|
|
|
|
|
assert "Error al crear Draft" in tecnico["html"] and "Service Layer caído" in tecnico["html"]
|
|
|
|
|
(registro,) = _registros()
|
|
|
|
|
assert registro["estado"] == "error" and registro["notificacion_estado"] == "omitida"
|
2026-10-15 00:14:41 -05:00
|
|
|
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
# ============================================================================
|
|
|
|
|
# Correos sin comprobante válido
|
|
|
|
|
# ============================================================================
|
|
|
|
|
def test_correo_con_adjuntos_que_no_son_pdf_ni_xml_se_rechaza(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
|
|
|
|
entorno.archivos = {"foto.png": b"x", "nota.docx": b"y"}
|
|
|
|
|
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
|
|
|
|
(respuesta,) = entorno.respuestas
|
|
|
|
|
assert respuesta["para"] == [SENDER]
|
|
|
|
|
assert respuesta["asunto"].startswith("Rechazado · Comprobante no recibido · ABR-")
|
|
|
|
|
html = respuesta["html"]
|
|
|
|
|
assert "No recibimos el XML ni el PDF de su comprobante" in html
|
|
|
|
|
assert "Guías revisadas" not in html # sin tabla de guías vacía
|
|
|
|
|
assert "RUC —" not in html and "Factura electrónica" not in html # sin datos de comprobante inventados
|
|
|
|
|
assert html.count("RUC") == 1 # solo el del pie (RUC de A. Berio)
|
|
|
|
|
assert "{{" not in html
|
|
|
|
|
assert entorno.tecnicos == [] and entorno.drafts == []
|
|
|
|
|
(registro,) = _registros()
|
|
|
|
|
assert registro["serie"] is None and registro["estado"] == "rechazado"
|
|
|
|
|
assert registro["notificacion_estado"] == "enviada" and registro["notificacion_destinatario"] == SENDER
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_solo_pdf_sin_xml_pide_el_xml(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
|
|
|
|
pdf = glob.glob(os.path.join(FIXTURES, "002_correo", "*.pdf"))[0]
|
|
|
|
|
entorno.archivos = {os.path.basename(pdf): open(pdf, "rb").read()}
|
|
|
|
|
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
|
|
|
|
(respuesta,) = entorno.respuestas
|
|
|
|
|
assert "Falta el archivo XML de su comprobante" in respuesta["html"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_correo_sin_adjuntos_no_se_responde(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
|
|
|
|
entorno.archivos = {}
|
|
|
|
|
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
|
|
|
|
assert entorno.correos == [] and _registros() == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("sender, subject, automatico, motivo", [
|
|
|
|
|
("no-reply@empresa.com", "Factura", False, "automática"),
|
|
|
|
|
("buzon@aberio.com", "Factura", False, "propio buzón"),
|
|
|
|
|
("persona@local", "Factura", False, "no válida"),
|
|
|
|
|
(SENDER, "Fuera de oficina", True, "automático"),
|
|
|
|
|
(SENDER, "Re: Aceptado · Factura F002-1 · ABR-261019-00042", False, "respuesta a una notificación"),
|
|
|
|
|
(SENDER, "RE: gracias", False, "respuesta a una notificación"),
|
|
|
|
|
])
|
|
|
|
|
def test_correos_sin_comprobante_a_los_que_no_se_responde(entorno, monkeypatch, sender, subject, automatico, motivo):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
|
|
|
|
entorno.archivos = {"foto.png": b"x"}
|
|
|
|
|
|
|
|
|
|
_procesar(entorno, sender=sender, subject=subject, es_automatico=automatico)
|
|
|
|
|
|
|
|
|
|
assert entorno.correos == []
|
|
|
|
|
(registro,) = _registros()
|
|
|
|
|
assert registro["notificacion_estado"] == "omitida" and motivo in registro["notificacion_error"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_limite_de_respuestas_por_remitente(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
|
|
|
|
entorno.archivos = {"foto.png": b"x"}
|
|
|
|
|
|
|
|
|
|
for n in range(4):
|
|
|
|
|
_procesar(entorno, id_=f"correo-{n}", carpeta=entorno.descarga.parent / f"d{n}")
|
|
|
|
|
|
|
|
|
|
assert [r["notificacion_estado"] for r in _registros()] == ["enviada", "enviada", "enviada", "omitida"]
|
|
|
|
|
assert len(entorno.respuestas) == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Remitente y registro
|
|
|
|
|
# ============================================================================
|
|
|
|
|
def test_remitente_desconocido_crea_el_draft_pero_no_responde(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
|
|
|
|
|
2026-10-15 00:14:41 -05:00
|
|
|
_procesar(entorno, sender="Desconocido")
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
assert len(entorno.drafts) == 1
|
|
|
|
|
assert entorno.respuestas == [] and len(entorno.tecnicos) == 1
|
|
|
|
|
assert _registros()[0]["notificacion_estado"] == "omitida"
|
2026-10-15 00:14:41 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_correo_ya_procesado_se_salta(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setattr(database_utilities, "get_existing_ids", lambda ids: ["abc"])
|
|
|
|
|
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
assert entorno.descargas == 0 and entorno.drafts == [] and entorno.correos == []
|
2026-10-15 00:14:41 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_guarda_resultados_y_draft_en_disco(entorno):
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
|
|
|
|
nombres = os.listdir(entorno.salida)
|
|
|
|
|
assert any(n.startswith("xml_resultado_F002-2450") for n in nombres)
|
|
|
|
|
assert any(n.startswith("draft_sapb1_F002-2450") for n in nombres)
|
2026-10-19 23:15:23 -05:00
|
|
|
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
def test_aceptado_deja_el_resultado_y_la_notificacion_registrados(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
2026-10-19 23:15:23 -05:00
|
|
|
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
|
|
|
|
(registro,) = _registros()
|
2026-10-22 08:55:54 -05:00
|
|
|
assert (registro["estado"], registro["doc_entry"], registro["tipo"]) == ("aceptado", 777, "01")
|
|
|
|
|
assert (registro["serie"], registro["numero"], registro["ruc_emisor"]) == ("F002", "2450", "10060028686")
|
|
|
|
|
assert registro["notificacion_estado"] == "enviada" and registro["notificacion_destinatario"] == SENDER
|
2026-10-19 23:15:23 -05:00
|
|
|
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
def test_modo_prueba_registra_a_quien_se_envio_de_verdad(entorno):
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
|
|
|
|
assert _registros()[0]["notificacion_destinatario"] == ", ".join(EQUIPO)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rechazo_guarda_las_observaciones_completas(entorno):
|
2026-10-19 23:15:23 -05:00
|
|
|
entorno.line_total_sap = TOTAL_LINEA + 50
|
|
|
|
|
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
|
|
|
|
(registro,) = _registros()
|
2026-10-22 08:55:54 -05:00
|
|
|
(observacion,) = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"]
|
|
|
|
|
assert observacion["codigo"] == "precio_no_coincide"
|
|
|
|
|
assert {"etiqueta": "Importe esperado", "valor": "S/ 674.38"} in observacion["filas"]
|
2026-10-19 23:15:23 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comprobante_reenviado_conserva_el_codigo_y_se_advierte(entorno, caplog):
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
(primero,) = _registros()
|
|
|
|
|
|
|
|
|
|
with caplog.at_level(logging.WARNING, logger="pipeline"):
|
2026-10-22 08:55:54 -05:00
|
|
|
_procesar(entorno, id_="otro-correo", carpeta=entorno.descarga.parent / "descarga2")
|
2026-10-19 23:15:23 -05:00
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
(registro,) = _registros() # sigue habiendo una sola fila
|
2026-10-19 23:15:23 -05:00
|
|
|
assert registro["codigo_seguimiento"] == primero["codigo_seguimiento"]
|
|
|
|
|
assert registro["veces_recibido"] == 2
|
|
|
|
|
assert any("ya recibido antes" in r.getMessage() for r in caplog.records)
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 08:55:54 -05:00
|
|
|
def test_si_falla_el_seguimiento_igual_se_crea_el_draft_pero_no_se_responde(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
|
|
|
|
|
2026-10-19 23:15:23 -05:00
|
|
|
def roto(*a, **k):
|
|
|
|
|
raise sqlite3.OperationalError("database is locked")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(seguimiento, "registrar_comprobante", roto)
|
|
|
|
|
monkeypatch.setattr(seguimiento, "actualizar_resultado", roto)
|
|
|
|
|
|
|
|
|
|
_procesar(entorno)
|
|
|
|
|
|
|
|
|
|
assert len(entorno.drafts) == 1
|
2026-10-22 08:55:54 -05:00
|
|
|
assert len(entorno.tecnicos) == 1 and entorno.respuestas == [] # sin código no se responde al proveedor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_un_fallo_inesperado_en_una_factura_no_tumba_el_ciclo(entorno, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
|
|
|
|
monkeypatch.setattr(pipeline.draft_sap, "build_sap_draft", lambda *a, **k: 1 / 0)
|
|
|
|
|
|
|
|
|
|
_procesar(entorno) # no debe lanzar excepción
|
|
|
|
|
|
|
|
|
|
assert entorno.drafts == [] and entorno.respuestas == []
|
|
|
|
|
assert "ZeroDivisionError" in entorno.tecnicos[0]["html"]
|
|
|
|
|
assert _registros()[0]["estado"] == "error"
|
2026-10-22 12:08:38 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# 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
|