Compare commits
2
Commits
50324cca6e
...
v1.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d23e87b9b | ||
|
|
6030320822 |
No files matched your search
+6
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (`?`).
|
||||
+12
-6
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
@@ -54,11 +54,17 @@ Módulos que `pipeline.py` orquesta (todos usan `pipeline_utils.py`):
|
||||
|
||||
```
|
||||
pipeline.py
|
||||
├── adjuntos.py → outlook_utilities
|
||||
├── documentos.py → sunat_schema, pdf_utilities
|
||||
├── persistencia.py → sunat_schema, pdf_utilities
|
||||
├── draft_sap.py → sapb1_manager, hana_manager
|
||||
└── notificaciones.py
|
||||
├── adjuntos.py → outlook_utilities
|
||||
├── documentos.py → sunat_schema, pdf_utilities
|
||||
├── persistencia.py → sunat_schema, pdf_utilities
|
||||
├── draft_sap.py → sapb1_manager, hana_manager
|
||||
├── 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
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user