- 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>
189 lines
7.9 KiB
Python
189 lines
7.9 KiB
Python
import json
|
|
import os
|
|
|
|
import pytest
|
|
|
|
import plantillas
|
|
from plantillas import PlantillaError, renderizar
|
|
|
|
EJEMPLOS = os.path.join(plantillas.CARPETA_PLANTILLAS, "ejemplos.json")
|
|
|
|
|
|
def _ejemplos() -> dict:
|
|
with open(EJEMPLOS, encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
# ============================================================================
|
|
# Motor de plantillas
|
|
# ============================================================================
|
|
def test_variable_con_escape_de_html():
|
|
assert renderizar("{{x}}", {"x": 'A & B <i> "c" \'d\''}) == "A & B <i> "c" 'd'"
|
|
|
|
|
|
def test_variable_none_queda_vacia_y_numeros_se_convierten():
|
|
assert renderizar("[{{a}}][{{b}}]", {"a": None, "b": 12.5}) == "[][12.5]"
|
|
|
|
|
|
def test_variable_faltante_lanza_error():
|
|
with pytest.raises(PlantillaError, match="Falta 'x'"):
|
|
renderizar("{{x}}", {})
|
|
|
|
|
|
def test_seccion_faltante_tambien_lanza_error():
|
|
with pytest.raises(PlantillaError, match="Falta 'opcional'"):
|
|
renderizar("{{#opcional}}x{{/opcional}}", {})
|
|
with pytest.raises(PlantillaError, match="Falta 'opcional'"):
|
|
renderizar("{{^opcional}}x{{/opcional}}", {})
|
|
|
|
|
|
def test_seccion_de_lista_se_repite_y_ve_el_contexto_externo():
|
|
texto = "{{titulo}}:{{#items}}[{{titulo}}-{{n}}]{{/items}}"
|
|
assert renderizar(texto, {"titulo": "T", "items": [{"n": 1}, {"n": 2}]}) == "T:[T-1][T-2]"
|
|
|
|
|
|
def test_lista_vacia_no_muestra_nada_y_la_invertida_si():
|
|
texto = "{{#items}}x{{/items}}{{^items}}vacío{{/items}}"
|
|
assert renderizar(texto, {"items": []}) == "vacío"
|
|
assert renderizar(texto, {"items": [{}]}) == "x"
|
|
|
|
|
|
@pytest.mark.parametrize("valor, visible", [(True, True), (False, False), (None, False), ("", False), ("a", True), (0, False)])
|
|
def test_seccion_booleana(valor, visible):
|
|
texto = "{{#f}}SI{{/f}}{{^f}}NO{{/f}}"
|
|
assert renderizar(texto, {"f": valor}) == ("SI" if visible else "NO")
|
|
|
|
|
|
def test_seccion_con_texto_muestra_una_vez_y_la_variable_sigue_disponible():
|
|
"""Caso de ``fecha_estimada_pago``: la sección usa un texto como condición y lo imprime dentro."""
|
|
texto = "{{#pago}}Pago: {{pago}}{{/pago}}"
|
|
assert renderizar(texto, {"pago": "18/11/2026"}) == "Pago: 18/11/2026"
|
|
assert renderizar(texto, {"pago": None}) == ""
|
|
|
|
|
|
def test_secciones_con_el_mismo_nombre_seguidas_en_una_linea():
|
|
"""Caso de ``conforme``: ``{{#conforme}}..{{/conforme}}{{^conforme}}..{{/conforme}}``."""
|
|
texto = "{{#guias}}<{{#ok}}Conforme{{/ok}}{{^ok}}No coincide{{/ok}}>{{/guias}}"
|
|
assert renderizar(texto, {"guias": [{"ok": True}, {"ok": False}]}) == "<Conforme><No coincide>"
|
|
|
|
|
|
def test_secciones_anidadas():
|
|
texto = "{{#obs}}{{t}}({{#filas}}{{k}}={{v}};{{/filas}}){{/obs}}"
|
|
contexto = {"obs": [{"t": "A", "filas": [{"k": "x", "v": 1}, {"k": "y", "v": 2}]}, {"t": "B", "filas": []}]}
|
|
assert renderizar(texto, contexto) == "A(x=1;y=2;)B()"
|
|
|
|
|
|
def test_seccion_sin_cerrar_o_cierre_sin_abrir():
|
|
with pytest.raises(PlantillaError, match="no se cierra"):
|
|
renderizar("{{#a}}x", {"a": True})
|
|
with pytest.raises(PlantillaError, match="no corresponde"):
|
|
renderizar("x{{/a}}", {"a": True})
|
|
with pytest.raises(PlantillaError, match="no corresponde"):
|
|
renderizar("{{#a}}{{#b}}x{{/a}}{{/b}}", {"a": True, "b": True})
|
|
|
|
|
|
def test_llaves_sueltas_de_css_no_se_confunden_con_etiquetas():
|
|
assert renderizar("a{color:red;}{{x}}", {"x": "1"}) == "a{color:red;}1"
|
|
|
|
|
|
def test_nombres_usados():
|
|
assert plantillas.nombres_usados("{{a}}{{#b}}{{c}}{{/b}}{{^d}}x{{/d}}") == {"a", "b", "c", "d"}
|
|
|
|
|
|
# ============================================================================
|
|
# Plantillas reales con los ejemplos de plantilla/ejemplos.json
|
|
# ============================================================================
|
|
ESCENARIOS = sorted(_ejemplos())
|
|
|
|
|
|
def _claves(obj) -> set[str]:
|
|
if isinstance(obj, dict):
|
|
return set(obj) | {c for v in obj.values() for c in _claves(v)}
|
|
if isinstance(obj, list):
|
|
return {c for v in obj for c in _claves(v)}
|
|
return set()
|
|
|
|
|
|
@pytest.mark.parametrize("escenario", ESCENARIOS)
|
|
def test_todos_los_escenarios_se_renderizan_completos(escenario):
|
|
ejemplo = _ejemplos()[escenario]
|
|
html = plantillas.renderizar_plantilla(ejemplo["plantilla"], ejemplo["contexto"])
|
|
|
|
assert "{{" not in html and "}}" not in html # nada sin resolver
|
|
assert ejemplo["contexto"]["codigo_seguimiento"] in html
|
|
assert html.lstrip().startswith("<!DOCTYPE html>")
|
|
|
|
|
|
@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))
|
|
ejemplos = [e["contexto"] for e in _ejemplos().values() if e["plantilla"] == nombre]
|
|
|
|
assert ejemplos, f"No hay ejemplos para la plantilla {nombre}"
|
|
cubiertos = set().union(*(_claves(c) for c in ejemplos)) # el ejemplo sin guías no trae las claves de cada guía
|
|
assert usados <= cubiertos, f"Ningún ejemplo trae: {sorted(usados - cubiertos)}"
|
|
for contexto in ejemplos:
|
|
sobran = _claves(contexto) - usados
|
|
assert not sobran, f"Sobran en el ejemplo: {sorted(sobran)}"
|
|
|
|
|
|
def test_razon_social_con_caracteres_especiales_se_escapa():
|
|
ejemplo = _ejemplos()["rechazado_una_observacion"]
|
|
html = plantillas.renderizar_plantilla("rechazado", ejemplo["contexto"])
|
|
|
|
assert "<Lima>" in html and ""EL RÁPIDO"" in html
|
|
assert "<Lima>" not in html
|
|
|
|
|
|
def test_aceptado_muestra_pago_estimado_solo_si_hay_fecha():
|
|
ejemplos = _ejemplos()
|
|
con = plantillas.renderizar_plantilla("aceptado", ejemplos["aceptado_con_pago"]["contexto"])
|
|
sin = plantillas.renderizar_plantilla("aceptado", ejemplos["aceptado_sin_pago"]["contexto"])
|
|
|
|
assert "Pago estimado" in con and "18/11/2026" in con
|
|
assert "Pago estimado" not in sin
|
|
|
|
|
|
def test_rechazado_numera_las_observaciones_y_marca_las_guias():
|
|
html = plantillas.renderizar_plantilla("rechazado", _ejemplos()["rechazado_dos_observaciones"]["contexto"])
|
|
|
|
assert "OBSERVACIÓN 1" in html and "OBSERVACIÓN 2" in html
|
|
assert "OBSERVACIÓN 3" not in html
|
|
assert html.count("Conforme") == 1 and html.count("No coincide") == 1
|
|
assert "Rechazado · 2 observaciones" in html
|
|
|
|
|
|
def test_enlaces_de_contacto_llevan_el_codigo_y_el_comprobante():
|
|
contexto = _ejemplos()["rechazado_dos_observaciones"]["contexto"]
|
|
html = plantillas.renderizar_plantilla("rechazado", contexto)
|
|
|
|
assert f"mailto:{contexto['contacto_correo']}?subject=Consulta%20F002-486%20ABR-261019-00045" in html
|
|
assert f'href="mailto:{contexto["envio_correo"]}"' in html
|
|
|
|
|
|
def test_rechazo_sin_comprobante_oculta_lo_que_no_existe():
|
|
ejemplo = _ejemplos()["rechazado_sin_comprobante"]
|
|
html = plantillas.renderizar_plantilla("rechazado", ejemplo["contexto"])
|
|
|
|
assert "Guías revisadas" not in html # sin tabla de guías vacía
|
|
assert "Comprobante</td>" not in html # sin la fila "Comprobante —"
|
|
assert "RUC —" not in html and html.count("RUC") == 1 # solo el RUC del pie (empresa)
|
|
assert "No pudimos registrar su comprobante" in html
|
|
|
|
|
|
def test_rechazo_con_comprobante_si_muestra_guias_ruc_y_fila_comprobante():
|
|
html = plantillas.renderizar_plantilla("rechazado", _ejemplos()["rechazado_dos_observaciones"]["contexto"])
|
|
|
|
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
|
|
|