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