From 2b1a583fa1e58732cc5aa38aae1b14827e076797 Mon Sep 17 00:00:00 2001 From: George Date: Thu, 22 Oct 2026 13:07:28 -0500 Subject: [PATCH] =?UTF-8?q?versi=C3=B3n=201.2.1:=20la=20diferencia=20de=20?= =?UTF-8?q?importes=20con=20SAP=20se=20redondea=20a=202=20decimales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 8 +++++++- README.md | 2 +- docs/despliegue_windows.md | 8 ++++---- docs/validar_precios.md | 4 +++- draft_sap.py | 7 ++++--- notificaciones.py | 4 +++- pipeline_utils.py | 16 ++++++++++++++++ pyproject.toml | 2 +- tests/test_draft_sap.py | 23 ++++++++++++++++++----- tests/test_notificaciones.py | 7 +++++++ tests/test_pipeline_utils.py | 31 +++++++++++++++++++++++++++++++ 11 files changed, 95 insertions(+), 17 deletions(-) create mode 100644 tests/test_pipeline_utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 17d1167..279d572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Historial de cambios -Las versiones siguen [SemVer](https://semver.org/lang/es/) y se publican como etiquetas de git (`v1.2.0`). La versión vigente está en `pyproject.toml` y se muestra en el log al arrancar. +Las versiones siguen [SemVer](https://semver.org/lang/es/) y se publican como etiquetas de git (`v1.2.1`). La versión vigente está en `pyproject.toml` y se muestra en el log al arrancar. + +## [1.2.1] — 2026-10-22 + +### Corregido +- **Tolerancia de importes:** la diferencia entre el importe de la factura y el de SAP se redondea a 2 decimales (lo que muestra un comprobante) y se admite hasta 1 céntimo (`pipeline_utils.importes_coinciden`). Antes se comparaba `abs(a - b) < 0.01` sin redondear, y por la coma flotante el mismo céntimo se aceptaba o se rechazaba según el importe: por ejemplo 431.41 contra 431.40 se rechazaba y 624.39 contra 624.38 se aceptaba. Con datos reales de SAP, ninguna línea cambió de resultado, así que no se acepta nada que antes se rechazara con razón. También se aplica a la comparación del valor referencial del correo técnico. ## [1.2.0] — 2026-10-22 @@ -31,6 +36,7 @@ Las versiones siguen [SemVer](https://semver.org/lang/es/) y se publican como et - `pyproject.toml` y `requirements.txt` alineados, con las dependencias directas fijadas. - `consulta_rapida.py` y los scripts manuales pasan a `tools/`; los correos de ejemplo, a `tests/fixtures/`. + ### Eliminado - `tools/documento.json` (token de ngrok sin uso). **El token sigue en el historial de git: revóquelo.** diff --git a/README.md b/README.md index 4c4bfb2..bfae8a5 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ El demonio trabaja por bloques horarios (cada 1, 5, 30 o 60 minutos según la ho ## Despliegue -En producción corre como **servicio de Windows con NSSM**: arranca con el servidor y se reinicia solo. La carpeta [`deploy/`](deploy/) trae los scripts (instalar, actualizar a una versión etiquetada y volver atrás, desinstalar) y [`docs/despliegue_windows.md`](docs/despliegue_windows.md) la guía completa. Antes de instalar, `python tools/verificar_conexiones.py` comprueba el `.env`, Microsoft Graph, SAP, HANA y la ruta de red. Las versiones son etiquetas de git (`v1.2.0`); el historial está en [`CHANGELOG.md`](CHANGELOG.md). +En producción corre como **servicio de Windows con NSSM**: arranca con el servidor y se reinicia solo. La carpeta [`deploy/`](deploy/) trae los scripts (instalar, actualizar a una versión etiquetada y volver atrás, desinstalar) y [`docs/despliegue_windows.md`](docs/despliegue_windows.md) la guía completa. Antes de instalar, `python tools/verificar_conexiones.py` comprueba el `.env`, Microsoft Graph, SAP, HANA y la ruta de red. Las versiones son etiquetas de git (`v1.2.1`); el historial está en [`CHANGELOG.md`](CHANGELOG.md). ## Pruebas diff --git a/docs/despliegue_windows.md b/docs/despliegue_windows.md index 449d73c..9786d26 100644 --- a/docs/despliegue_windows.md +++ b/docs/despliegue_windows.md @@ -27,7 +27,7 @@ En PowerShell **como administrador**: cd C:\apps git clone https://unasgeorge.vaquita-hadar.ts.net/george/descarga_factura_transporte.git cd descarga_factura_transporte -git checkout v1.2.0 # siempre una versión etiquetada, no main +git checkout v1.2.1 # siempre una versión etiquetada, no main py -3 -m venv venv venv\Scripts\python -m pip install -r requirements.txt @@ -66,7 +66,7 @@ Get-Service descarga_factura_transporte Get-Content logs\descarga_factura_transporte.log -Wait -Tail 30 ``` -En el log, al arrancar, debe verse `🚀 Demonio iniciado … (versión 1.2.0)`, y `config_validator` anuncia el modo de notificación (`PRUEBA` o `REAL`). +En el log, al arrancar, debe verse `🚀 Demonio iniciado … (versión 1.2.1)`, y `config_validator` anuncia el modo de notificación (`PRUEBA` o `REAL`). ## Operación @@ -85,8 +85,8 @@ En el log, al arrancar, debe verse `🚀 Demonio iniciado … (versión 1.2.0)`, Siempre a una **versión etiquetada** (ver [CHANGELOG](../CHANGELOG.md)): ```powershell -.\deploy\actualizar.ps1 -Version v1.2.0 -.\deploy\actualizar.ps1 -Version v1.1.0 # volver a la anterior +.\deploy\actualizar.ps1 -Version v1.2.1 +.\deploy\actualizar.ps1 -Version v1.2.0 # volver a la anterior ``` El script detiene el servicio, hace `git checkout` de la etiqueta, instala las dependencias y arranca de nuevo (aunque algo falle, vuelve a levantar lo que había). No toca el `.env` ni `emails.db`. Se niega a actuar si hay cambios locales sin guardar. diff --git a/docs/validar_precios.md b/docs/validar_precios.md index 1e438eb..161207d 100644 --- a/docs/validar_precios.md +++ b/docs/validar_precios.md @@ -30,7 +30,7 @@ flowchart TD Q --> R["Construir guias_lt[]
para cada guía:
line_total, quantity,
price_after_vat_sum,
warehouse_code, zona, weight1"] R --> S["Comparar total_linea (XML)
vs sap_total (FL*)"] - S --> T{"abs(diff) < 0.01
y ninguna guía falló?"} + S --> T{"importes_coinciden:
diferencia redondeada a 2 decimales ≤ 0.01
y ninguna guía falló?"} T -->|Sí| U["ok = True
draft_quantity = int(Quantity máx FL*)
draft_price = suma PriceAfterVAT FL*"] T -->|No| V["ok = False
draft_quantity = int(cantidad XML)
draft_price = total_linea / cantidad"] @@ -46,3 +46,5 @@ flowchart TD - Si ninguna guía de la línea existe en SAP, el resultado lleva `label = "Sin guías SAP"` y se registra "línea saltada", pero igual queda `ok = False` (salvo que `total_linea` sea 0), por lo que `todas_coinciden` es falso y no se crea el draft. - `weight1` (peso de la primera línea de cada guía) se sigue calculando y guardando en `guias_lt[]`, pero el draft ya no lo usa. - Función: `draft_sap.validate_prices(factura)` → `(todas_coinciden, resultados)`. +- **Tolerancia:** `pipeline_utils.importes_coinciden` redondea la diferencia entre el importe de la línea del XML y el de SAP a 2 decimales (lo que muestra un comprobante) y admite hasta 1 céntimo. Comparar la diferencia sin redondear (`abs(a - b) < 0.01`) no era fiable por la coma flotante: 431.41 − 431.40 daba `0.01000000000004775` (se rechazaba) y 624.39 − 624.38 daba `0.00999999999999091` (se aceptaba), así que el mismo céntimo pasaba o no según el importe. Con datos reales de SAP, ninguna línea cambió de resultado al introducir el redondeo. Se usa también para comparar el valor referencial del correo técnico. + diff --git a/draft_sap.py b/draft_sap.py index 1cc331a..ca93e05 100644 --- a/draft_sap.py +++ b/draft_sap.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta import sapb1_manager import hana_manager -from pipeline_utils import log +from pipeline_utils import importes_coinciden, log # ============================================================================ @@ -253,7 +253,8 @@ def validate_prices(factura: dict) -> tuple[bool, list[dict]]: ``line_total``, ``quantity``, ``price_after_vat_sum``, ``warehouse_code`` y ``zona``. 3. Determina si los precios coinciden (``ok``): - ``abs(total_linea - sap_total) < 0.01``. + ``importes_coinciden(total_linea, sap_total)``: la diferencia, redondeada a + 2 decimales, no supera 1 céntimo. 4. Fija ``draft_quantity`` y ``draft_price`` de cada guía: si ``ok``, usa SAP (``int(quantity)`` y ``price_after_vat_sum``); si no, usa el XML (``int(cantidad)`` y ``total_linea / cantidad``). @@ -377,7 +378,7 @@ def validate_prices(factura: dict) -> tuple[bool, list[dict]]: sap_total = sum(item["line_total"] for item in guias_lt) sin_sap = all(item["line_total"] == 0.0 and item["quantity"] == 0.0 for item in guias_lt) - ok = all_ok and abs(total_linea - sap_total) < 0.01 + ok = all_ok and importes_coinciden(total_linea, sap_total) label = "Sin guías SAP" if sin_sap else "LineTotal (FL*) vs total_linea" grflete_str = "; ".join(f"{item['guia']['tipo']}{item['guia']['serie']}-{item['guia']['numero']}" for item in guias_lt) wh_str = ", ".join(sorted(all_wh)) if all_wh else "A-CENTRA" diff --git a/notificaciones.py b/notificaciones.py index de0d6bb..4455c38 100644 --- a/notificaciones.py +++ b/notificaciones.py @@ -1,5 +1,7 @@ """Cuerpos HTML de los correos de notificación.""" +from pipeline_utils import importes_coinciden + @@ -59,7 +61,7 @@ def build_valor_referencial_html(factura: dict, guias_valor_ref: list[dict]) -> f"09{g['serie']}-{g['numero']}{g['valor_sap']:.2f}" for g in guias_valor_ref ) - coincide = abs(valor_xml - total_sap) < 0.01 + coincide = importes_coinciden(valor_xml, total_sap) icono = "✅" if coincide else "❌" return ( diff --git a/pipeline_utils.py b/pipeline_utils.py index 70ffc76..cda8768 100644 --- a/pipeline_utils.py +++ b/pipeline_utils.py @@ -36,3 +36,19 @@ def version_del_proyecto() -> str: except (OSError, KeyError, tomllib.TOMLDecodeError): return "desconocida" + +# Diferencia máxima, en soles, entre el importe de la factura y el de SAP para darlos por iguales. +TOLERANCIA_IMPORTE = 0.01 + + +def importes_coinciden(a: float, b: float) -> bool: + """ + True si dos importes son iguales **a nivel de céntimos**, que es lo que muestra un comprobante. + + La diferencia se redondea a 2 decimales y se admite hasta ``TOLERANCIA_IMPORTE`` (1 céntimo). + Comparar la diferencia sin redondear (``abs(a - b) < 0.01``) no es fiable: con coma flotante, + 431.41 - 431.40 da 0.01000000000004775 (se rechazaba) y 624.39 - 624.38 da 0.00999999999999091 + (se aceptaba), de modo que el mismo céntimo pasaba o no según el importe. + """ + return round(abs(a - b), 2) <= TOLERANCIA_IMPORTE + diff --git a/pyproject.toml b/pyproject.toml index 5e8a6a2..4c9c868 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "descarga_factura_transporte" -version = "1.2.0" +version = "1.2.1" description = "Descarga y procesamiento de facturas de transporte desde Office365" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_draft_sap.py b/tests/test_draft_sap.py index d25a807..03bbd9a 100644 --- a/tests/test_draft_sap.py +++ b/tests/test_draft_sap.py @@ -79,11 +79,24 @@ def test_varias_guias_de_una_linea_se_suman(sap): 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 +@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): diff --git a/tests/test_notificaciones.py b/tests/test_notificaciones.py index 491412f..759ba73 100644 --- a/tests/test_notificaciones.py +++ b/tests/test_notificaciones.py @@ -44,3 +44,10 @@ def test_valor_referencial_no_coincide(): factura = {"lineas": [{"valor_referencial": 10.0}]} html = n.build_valor_referencial_html(factura, [{"serie": "T001", "numero": "1", "valor_sap": 9.0}]) assert "❌ No coincide" in html + + +def test_valor_referencial_con_un_centimo_de_diferencia_coincide(): + factura = {"lineas": [{"valor_referencial": 624.38}]} + html = n.build_valor_referencial_html(factura, [{"serie": "T001", "numero": "1", "valor_sap": 624.39}]) + assert "✅ Coincide" in html + diff --git a/tests/test_pipeline_utils.py b/tests/test_pipeline_utils.py new file mode 100644 index 0000000..c16fc84 --- /dev/null +++ b/tests/test_pipeline_utils.py @@ -0,0 +1,31 @@ +import pytest + +from pipeline_utils import TOLERANCIA_IMPORTE, importes_coinciden + + +def test_el_ruido_de_la_coma_flotante_era_el_problema(): + """Documenta por qué no se compara ``abs(a - b) < 0.01``: el mismo céntimo daba resultados distintos.""" + assert abs(431.41 - 431.40) >= 0.01 # 0.01000000000004775: la regla vieja lo rechazaba + assert abs(170.30 - 170.29) >= 0.01 # 0.01000000000001933: también + assert abs(624.39 - 624.38) < 0.01 # 0.00999999999999091: la regla vieja lo aceptaba + assert abs(828.74 - 828.73) < 0.01 # igual: el mismo céntimo, otro resultado + for a, b in [(431.41, 431.40), (170.30, 170.29), (624.39, 624.38), (828.74, 828.73)]: + assert importes_coinciden(a, b) + + +@pytest.mark.parametrize("a, b, esperado", [ + (100.00, 100.00, True), (100.00, 100.01, True), (100.01, 100.00, True), (100.004, 100.00, True), + (100.00, 100.02, False), (100.02, 100.00, False), (0.00, 0.02, False), (1500.00, 1499.98, False), + (0.0, 0.0, True), (-5.00, -5.01, True), +]) +def test_importes_coinciden(a, b, esperado): + assert importes_coinciden(a, b) is esperado + + +def test_la_comparacion_es_simetrica(): + for a, b in [(624.38, 624.39), (1.0, 1.02), (850.5, 850.49)]: + assert importes_coinciden(a, b) == importes_coinciden(b, a) + + +def test_la_tolerancia_es_un_centimo(): + assert TOLERANCIA_IMPORTE == 0.01