Files
descarga_factura_transporte/tests/test_draft_sap.py
T
GeorgeandClaude Sonnet 5.5 f0ad6aa78e 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>
2026-10-22 12:08:38 -05:00

281 lines
12 KiB
Python

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"
vencimiento = (hoy + timedelta(days=30)).strftime("%Y-%m-%d")
assert draft["DocumentInstallments"] == [ # la cuota vence en la fecha de vencimiento, no en la de emisión
{"DueDate": vencimiento, "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["DueDate"] == neto["DueDate"] == draft["DocDueDate"] # ambas cuotas vencen en el vencimiento
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"]
assert draft["DocumentInstallments"][0]["DueDate"] == 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]
# ============================================================================
# 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