se actualiza la documentación: sapb1_manager, nombres de funciones y cifras

- Nueva página docs/sapb1_manager.md: sesión, funciones, clasificación de
  errores (SapNoDisponible frente a rechazo de SAP), búsqueda de facturas
  existentes y cómo se ubica una guía.
- procesar_pdfs.md y procesar_xmls.md usaban los nombres anteriores a la
  división de pipeline.py (_procesar_pdfs, _ruta_carpeta...) y números de
  línea que envejecen; ahora apuntan a documentos.py.
- inventario.md: árbol de dependencias con los módulos nuevos.
- pipeline.md: _procesar_factura, importes_coinciden, hana_disponible y
  buscar_factura_existente; validar_precios.md, hana_manager.md, main.md y
  procesar_xmls.md con las notas que faltaban; README sin la cifra de pruebas
  vieja.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-22 14:56:34 -05:00
1 parent 50324cca6e
commit 6030320822
9 files changed
+102 -23

No files matched your search

+2 -1
View File
@@ -53,7 +53,7 @@ En producción corre como **servicio de Windows con NSSM**: arranca con el servi
```bash ```bash
pip install -e ".[dev]" # instala pytest pip install -e ".[dev]" # instala pytest
pytest # ~95 pruebas, unos 3 segundos; no tocan SAP, HANA ni Outlook pytest # más de 300 pruebas, unos 4 segundos; no tocan SAP, HANA ni Outlook
``` ```
- **Extracción** (`tests/test_extraccion_correos.py`): corre la extracción real sobre cada correo de `tests/fixtures/correos_prueba/` y la compara con `tests/golden/<caso>.json`. Esos resultados esperados fijan el comportamiento actual; no prueban que sea correcto. Si cambias la extracción a propósito, revisa el diff y regenéralos con `UPDATE_GOLDEN=1 pytest tests/test_extraccion_correos.py`. - **Extracción** (`tests/test_extraccion_correos.py`): corre la extracción real sobre cada correo de `tests/fixtures/correos_prueba/` y la compara con `tests/golden/<caso>.json`. Esos resultados esperados fijan el comportamiento actual; no prueban que sea correcto. Si cambias la extracción a propósito, revisa el diff y regenéralos con `UPDATE_GOLDEN=1 pytest tests/test_extraccion_correos.py`.
@@ -103,4 +103,5 @@ Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, e
| [`docs/despliegue_windows.md`](docs/despliegue_windows.md) | Instalación como servicio de Windows con NSSM, operación y rollback | | [`docs/despliegue_windows.md`](docs/despliegue_windows.md) | Instalación como servicio de Windows con NSSM, operación y rollback |
| [`docs/configurar_msal.md`](docs/configurar_msal.md) | Cómo obtener `CLIENT_ID`, `TENANT_ID` y `CLIENT_SECRET` en Azure | | [`docs/configurar_msal.md`](docs/configurar_msal.md) | Cómo obtener `CLIENT_ID`, `TENANT_ID` y `CLIENT_SECRET` en Azure |
| [`docs/outlook_utilities.md`](docs/outlook_utilities.md) | Cliente de Microsoft Graph | | [`docs/outlook_utilities.md`](docs/outlook_utilities.md) | Cliente de Microsoft Graph |
| [`docs/sapb1_manager.md`](docs/sapb1_manager.md) | SAP Service Layer: drafts, guías, facturas existentes y manejo de caídas |
| [`docs/hana_manager.md`](docs/hana_manager.md) | Consultas directas a SAP HANA | | [`docs/hana_manager.md`](docs/hana_manager.md) | Consultas directas a SAP HANA |
+2
View File
@@ -51,6 +51,8 @@ Ninguna función lanza excepciones: todas registran un `ERROR` en el log y devue
## Notas ## Notas
- `draft_sap.hana_disponible()` usa `get_connection()` como comprobación de salud: abre y cierra una conexión. Se invoca antes de rechazar por "guía no encontrada", porque `get_delivery` devuelve lista vacía tanto si la guía no existe como si HANA está caído.
- Cada llamada abre y cierra su propia conexión (no hay pool). - Cada llamada abre y cierra su propia conexión (no hay pool).
- El número se busca con `LIKE '%numero%'`: es una coincidencia parcial, así que un número corto podría coincidir con más de un documento de la misma serie. - El número se busca con `LIKE '%numero%'`: es una coincidencia parcial, así que un número corto podría coincidir con más de un documento de la misma serie.
- El esquema se interpola en el SQL desde `HANA_SCHEMA` (`.env`); los valores de búsqueda sí van como parámetros (`?`). - El esquema se interpola en el SQL desde `HANA_SCHEMA` (`.env`); los valores de búsqueda sí van como parámetros (`?`).
+8 -2
View File
@@ -30,7 +30,7 @@
| `email_utilities.py` | 55 | Utilitarios de adjuntos (ZIP, filtrado) | | `email_utilities.py` | 55 | Utilitarios de adjuntos (ZIP, filtrado) |
| `logger_manager.py` | 150 | Logging a archivo rotado (30 días) + consola + captura de `print()` | | `logger_manager.py` | 150 | Logging a archivo rotado (30 días) + consola + captura de `print()` |
Documentación por módulo: despliegue → [despliegue_windows.md](despliegue_windows.md), `respuesta_proveedor.py`/`observaciones.py`/`plantillas.py` → [respuesta_proveedor.md](respuesta_proveedor.md), `seguimiento.py` → [seguimiento.md](seguimiento.md), `outlook_utilities.py` → [outlook_utilities.md](outlook_utilities.md), `hana_manager.py` → [hana_manager.md](hana_manager.md), validación y draft → [validar_precios.md](validar_precios.md) y [construir_draft.md](construir_draft.md). Documentación por módulo: `sapb1_manager.py` → [sapb1_manager.md](sapb1_manager.md), despliegue → [despliegue_windows.md](despliegue_windows.md), `respuesta_proveedor.py`/`observaciones.py`/`plantillas.py` → [respuesta_proveedor.md](respuesta_proveedor.md), `seguimiento.py` → [seguimiento.md](seguimiento.md), `outlook_utilities.py` → [outlook_utilities.md](outlook_utilities.md), `hana_manager.py` → [hana_manager.md](hana_manager.md), validación y draft → [validar_precios.md](validar_precios.md) y [construir_draft.md](construir_draft.md).
--- ---
@@ -58,7 +58,13 @@ pipeline.py
├── documentos.py → sunat_schema, pdf_utilities ├── documentos.py → sunat_schema, pdf_utilities
├── persistencia.py → sunat_schema, pdf_utilities ├── persistencia.py → sunat_schema, pdf_utilities
├── draft_sap.py → sapb1_manager, hana_manager ├── draft_sap.py → sapb1_manager, hana_manager
└── notificaciones.py ├── notificaciones.py (correo técnico para el equipo)
├── observaciones.py (motivos de rechazo y sus textos)
├── respuesta_proveedor.py → plantillas.py, observaciones.py, outlook_utilities, seguimiento.py
└── seguimiento.py → database_utilities
main.py → config_validator.py (valida el .env al arrancar)
```
tools/consulta_rapida.py tools/consulta_rapida.py
├── logger_manager.py ├── logger_manager.py
+2 -2
View File
@@ -91,7 +91,7 @@ main()
Los bloques cubren las 24 horas sin huecos ni solapes (lo comprueba `tests/test_horarios.py`). El modo (`rápido`/`full`) solo se registra en el log: `run_pipeline` se ejecuta igual en ambos. Antes de la versión 1.2.0 los tramos 21:30–00:00 y 05:00–06:30 no tenían bloque y el demonio dormía hasta las 06:30. Los bloques cubren las 24 horas sin huecos ni solapes (lo comprueba `tests/test_horarios.py`). El modo (`rápido`/`full`) solo se registra en el log: `run_pipeline` se ejecuta igual en ambos. Antes de la versión 1.2.0 los tramos 21:30–00:00 y 05:00–06:30 no tenían bloque y el demonio dormía hasta las 06:30.
## Entry point (línea 126-134) ## Entry point
``` ```
if __name__ == "__main__": if __name__ == "__main__":
@@ -100,7 +100,7 @@ if __name__ == "__main__":
rotación diaria, 30 días de historial) + consola, y redirige print()/stderr rotación diaria, 30 días de historial) + consola, y redirige print()/stderr
al mismo log (los módulos del pipeline ya usan logger con nivel explícito; al mismo log (los módulos del pipeline ya usan logger con nivel explícito;
los print() restantes se registran como INFO). los print() restantes se registran como INFO).
2. logger.info(...) banner de inicio, incluyendo logger_manager.LOG_FILE 2. logger.info(...) banner de inicio: fecha, versión del proyecto (pyproject.toml) y logger_manager.LOG_FILE
3. main() 3. main()
└── Tras load_dotenv(), config_validator.verificar_configuracion(): └── Tras load_dotenv(), config_validator.verificar_configuracion():
termina con código 1 si falta alguna variable obligatoria del .env termina con código 1 si falta alguna variable obligatoria del .env
+6
View File
@@ -121,6 +121,12 @@ flowchart TD
| `build_validation_html(resultados)` | `notificaciones` | Genera tabla HTML de validación de precios | | `build_validation_html(resultados)` | `notificaciones` | Genera tabla HTML de validación de precios |
| `build_valor_referencial_html(factura, guias_valor_ref)` | `notificaciones` | Genera tabla HTML comparando valor referencial XML vs SAP | | `build_valor_referencial_html(factura, guias_valor_ref)` | `notificaciones` | Genera tabla HTML comparando valor referencial XML vs SAP |
| `build_notification_body(status, factura, validation_table, valor_referencial_table, **kwargs)` | `notificaciones` | Arma cuerpo del email de notificación | | `build_notification_body(status, factura, validation_table, valor_referencial_table, **kwargs)` | `notificaciones` | Arma cuerpo del email de notificación |
| `_procesar_factura(email, token, email_addr, factura, output_folder)` | `pipeline` | Valida una factura contra SAP, crea el draft y notifica; devuelve si hay que reintentar el correo |
| `importes_coinciden(a, b)` | `pipeline_utils` | Compara importes a nivel de céntimos: diferencia redondeada a 2 decimales, hasta 1 céntimo |
| `version_del_proyecto()` | `pipeline_utils` | Versión de `pyproject.toml`, para el log de arranque |
| `hay_error_de_consulta(resultados_val)` | `draft_sap` | Detalle del primer error de consulta a SAP que dejó `validate_prices`, o `None` |
| `hana_disponible()` | `draft_sap` | Abre y cierra una conexión a HANA: descarta que una guía "no encontrada" sea una caída |
| `buscar_factura_existente(ruc, tipo, serie, numero)` | `sapb1_manager` | ¿La factura ya está en SAP (factura de compra no anulada o draft)? Ver [sapb1_manager.md](sapb1_manager.md) |
--- ---
+6 -6
View File
@@ -1,9 +1,9 @@
# _procesar_pdfs — Extracción de datos desde PDFs # procesar_pdfs — Extracción de datos desde PDFs
## Ubicación ## Ubicación
- **Archivo:** `pipeline.py` (línea 255-308, función anidada en `_procesar_un_correo`) - **Archivo:** `documentos.py`, función `procesar_pdfs(email, download_folder)`; la llama `pipeline._procesar_un_correo`
- **Librería:** `pdf_utilities.py` (línea 1-464) - **Librería:** `pdf_utilities.py`
--- ---
@@ -11,7 +11,7 @@
```mermaid ```mermaid
flowchart TD flowchart TD
A[_procesar_pdfs] --> B[Iterar adjuntos del email] A[procesar_pdfs] --> B[Iterar adjuntos del email]
B --> C{Es .pdf?} B --> C{Es .pdf?}
C -- No --> B C -- No --> B
C -- Sí --> D[extraer_datos_pdf]:::highlight C -- Sí --> D[extraer_datos_pdf]:::highlight
@@ -37,9 +37,9 @@ flowchart TD
## Paso a paso ## Paso a paso
``` ```
_procesar_pdfs() → dict (pdf_cache) procesar_pdfs(email, download_folder) → dict (pdf_cache)
│ │
├── carpeta = _ruta_carpeta(download_folder) ├── carpeta = ruta_carpeta(download_folder)
│ │
├── Por cada adjunto en email["adjuntos"]: ├── Por cada adjunto en email["adjuntos"]:
│ │ │ │
+12 -12
View File
@@ -1,9 +1,9 @@
# _procesar_xmls — Extracción y cruce de datos desde XML # procesar_xmls — Extracción y cruce de datos desde XML
## Ubicación ## Ubicación
- **Archivo:** `pipeline.py` (línea 310-463, función anidada en `_procesar_un_correo`) - **Archivo:** `documentos.py`, función `procesar_xmls(email, download_folder, pdf_cache)`; la llama `pipeline._procesar_un_correo`
- **Librería:** `sunat_schema.py` (línea 1-720) - **Librería:** `sunat_schema.py`
--- ---
@@ -11,7 +11,7 @@
```mermaid ```mermaid
flowchart TD flowchart TD
A[_procesar_xmls] --> B[Iterar adjuntos del email] A[procesar_xmls] --> B[Iterar adjuntos del email]
B --> C{Es .xml?} B --> C{Es .xml?}
C -- No --> B C -- No --> B
C -- Sí --> D[extraer_datos_xml]:::highlight C -- Sí --> D[extraer_datos_xml]:::highlight
@@ -23,7 +23,7 @@ flowchart TD
CDR2 --> B CDR2 --> B
CDR -- No --> G[Renombrar XML a nombre canónico] CDR -- No --> G[Renombrar XML a nombre canónico]
G --> H[Construir nombre PDF canónico y buscarlo] G --> H[Construir nombre PDF canónico y buscarlo]
H --> RED[_copiar_a_red]:::highlight H --> RED[copiar_a_red]:::highlight
RED --> I[Cruzar documentos_referencia desde PDF si faltan]:::highlight RED --> I[Cruzar documentos_referencia desde PDF si faltan]:::highlight
I --> J[Asignar guías de remisión por línea]:::highlight I --> J[Asignar guías de remisión por línea]:::highlight
J --> K[Agregar a lista de documentos] J --> K[Agregar a lista de documentos]
@@ -40,9 +40,9 @@ flowchart TD
## Paso a paso ## Paso a paso
``` ```
_procesar_xmls(pdf_cache) → list[dict] procesar_xmls(email, download_folder, pdf_cache) → list[dict]
│ │
├── carpeta = _ruta_carpeta(download_folder) ├── carpeta = ruta_carpeta(download_folder)
│ │
├── Por cada adjunto en email["adjuntos"]: ├── Por cada adjunto en email["adjuntos"]:
│ │ │ │
@@ -67,7 +67,7 @@ _procesar_xmls(pdf_cache) → list[dict]
│ │ └── 2. Fallback: mismo nombre que el XML renombrado, con extensión .pdf │ │ └── 2. Fallback: mismo nombre que el XML renombrado, con extensión .pdf
│ │ └── data["ruta_pdf"] = ruta encontrada o None │ │ └── data["ruta_pdf"] = ruta encontrada o None
│ │ │ │
│ ├── _copiar_a_red(data, _log) │ ├── copiar_a_red(data)
│ │ └── Copia PDF/XML a la ruta de red (RUTA_RED_FACTURACION), si está configurada. │ │ └── Copia PDF/XML a la ruta de red (RUTA_RED_FACTURACION), si está configurada.
│ │ Guarda data["ruta_red_pdf"] / data["ruta_red_xml"] (ver docs/pipeline.md). │ │ Guarda data["ruta_red_pdf"] / data["ruta_red_xml"] (ver docs/pipeline.md).
│ │ │ │
@@ -215,7 +215,7 @@ pdf_cache[nombre_canonico].items[] → extraer_referencias()
└── guia_transportista → ref tipo 31 └── guia_transportista → ref tipo 31
``` ```
### 2. Asignar guías por línea (dentro de `_procesar_xmls`) ### 2. Asignar guías por línea (dentro de `procesar_xmls`)
Prioridad para asignar `guias_remision` y `guia_transportista` a cada línea de la factura, en el orden en que realmente se aplican en el código: Prioridad para asignar `guias_remision` y `guia_transportista` a cada línea de la factura, en el orden en que realmente se aplican en el código:
@@ -228,9 +228,9 @@ Prioridad para asignar `guias_remision` y `guia_transportista` a cada línea de
Después de procesar todos los XML del correo, si una factura referencia una **guía transportista (31)** y esa guía (presente como documento independiente en el mismo correo) referencia a su vez una o más **GRE (09)**, esas GRE se propagan a las líneas de la factura que aún no tengan `guias_remision` asignadas. Después de procesar todos los XML del correo, si una factura referencia una **guía transportista (31)** y esa guía (presente como documento independiente en el mismo correo) referencia a su vez una o más **GRE (09)**, esas GRE se propagan a las líneas de la factura que aún no tengan `guias_remision` asignadas.
### 4. `_completar_guias` (paso posterior, fuera de `_procesar_xmls`) ### 4. `completar_guias` (paso posterior, fuera de `procesar_xmls`)
Si tras los pasos anteriores alguna línea sigue sin `guias_remision`, `_completar_guias` (ver `docs/pipeline.md`) las copia desde los `items[]` del PDF cache, parseando el texto con regex (`{T/V/E}{serie}-{numero}`). Si tras los pasos anteriores alguna línea sigue sin `guias_remision`, `completar_guias` (ver `docs/pipeline.md`) las copia desde los `items[]` del PDF cache, parseando el texto con regex (`{T/V/E}{serie}-{numero}`).
--- ---
@@ -276,4 +276,4 @@ Si tras los pasos anteriores alguna línea sigue sin `guias_remision`, `_complet
] ]
``` ```
`ruta_red_pdf`/`ruta_red_xml` solo aparecen si `RUTA_RED_FACTURACION` está configurada en `.env` y la copia fue exitosa; se usan luego en `_build_sap_draft` para llenar `U_VS_ANEXO_PDF`/`U_VS_ANEXO_XML` (ver `docs/pipeline.md`). `ruta_red_pdf`/`ruta_red_xml` solo aparecen si `RUTA_RED_FACTURACION` está configurada en `.env`, es una ruta absoluta en el sistema operativo (una ruta UNC fuera de Windows no lo es: no se copia) y la copia fue exitosa; se usan luego en `draft_sap.build_sap_draft` para llenar `U_VS_ANEXO_PDF`/`U_VS_ANEXO_XML` (ver `docs/pipeline.md`).
+63
View File
@@ -0,0 +1,63 @@
# sapb1_manager.py — SAP Business One Service Layer
Cliente del Service Layer de SAP B1: crea los drafts de factura de compra, ubica las guías de remisión para validar precios, busca si una factura ya existe y obtiene datos maestros. Si el Service Layer no encuentra una guía, recurre a HANA (`hana_manager.py`).
## Configuración (`.env`)
| Variable | Para qué | Por defecto |
|----------|----------|-------------|
| `SAP_B1_URL` | URL del Service Layer (por ejemplo `https://servidor:50000/b1s/v1`) | — |
| `SAP_B1_COMPANY`, `SAP_B1_USER`, `SAP_B1_PASSWORD` | Base de datos y credenciales | — |
| `SAP_B1_TIMEOUT` | Segundos de espera por petición | `15` |
| `SAP_B1_VERIFY_SSL` | Verificar el certificado (`true`) | `false` |
## Sesión
La sesión se abre sola en la primera petición (`login`) y se guarda en un diccionario del módulo. Ante un HTTP 401 se vuelve a iniciar sesión **una vez** y se repite la petición. No hay cierre automático: `logout()` existe pero el demonio no lo llama. Al ser un estado global del módulo, no está pensado para varios hilos.
## Funciones
| Función | Qué hace |
|---------|----------|
| `forward_to_sapb1(method, endpoint_path, json_data, params, extra_headers)` | Punto único de salida hacia SAP. Registra cada petición y clasifica los errores (ver abajo). |
| `create_draft(payload)` | `POST /Drafts`. Devuelve el draft creado (con `DocEntry`). |
| `buscar_factura_existente(ruc_emisor, tipo, serie, numero)` | ¿La factura ya está en SAP? Ver más abajo. |
| `get_delivery_notes(tipo, serie, numero)` | Documento completo de una guía, con las líneas `FL*` de las que se validan los precios. |
| `obtener_condicion_pago(card_code)` | Condición de pago del proveedor (para el vencimiento). |
| `get_zonas()` | Maestro `U_IDC_ZONAS` (activas), para resolver el nombre de la zona de destino. |
| `get_purchase_invoice(doc_entry)` | `GET /PurchaseInvoices(DocEntry)`. |
| `login()` / `logout()` | Inicio y cierre de sesión. |
## Errores: caída o rechazo
Todo error del Service Layer se lanza como `ConnectionError`, pero se distinguen dos casos:
| Excepción | Cuándo | Qué hace el pipeline |
|-----------|--------|----------------------|
| **`SapNoDisponible`** (subclase de `ConnectionError`) | Timeout, sin conexión, **HTTP 5xx**, o login imposible por esos motivos | Es una **caída**: aviso "En cola" al proveedor y reintento del correo |
| `ConnectionError` | HTTP 4xx (SAP rechazó la petición), credenciales inválidas | Es un error nuestro: se avisa solo al equipo |
Así, una caída nunca se confunde con "la guía no existe" ni se le comunica al proveedor como rechazo. Detalle del flujo en [respuesta_proveedor.md](respuesta_proveedor.md).
## ¿La factura ya existe en SAP?
`buscar_factura_existente` busca por proveedor (`CardCode = 'PL' + RUC`), tipo, serie y número (`U_BPP_MDTD`, `U_BPP_MDSD`, `U_BPP_MDCD`, los mismos campos que llena el draft) en dos entidades, en este orden:
1. `PurchaseInvoices`, solo las **no anuladas** (`Cancelled eq 'tNO'`).
2. `Drafts` de factura de compra (`DocObjectCode eq 'oPurchaseInvoices'`): una factura cuyo draft ya se creó aunque nadie lo haya contabilizado.
Devuelve `{"entidad", "DocEntry", "DocNum"}` del primer documento hallado, o `None`. Los valores se escapan para el filtro OData (las comillas simples se duplican). Si SAP no responde lanza `SapNoDisponible`: no se puede afirmar que la factura no exista.
## Cómo se ubica una guía (`get_delivery_notes`)
1. **Service Layer:** `GET /DeliveryNotes` con `Cancelled eq 'tNO'`, el tipo, la serie y `contains(U_BPP_MDCD, número)` (coincidencia parcial del número).
2. **HANA**, si Service Layer no devolvió nada o falló: consulta `ODLN` y, si no hay resultados, `OWTR`.
3. Por cada documento hallado se hace `GET` a su entidad: `DeliveryNotes` ya trae las líneas `FL*`; en `StockTransfers` (traslados, `OWTR`) se lee `U_IDC_NUMOV` y se obtiene la **orden de venta** (`Orders`) asociada, que es la que lleva precios.
Si Service Layer **cayó** y HANA no aporta nada, se lanza `SapNoDisponible` en vez de devolver una lista vacía; igual si la caída ocurre al leer el documento. Una lista vacía significa solo "la guía no existe".
## Notas
- `obtener_condicion_pago` devuelve `None` ante **cualquier** error (incluida una caída): en ese caso el vencimiento del draft queda igual a la fecha de contabilización.
- `get_zonas` no captura errores: si Service Layer no responde, lanza la excepción (y `validate_prices` la deja pasar como `SapNoDisponible`).
- `SAP_B1_VERIFY_SSL` es `false` por defecto; en producción conviene activarlo con el certificado de la CA.
+1
View File
@@ -47,4 +47,5 @@ flowchart TD
- `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. - `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)`. - 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. - **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.
- **Caída de SAP vs. guía inexistente:** `validate_prices` deja pasar `SapNoDisponible` (timeout, sin conexión, HTTP 5xx) en vez de convertirla en un error de la guía; el pipeline la trata como servicio fuera de línea. Otros errores de consulta quedan en `guias_lt[].error` (ver `draft_sap.hay_error_de_consulta`) y se tratan como error interno. Una guía sin datos en SAP solo se reporta como "no encontrada" si HANA responde (`draft_sap.hana_disponible`).