versión 1.2.1: la diferencia de importes con SAP se redondea a 2 decimales

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>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-22 13:07:28 -05:00
1 parent ca1550bced
commit 2b1a583fa1
11 files changed
+95 -17

No files matched your search

+7 -1
View File
@@ -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.**
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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.
+3 -1
View File
@@ -30,7 +30,7 @@ flowchart TD
Q --> R["Construir guias_lt[]<br>para cada guía:<br>line_total, quantity,<br>price_after_vat_sum,<br>warehouse_code, zona, weight1"]
R --> S["Comparar total_linea (XML)<br>vs sap_total (FL*)"]
S --> T{"abs(diff) < 0.01<br>y ninguna guía falló?"}
S --> T{"importes_coinciden:<br>diferencia redondeada a 2 decimales ≤ 0.01<br>y ninguna guía falló?"}
T -->|Sí| U["ok = True<br>draft_quantity = int(Quantity máx FL*)<br>draft_price = suma PriceAfterVAT FL*"]
T -->|No| V["ok = False<br>draft_quantity = int(cantidad XML)<br>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.
+4 -3
View File
@@ -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"
+3 -1
View File
@@ -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"<tr><td>09{g['serie']}-{g['numero']}</td><td>{g['valor_sap']:.2f}</td></tr>"
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 (
+16
View File
@@ -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
+1 -1
View File
@@ -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 = [
+18 -5
View File
@@ -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):
+7
View File
@@ -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
+31
View File
@@ -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