se reorganiza tests/ y se agrega suite pytest

Los scripts manuales pasan a tools/ y los correos de ejemplo a
tests/fixtures/. Suite de 95 pruebas: caracterización de la extracción sobre
los 46 correos (resultados esperados en tests/golden/, fuera de la carpeta
que descargar_correos_prueba borra), unitarias de draft_sap, notificaciones,
documentos, adjuntos y config_validator, y flujo completo de
_procesar_un_correo con SAP/HANA/Outlook simulados. pytest como dependencia
dev y configuración en pyproject.toml.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-20 22:03:38 -05:00
1 parent b1b6f70983
commit 1d6d869111
261 files changed
+7876 -7

No files matched your search

+232
View File
@@ -0,0 +1,232 @@
import math
from datetime import datetime, timedelta
import pytest
import draft_sap
# ============================================================================
# calcular_fecha_vencimiento
# ============================================================================
@pytest.mark.parametrize("condicion, esperado", [
(None, "2026-01-10"),
({"NumberOfAdditionalDays": 30, "NumberOfAdditionalMonths": 0}, "2026-02-09"),
({"NumberOfAdditionalDays": 0, "NumberOfAdditionalMonths": 2}, "2026-03-11"), # 2 meses = 60 días
({"NumberOfAdditionalDays": None, "NumberOfAdditionalMonths": None}, "2026-01-10"),
({"NumberOfAdditionalDays": 15, "NumberOfAdditionalMonths": 3}, "2026-01-25"), # los días priman
])
def test_calcular_fecha_vencimiento(condicion, esperado):
assert draft_sap.calcular_fecha_vencimiento("2026-01-10", condicion) == esperado
# ============================================================================
# validate_prices
# ============================================================================
def _delivery_note(line_total=100.0, pav=1.5, qty=10, almacen="A-X"):
return [{
"Series": "1", "DocNum": "9", "U_IDC_DESTINO": "Z1",
"DocumentLines": [
{"ItemCode": "FL10007", "LineTotal": line_total, "PriceAfterVAT": pav,
"Quantity": qty, "WarehouseCode": almacen, "Weight1": 5},
{"ItemCode": "OTRO", "LineTotal": 999, "PriceAfterVAT": 9, "Quantity": 9}, # no FL\d: se ignora
],
}]
def _guia(numero="1"):
return {"tipo": "09", "serie": "T001", "numero": numero}
@pytest.fixture
def sap(monkeypatch):
"""SAP simulado: devuelve la misma DeliveryNote para toda guía."""
estado = {"dn": _delivery_note()}
monkeypatch.setattr(draft_sap.sapb1_manager, "get_zonas", lambda: [{"Code": "Z1", "Name": "ZONA1"}])
monkeypatch.setattr(draft_sap.sapb1_manager, "get_delivery_notes", lambda **k: estado["dn"])
return estado
def test_precios_coinciden_usa_valores_de_sap(sap):
factura = {"lineas": [{"cantidad": 1, "total_linea": 100.0, "guias_remision": [_guia()]}]}
ok, resultados = draft_sap.validate_prices(factura)
assert ok is True
item = resultados[0]["guias_lt"][0]
assert resultados[0]["ok"] is True
assert (item["draft_quantity"], item["draft_price"]) == (10, 1.5) # de SAP, no del XML
assert item["zona"] == "ZONA1"
assert item["warehouse_code"] == "A-X"
assert item["weight1"] == 5.0
def test_precios_no_coinciden_usa_valores_del_xml(sap):
factura = {"lineas": [{"cantidad": 4, "total_linea": 90.0, "guias_remision": [_guia()]}]}
ok, resultados = draft_sap.validate_prices(factura)
assert ok is False
item = resultados[0]["guias_lt"][0]
assert (item["draft_quantity"], item["draft_price"]) == (4, 22.5) # 90 / 4
def test_varias_guias_de_una_linea_se_suman(sap):
sap["dn"] = _delivery_note(line_total=60.0)
factura = {"lineas": [{"cantidad": 1, "total_linea": 120.0, "guias_remision": [_guia("1"), _guia("2")]}]}
ok, resultados = draft_sap.validate_prices(factura)
assert ok is True
assert resultados[0]["sap_price"] == 120.0
assert len(resultados[0]["guias_lt"]) == 2
def test_tolerancia_de_un_centimo(sap):
factura = {"lineas": [{"cantidad": 1, "total_linea": 100.005, "guias_remision": [_guia()]}]}
assert draft_sap.validate_prices(factura)[0] is True
factura["lineas"][0]["total_linea"] = 100.02
assert draft_sap.validate_prices(factura)[0] is False
def test_cantidad_cero_no_se_valida(sap):
factura = {"lineas": [{"cantidad": 0, "total_linea": 100.0, "guias_remision": [_guia()]}]}
ok, resultados = draft_sap.validate_prices(factura)
assert ok is False
assert resultados[0]["label"] == "Cantidad cero, sin validar"
assert resultados[0]["guias_lt"] == []
def test_linea_sin_guia_no_se_valida(sap):
factura = {"lineas": [{"cantidad": 1, "total_linea": 100.0, "guias_remision": []}]}
ok, resultados = draft_sap.validate_prices(factura)
assert ok is False
assert resultados[0]["label"] == "Sin guía de referencia"
def test_guia_inexistente_en_sap_queda_sin_validar(sap):
sap["dn"] = []
factura = {"lineas": [{"cantidad": 1, "total_linea": 100.0, "guias_remision": [_guia()]}]}
ok, resultados = draft_sap.validate_prices(factura)
assert ok is False
assert resultados[0]["label"] == "Sin guías SAP"
def test_error_consultando_sap_invalida_la_linea(monkeypatch, sap):
def falla(**k):
raise RuntimeError("sin conexión")
monkeypatch.setattr(draft_sap.sapb1_manager, "get_delivery_notes", falla)
factura = {"lineas": [{"cantidad": 1, "total_linea": 0.0, "guias_remision": [_guia()]}]}
ok, resultados = draft_sap.validate_prices(factura)
assert ok is False # aunque 0 == 0, el error obliga a ok=False
assert resultados[0]["guias_lt"][0]["line_total"] == 0.0
# ============================================================================
# build_sap_draft
# ============================================================================
def _factura(fecha_emision, **extra):
base = {
"serie": "F002", "numero": "486", "tipo": "01", "total_factura": 118.0,
"fecha_emision": fecha_emision, "ruc_emisor": "20537929520",
"razon_social_emisor": "TRANSPORTES SA", "tiene_detraccion": False,
"ruta_red_pdf": "\\\\srv\\a.pdf", "ruta_red_xml": "\\\\srv\\a.xml",
"lineas": [
{"descripcion": "Servicio A", "valor_referencial": 10.5},
{"descripcion": "Servicio B", "valor_referencial": 4.5},
],
}
base.update(extra)
return base
def _resultados(draft_price=1.23456789, draft_quantity=3):
item = {"guia": {"tipo": "09", "serie": "T001", "numero": "100"},
"draft_price": draft_price, "draft_quantity": draft_quantity, "warehouse_code": "A-X"}
return [{"guias_lt": [item]}, {"guias_lt": []}]
@pytest.fixture
def condicion_pago(monkeypatch):
monkeypatch.setattr(draft_sap.sapb1_manager, "obtener_condicion_pago",
lambda card_code: {"NumberOfAdditionalDays": 30})
def test_draft_cabecera_y_linea(condicion_pago):
hoy = datetime.now()
draft = draft_sap.build_sap_draft(_factura(hoy.strftime("%Y-%m-%d")), _resultados())
assert draft["CardCode"] == "PL20537929520"
assert draft["NumAtCard"] == "01F002-486"
assert draft["DocDate"] == hoy.strftime("%Y-%m-%d")
assert draft["DocDueDate"] == (hoy + timedelta(days=30)).strftime("%Y-%m-%d")
assert draft["U_IDC_VALREF"] == 15.0 # suma del XML
assert draft["U_IDC_NUMGR"] == "09T001-100"
assert draft["U_VS_ANEXO_PDF"] == "\\\\srv\\a.pdf"
assert len(draft["DocumentLines"]) == 1 # la línea sin guías se omite
linea = draft["DocumentLines"][0]
assert linea["Quantity"] == 3
assert linea["PriceAfterVAT"] == math.trunc(1.23456789 * 1_000_000) / 1_000_000 == 1.234567
assert linea["U_IDC_GRFLETE"] == "09T001-100"
assert linea["WarehouseCode"] == "A-X"
assert draft["DocumentInstallments"] == [
{"DueDate": hoy.strftime("%Y-%m-%d"), "Total": 118.0, "InstallmentId": 1,
"U_VS_TIPOCUOTA": "N", "U_VS_TIPOSERVICIO": ""},
]
def test_item_code_segun_mes_de_emision(condicion_pago):
hoy = datetime.now()
mes_anterior = (hoy.replace(day=1) - timedelta(days=1)).strftime("%Y-%m-%d")
actual = draft_sap.build_sap_draft(_factura(hoy.strftime("%Y-%m-%d")), _resultados())
anterior = draft_sap.build_sap_draft(_factura(mes_anterior), _resultados())
assert actual["DocumentLines"][0]["ItemCode"] == "C103002"
assert anterior["DocumentLines"][0]["ItemCode"] == "C103002-A"
def test_draft_con_detraccion_genera_dos_cuotas(condicion_pago):
fecha = datetime.now().strftime("%Y-%m-%d")
factura = _factura(fecha, tiene_detraccion=True, codigo_detraccion="004",
porcentaje_detraccion=12, monto_detraccion=11.2)
draft = draft_sap.build_sap_draft(factura, _resultados())
detraccion, neto = draft["DocumentInstallments"]
assert (detraccion["Total"], detraccion["U_VS_TIPOCUOTA"], detraccion["U_VS_TIPOSERVICIO"]) == (12, "D", "00402")
assert (neto["Total"], neto["U_VS_TIPOCUOTA"]) == (106.0, "N") # 118 - ceil(11.2)
assert draft["U_VS_AFEDET"] == "Y"
assert draft["U_VS_CODSER"] == "00402"
assert draft["U_VS_PORDET"] == 12
def test_draft_sin_guias_validadas_queda_sin_lineas(condicion_pago):
fecha = datetime.now().strftime("%Y-%m-%d")
draft = draft_sap.build_sap_draft(_factura(fecha), [{"guias_lt": []}, {"guias_lt": []}])
assert draft["DocumentLines"] == []
assert draft["U_IDC_NUMGR"] == ""
def test_draft_sin_condicion_de_pago_vence_el_dia_de_contabilizacion(monkeypatch):
monkeypatch.setattr(draft_sap.sapb1_manager, "obtener_condicion_pago", lambda c: None)
fecha = datetime.now().strftime("%Y-%m-%d")
draft = draft_sap.build_sap_draft(_factura(fecha), _resultados())
assert draft["DocDueDate"] == draft["DocDate"]
# ============================================================================
# obtener_valor_referencial_sap
# ============================================================================
def test_valor_referencial_consulta_una_vez_por_guia(monkeypatch):
llamadas = []
monkeypatch.setattr(draft_sap.hana_manager, "obtener_valor_referencial",
lambda serie, numero: llamadas.append((serie, numero)) or 7.5)
g = {"tipo": "09", "serie": "T001", "numero": "1"}
factura = {"lineas": [
{"guias_remision": [g]},
{"guias_remision": [g, {"tipo": "09", "serie": "T001", "numero": "2"}]},
]}
resultado = draft_sap.obtener_valor_referencial_sap(factura)
assert llamadas == [("T001", "1"), ("T001", "2")]
assert [r["valor_sap"] for r in resultado] == [7.5, 7.5]