- 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>
281 lines
12 KiB
Python
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
|
|
|