3 Commits
Author SHA1 Message Date
GeorgeandClaude Sonnet 5.5 1d23e87b9b versión 1.2.2: horarios nocturnos unificados en dos tramos
HORARIOS pasa a 5 bloques que siguen cubriendo las 24 horas: 00:00-06:30 cada
30 minutos (antes 00:00-05:00 cada 60 s y 05:00-06:30 cada 30 min) y
18:30-24:00 cada 60 minutos (antes dos bloques iguales). El día no cambia.
De madrugada un correo puede esperar hasta 30 minutos y los reintentos por
SAP fuera de línea, uno por ciclo, van igual de espaciados.

Se ordena el formato de la lista, test_horarios fija el primer y el último
minuto de cada bloque (y que nunca se devuelva None en el día), y se
actualizan docs/main.md, la guía de despliegue y el CHANGELOG.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:36:48 -05:00
GeorgeandClaude Sonnet 5.5 6030320822 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>
2026-10-22 14:56:34 -05:00
GeorgeandClaude Sonnet 5.5 50324cca6e se elimina tools/aberio.http (llevaba escrita una contraseña de SAP)
Era una colección de peticiones de prueba al Service Layer con usuario,
contraseña y base de producción en texto plano.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:15:35 -05:00
14 changed files with 138 additions and 47 deletions

No files matched your search

+7 -2
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.1`). 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.2`). La versión vigente está en `pyproject.toml` y se muestra en el log al arrancar.
## [1.2.2] — 2026-10-22
### Cambiado
- **Horarios del demonio:** la noche pasa a dos tramos, `00:00–06:30` cada 30 minutos y `18:30–24:00` cada 60 (antes: `00:00–05:00` cada 60 segundos, `05:00–06:30` cada 30 minutos, `18:30–21:30` y `21:30–24:00` cada 60). El día sigue igual. De madrugada un correo puede esperar hasta 30 minutos y los reintentos por SAP fuera de línea (uno por ciclo) van igual de espaciados. Los 5 bloques siguen cubriendo las 24 horas sin huecos.
## [1.2.1] — 2026-10-22
@@ -41,7 +46,7 @@ Las versiones siguen [SemVer](https://semver.org/lang/es/) y se publican como et
- `tools/documento.json` (token de ngrok sin uso). **El token sigue en el historial de git: revóquelo.**
### Seguridad
- Pendiente de rotar: la contraseña de SAP y el token de ngrok, presentes en el historial de git (`tools/aberio.http`, `tools/documento.json`).
- Se elimina `tools/aberio.http`, que llevaba escrita una contraseña de SAP, y se reescribe el historial de git para enmascarar esa contraseña y el token de ngrok. **Aun así deben rotarse**: el historial anterior pudo haberse copiado en otros equipos o servidores.
## [1.1.0]
+4 -3
View File
@@ -47,13 +47,13 @@ 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.1`); 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.2`); el historial está en [`CHANGELOG.md`](CHANGELOG.md).
## Pruebas
```bash
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`.
@@ -85,7 +85,7 @@ Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, e
| `database_utilities.py`, `seguimiento.py`, `email_utilities.py` | SQLite local; códigos de seguimiento y resultado por comprobante; ZIP y filtrado de adjuntos |
| `logger_manager.py`, `config_validator.py`, `pipeline_utils.py` | Logging, validación del `.env`, utilidades |
| `deploy/` | Scripts de PowerShell para instalar el servicio de Windows con NSSM |
| `tools/` | Scripts manuales: verificar conexiones, vista previa de las plantillas, reprocesar correos, regenerar los correos de prueba, `aberio.http` |
| `tools/` | Scripts manuales: verificar conexiones, vista previa de las plantillas, reprocesar correos, regenerar los correos de prueba |
| `tests/` | Suite pytest, correos de ejemplo (`tests/fixtures/`) y resultados esperados (`tests/golden/`) |
## Documentación
@@ -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/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/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 |
+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.1 # siempre una versión etiquetada, no main
git checkout v1.2.2 # 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.1)`, 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.2)`, 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.1)`,
Siempre a una **versión etiquetada** (ver [CHANGELOG](../CHANGELOG.md)):
```powershell
.\deploy\actualizar.ps1 -Version v1.2.1
.\deploy\actualizar.ps1 -Version v1.2.0 # volver a la anterior
.\deploy\actualizar.ps1 -Version v1.2.2
.\deploy\actualizar.ps1 -Version v1.2.1 # 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.
+2
View File
@@ -51,6 +51,8 @@ Ninguna función lanza excepciones: todas registran un `ERROR` en el log y devue
## 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).
- 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 (`?`).
+8 -2
View File
@@ -30,7 +30,7 @@
| `email_utilities.py` | 55 | Utilitarios de adjuntos (ZIP, filtrado) |
| `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
├── persistencia.py → sunat_schema, pdf_utilities
├── 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
├── logger_manager.py
+6 -8
View File
@@ -4,7 +4,7 @@
```
main.py
├── Constantes: HORARIOS (7 bloques que cubren las 24 horas)
├── Constantes: HORARIOS (5 bloques que cubren las 24 horas)
├── obtener_intervalo(dt) → (intervalo, modo) | None
├── segundos_hasta_proximo_inicio(dt) → int
├── non_negative_int(value) → int (validador argparse)
@@ -81,17 +81,15 @@ main()
| Desde | Hasta | Intervalo | Modo |
|---------|---------|-----------|----------|
| 00:00 | 05:00 | 60s | rápido |
| 05:00 | 06:30 | 1800s | full |
| 00:00 | 06:30 | 1800s | full |
| 06:30 | 13:15 | 300s | rápido |
| 13:15 | 14:00 | 1800s | full |
| 14:00 | 18:30 | 300s | rápido |
| 18:30 | 21:30 | 3600s | full |
| 21:30 | 24:00 | 3600s | full |
| 18:30 | 24:00 | 3600s | full |
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; desde la 1.2.2 la noche son solo dos tramos (00:00–06:30 cada 30 minutos y 18:30–24:00 cada 60). De madrugada un correo puede esperar hasta 30 minutos, y los reintentos por SAP fuera de línea (uno por ciclo) van igual de espaciados.
## Entry point (línea 126-134)
## Entry point
```
if __name__ == "__main__":
@@ -100,7 +98,7 @@ if __name__ == "__main__":
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;
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()
└── Tras load_dotenv(), config_validator.verificar_configuracion():
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_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 |
| `_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
- **Archivo:** `pipeline.py` (línea 255-308, función anidada en `_procesar_un_correo`)
- **Librería:** `pdf_utilities.py` (línea 1-464)
- **Archivo:** `documentos.py`, función `procesar_pdfs(email, download_folder)`; la llama `pipeline._procesar_un_correo`
- **Librería:** `pdf_utilities.py`
---
@@ -11,7 +11,7 @@
```mermaid
flowchart TD
A[_procesar_pdfs] --> B[Iterar adjuntos del email]
A[procesar_pdfs] --> B[Iterar adjuntos del email]
B --> C{Es .pdf?}
C -- No --> B
C -- Sí --> D[extraer_datos_pdf]:::highlight
@@ -37,9 +37,9 @@ flowchart TD
## 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"]:
│ │
+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
- **Archivo:** `pipeline.py` (línea 310-463, función anidada en `_procesar_un_correo`)
- **Librería:** `sunat_schema.py` (línea 1-720)
- **Archivo:** `documentos.py`, función `procesar_xmls(email, download_folder, pdf_cache)`; la llama `pipeline._procesar_un_correo`
- **Librería:** `sunat_schema.py`
---
@@ -11,7 +11,7 @@
```mermaid
flowchart TD
A[_procesar_xmls] --> B[Iterar adjuntos del email]
A[procesar_xmls] --> B[Iterar adjuntos del email]
B --> C{Es .xml?}
C -- No --> B
C -- Sí --> D[extraer_datos_xml]:::highlight
@@ -23,7 +23,7 @@ flowchart TD
CDR2 --> B
CDR -- No --> G[Renombrar XML a nombre canónico]
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
I --> J[Asignar guías de remisión por línea]:::highlight
J --> K[Agregar a lista de documentos]
@@ -40,9 +40,9 @@ flowchart TD
## 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"]:
│ │
@@ -67,7 +67,7 @@ _procesar_xmls(pdf_cache) → list[dict]
│ │ └── 2. Fallback: mismo nombre que el XML renombrado, con extensión .pdf
│ │ └── 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.
│ │ 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
```
### 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:
@@ -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.
### 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.
- 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.
- **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`).
+2 -4
View File
@@ -17,13 +17,11 @@ logger = logging.getLogger(__name__)
# (hora inicio, min inicio, hora fin, min fin, intervalo en segundos, modo). El fin no se incluye.
# Los bloques cubren las 24 horas sin huecos ni solapes (lo comprueba tests/test_horarios.py).
HORARIOS = [
(0, 0, 5, 0, 60, "rapido"), # 00:00 - 05:00 -> cada 60 segundos
(5, 0, 6, 30, 1800, "full"), # 05:00 - 06:30 -> cada 30 minutos
(0, 0, 6, 30, 1800, "full"), # 00:00 - 06:30 -> cada 30 minutos
(6, 30, 13, 15, 300, "rapido"), # 06:30 - 13:15 -> cada 5 minutos
(13, 15, 14, 0, 1800, "full"), # 13:15 - 14:00 -> cada 30 minutos
(14, 0, 18, 30, 300, "rapido"), # 14:00 - 18:30 -> cada 5 minutos
(18, 30, 21, 30, 3600, "full"), # 18:30 - 21:30 -> cada 60 minutos
(21, 30, 24, 0, 3600, "full"), # 21:30 - 24:00 -> cada 60 minutos
(18, 30, 24, 0, 3600, "full"), # 18:30 - 24:00 -> cada 60 minutos
]
# ============================================================================
def obtener_intervalo(dt: datetime) -> tuple[int, str] | None:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "descarga_factura_transporte"
version = "1.2.1"
version = "1.2.2"
description = "Descarga y procesamiento de facturas de transporte desde Office365"
requires-python = ">=3.11"
dependencies = [
+16 -5
View File
@@ -23,11 +23,22 @@ def test_los_bloques_estan_ordenados_y_se_encadenan():
assert inicios[1:] == fines[:-1] # cada bloque empieza donde termina el anterior
def test_los_huecos_de_antes_ahora_tienen_horario():
assert _intervalo(21, 29) == (3600, "full") and _intervalo(21, 30) == (3600, "full")
assert _intervalo(23, 59) == (3600, "full")
assert _intervalo(5, 0) == (1800, "full") and _intervalo(6, 29) == (1800, "full")
assert _intervalo(6, 30) == (300, "rapido")
def test_los_limites_de_cada_bloque():
"""Primer y último minuto de cada bloque, con su intervalo."""
esperado = [
((0, 0), (1800, "full")), ((6, 29), (1800, "full")), # madrugada
((6, 30), (300, "rapido")), ((13, 14), (300, "rapido")), # mañana
((13, 15), (1800, "full")), ((13, 59), (1800, "full")), # mediodía
((14, 0), (300, "rapido")), ((18, 29), (300, "rapido")), # tarde
((18, 30), (3600, "full")), ((23, 59), (3600, "full")), # noche
]
for (hora, minuto), valor in esperado:
assert _intervalo(hora, minuto) == valor, f"{hora:02d}:{minuto:02d}"
def test_nunca_se_devuelve_none_en_el_dia():
"""``obtener_intervalo`` devuelve None fuera de horario: con los bloques actuales no debe pasar nunca."""
assert all(_intervalo(h, m) is not None for h in range(24) for m in range(60))
def test_la_version_del_proyecto_sale_de_pyproject():