La comparación abs(a - b) < 0.01 no era fiable por la coma flotante: el mismo céntimo se aceptaba o rechazaba según el importe (431.41 contra 431.40 se rechazaba y 624.39 contra 624.38 se aceptaba). pipeline_utils.importes_coinciden redondea la diferencia a 2 decimales, lo que muestra un comprobante, y admite hasta 1 céntimo. Se usa en la validación de precios y en la comparación del valor referencial. Con datos reales de SAP (49 líneas) ningún resultado cambió. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
294 lines
13 KiB
Python
294 lines
13 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
|
|
|
|
|
|
@pytest.mark.parametrize("total_xml, total_sap, coincide", [
|
|
# un céntimo de diferencia: antes se aceptaba o se rechazaba según el ruido de la coma flotante
|
|
(624.38, 624.39, True), (624.39, 624.38, True),
|
|
(431.40, 431.41, True), (170.29, 170.30, True), # estos dos la regla vieja los rechazaba
|
|
(828.73, 828.74, True), (828.74, 828.73, True),
|
|
(100.00, 100.01, True), (0.50, 0.51, True), (1.00, 1.01, True), (10.01, 10.00, True),
|
|
# menos de un céntimo, o igual
|
|
(100.00, 100.00, True), (100.005, 100.00, True), (1203.94, 1203.9399, True),
|
|
# dos céntimos o más: no coincide
|
|
(100.00, 100.02, False), (100.02, 100.00, False), (624.38, 624.40, False), (624.38, 674.38, False),
|
|
])
|
|
def test_tolerancia_de_un_centimo_es_determinista(sap, total_xml, total_sap, coincide):
|
|
sap["dn"] = _delivery_note(line_total=total_sap)
|
|
factura = {"lineas": [{"cantidad": 1, "total_linea": total_xml, "guias_remision": [_guia()]}]}
|
|
|
|
todas_coinciden, resultados = draft_sap.validate_prices(factura)
|
|
|
|
assert todas_coinciden is coincide and resultados[0]["ok"] is coincide
|
|
|
|
|
|
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
|
|
|