fase 1: registro de comprobantes con código de seguimiento
Nueva tabla comprobantes (creada en init_db) y módulo seguimiento.py: cada comprobante recibe un código ABR-AAMMDD-NNNNN que conserva si reaparece en otro correo, más su resultado (aceptado/rechazado/error) y el estado de la notificación. El pipeline lo registra por factura sin bloquear el flujo si el seguimiento falla. Las pruebas usan una BD temporal para no tocar emails.db. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d6d869111
commit
30e9b160f8
9 files changed
+563
-6
No files matched your search
@@ -61,7 +61,7 @@ Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, e
|
|||||||
| `adjuntos/` | PDF/XML descargados (`DOWNLOAD_FOLDER`) |
|
| `adjuntos/` | PDF/XML descargados (`DOWNLOAD_FOLDER`) |
|
||||||
| `resultado/` | JSON de datos extraídos y de drafts, para depuración (`OUTPUT_FOLDER`) |
|
| `resultado/` | JSON de datos extraídos y de drafts, para depuración (`OUTPUT_FOLDER`) |
|
||||||
| `logs/` | Log con rotación diaria y 30 días de historial. `LOG_LEVEL` controla el nivel |
|
| `logs/` | Log con rotación diaria y 30 días de historial. `LOG_LEVEL` controla el nivel |
|
||||||
| `emails.db` | Correos ya procesados |
|
| `emails.db` | Correos ya procesados y comprobantes con su código de seguimiento |
|
||||||
|
|
||||||
## Estructura del proyecto
|
## Estructura del proyecto
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, e
|
|||||||
| `draft_sap.py`, `notificaciones.py` | Validación de precios y construcción del Draft; cuerpo de los correos |
|
| `draft_sap.py`, `notificaciones.py` | Validación de precios y construcción del Draft; cuerpo de los correos |
|
||||||
| `outlook_utilities.py`, `sapb1_manager.py`, `hana_manager.py` | Clientes de Microsoft Graph, SAP Service Layer y SAP HANA |
|
| `outlook_utilities.py`, `sapb1_manager.py`, `hana_manager.py` | Clientes de Microsoft Graph, SAP Service Layer y SAP HANA |
|
||||||
| `sunat_schema.py`, `pdf_utilities.py` | Lectura de XML SUNAT y de PDF |
|
| `sunat_schema.py`, `pdf_utilities.py` | Lectura de XML SUNAT y de PDF |
|
||||||
| `database_utilities.py`, `email_utilities.py` | SQLite local; ZIP y filtrado de adjuntos |
|
| `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 |
|
| `logger_manager.py`, `config_validator.py`, `pipeline_utils.py` | Logging, validación del `.env`, utilidades |
|
||||||
| `tools/` | Scripts manuales: reprocesar correos, regenerar/inspeccionar los correos de prueba, `aberio.http` |
|
| `tools/` | Scripts manuales: reprocesar correos, regenerar/inspeccionar los correos de prueba, `aberio.http` |
|
||||||
| `tests/` | Suite pytest, correos de ejemplo (`tests/fixtures/`) y resultados esperados (`tests/golden/`) |
|
| `tests/` | Suite pytest, correos de ejemplo (`tests/fixtures/`) y resultados esperados (`tests/golden/`) |
|
||||||
@@ -88,5 +88,6 @@ Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, e
|
|||||||
| [`docs/procesar_pdfs.md`](docs/procesar_pdfs.md) / [`docs/procesar_xmls.md`](docs/procesar_xmls.md) | Extracción de datos de PDF y XML |
|
| [`docs/procesar_pdfs.md`](docs/procesar_pdfs.md) / [`docs/procesar_xmls.md`](docs/procesar_xmls.md) | Extracción de datos de PDF y XML |
|
||||||
| [`docs/validar_precios.md`](docs/validar_precios.md) | Validación de precios contra SAP |
|
| [`docs/validar_precios.md`](docs/validar_precios.md) | Validación de precios contra SAP |
|
||||||
| [`docs/construir_draft.md`](docs/construir_draft.md) | Construcción del Draft SAP B1 |
|
| [`docs/construir_draft.md`](docs/construir_draft.md) | Construcción del Draft SAP B1 |
|
||||||
|
| [`docs/seguimiento.md`](docs/seguimiento.md) | Códigos de seguimiento y resultado por comprobante |
|
||||||
| [`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/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 |
|
||||||
+34
-1
@@ -9,7 +9,9 @@ DB_NAME = "emails.db"
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
"""
|
"""
|
||||||
Inicializa la base de datos SQLite y crea la tabla de correos si no existe.
|
Inicializa la base de datos SQLite y crea las tablas si no existen:
|
||||||
|
``emails`` (correos ya procesados) y ``comprobantes`` (códigos de
|
||||||
|
seguimiento y resultado de cada comprobante, ver ``seguimiento.py``).
|
||||||
"""
|
"""
|
||||||
conn = sqlite3.connect(DB_NAME)
|
conn = sqlite3.connect(DB_NAME)
|
||||||
try:
|
try:
|
||||||
@@ -27,6 +29,37 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# Un comprobante se identifica por (ruc_emisor, tipo, serie, numero). Los
|
||||||
|
# correos sin comprobante identificable llevan serie NULL y son únicos por correo.
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS comprobantes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
codigo_seguimiento TEXT UNIQUE,
|
||||||
|
email_id TEXT NOT NULL,
|
||||||
|
ruc_emisor TEXT,
|
||||||
|
tipo TEXT,
|
||||||
|
serie TEXT,
|
||||||
|
numero TEXT,
|
||||||
|
estado TEXT NOT NULL DEFAULT 'pendiente',
|
||||||
|
observaciones TEXT,
|
||||||
|
doc_entry INTEGER,
|
||||||
|
veces_recibido INTEGER NOT NULL DEFAULT 1,
|
||||||
|
notificacion_estado TEXT NOT NULL DEFAULT 'pendiente',
|
||||||
|
notificacion_error TEXT,
|
||||||
|
notificacion_fecha TEXT,
|
||||||
|
creado_en TEXT NOT NULL,
|
||||||
|
actualizado_en TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_comprobantes_documento
|
||||||
|
ON comprobantes (ruc_emisor, tipo, serie, numero) WHERE serie IS NOT NULL
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_comprobantes_correo
|
||||||
|
ON comprobantes (email_id) WHERE serie IS NULL
|
||||||
|
''')
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
+4
-3
@@ -5,7 +5,7 @@
|
|||||||
| Archivo | Líneas | Rol |
|
| Archivo | Líneas | Rol |
|
||||||
|---------|--------|-----|
|
|---------|--------|-----|
|
||||||
| `main.py` | 137 | Demonio orquestador (loop de horarios) |
|
| `main.py` | 137 | Demonio orquestador (loop de horarios) |
|
||||||
| `pipeline.py` | 233 | Orquestador del pipeline (autenticar, descargar correos, procesar un correo) |
|
| `pipeline.py` | 272 | Orquestador del pipeline (autenticar, descargar correos, procesar un correo) |
|
||||||
| `pipeline_utils.py` | 27 | `log()` (logging con indentación de árbol y `severity`) y `ruta_carpeta()` |
|
| `pipeline_utils.py` | 27 | `log()` (logging con indentación de árbol y `severity`) y `ruta_carpeta()` |
|
||||||
| `adjuntos.py` | 82 | Descarga de adjuntos (Graph) y filtrado PDF/XML |
|
| `adjuntos.py` | 82 | Descarga de adjuntos (Graph) y filtrado PDF/XML |
|
||||||
| `documentos.py` | 372 | Extracción PDF/XML, asignación de guías, copia a red |
|
| `documentos.py` | 372 | Extracción PDF/XML, asignación de guías, copia a red |
|
||||||
@@ -17,14 +17,15 @@
|
|||||||
| `sapb1_manager.py` | 401 | Comunicación con SAP B1 Service Layer |
|
| `sapb1_manager.py` | 401 | Comunicación con SAP B1 Service Layer |
|
||||||
| `outlook_utilities.py` | 249 | Comunicación con Microsoft Graph |
|
| `outlook_utilities.py` | 249 | Comunicación con Microsoft Graph |
|
||||||
| `hana_manager.py` | 141 | Comunicación con SAP HANA directa (fallback de `sapb1_manager` y valor referencial) |
|
| `hana_manager.py` | 141 | Comunicación con SAP HANA directa (fallback de `sapb1_manager` y valor referencial) |
|
||||||
| `database_utilities.py` | 117 | SQLite local (cache de correos) |
|
| `database_utilities.py` | 150 | SQLite local (cache de correos) |
|
||||||
| `tools/*_prueba.py`, `tools/completar_guias_pipeline.py`, `tools/explorar_notes_xml.py` | — | Scripts manuales sobre `tests/fixtures/correos_prueba/` (anteriores a la suite pytest) |
|
| `tools/*_prueba.py`, `tools/completar_guias_pipeline.py`, `tools/explorar_notes_xml.py` | — | Scripts manuales sobre `tests/fixtures/correos_prueba/` (anteriores a la suite pytest) |
|
||||||
| `tools/consulta_rapida.py` | 120 | Script auxiliar para reprocesar correos por ID (sets en `tools/ids_prueba.json`) |
|
| `tools/consulta_rapida.py` | 120 | Script auxiliar para reprocesar correos por ID (sets en `tools/ids_prueba.json`) |
|
||||||
|
| `seguimiento.py` | 187 | Códigos de seguimiento y resultado por comprobante (tabla `comprobantes`) |
|
||||||
| `config_validator.py` | 83 | Valida el `.env` al arrancar (obligatorias, ruta de red, avisos de SSL) |
|
| `config_validator.py` | 83 | Valida el `.env` al arrancar (obligatorias, ruta de red, avisos de SSL) |
|
||||||
| `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: `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: `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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# seguimiento.py — Códigos de seguimiento y resultado por comprobante
|
||||||
|
|
||||||
|
Cada comprobante que llega por correo recibe un **código de seguimiento** (`ABR-AAMMDD-NNNNN`, por ejemplo `ABR-261019-00042`) que se le comunicará al proveedor y que permite reconocer el comprobante si lo reenvía. El registro también guarda el resultado del proceso y el estado de la notificación.
|
||||||
|
|
||||||
|
Los datos viven en la tabla `comprobantes` de `emails.db`. El esquema lo crea `database_utilities.init_db()` con `CREATE TABLE IF NOT EXISTS`, por lo que una base existente se actualiza sola sin tocar la tabla `emails`.
|
||||||
|
|
||||||
|
## Identidad
|
||||||
|
|
||||||
|
- Un comprobante se identifica por **(RUC emisor, tipo, serie, número)**. Si vuelve a llegar, en el mismo correo o en otro, conserva su código.
|
||||||
|
- Un correo que no trae ningún comprobante identificable (sin adjuntos, sin XML válido…) se registra aparte con `registrar_correo`: una fila por correo, con RUC, serie y número en `NULL`.
|
||||||
|
- El código sale del `id` de la fila (`ABR-` + fecha + `id` con 5 dígitos), así que es único por construcción, sin reintentos ni contadores aparte.
|
||||||
|
|
||||||
|
## Tabla `comprobantes`
|
||||||
|
|
||||||
|
| Columna | Contenido |
|
||||||
|
|---------|-----------|
|
||||||
|
| `codigo_seguimiento` | Código único del comprobante |
|
||||||
|
| `email_id` | Último correo en el que llegó |
|
||||||
|
| `ruc_emisor`, `tipo`, `serie`, `numero` | Identidad del comprobante (`NULL` en las filas de correo sin comprobante) |
|
||||||
|
| `estado` | `pendiente`, `aceptado`, `rechazado` o `error` |
|
||||||
|
| `observaciones` | JSON con los motivos de rechazo o el detalle del error |
|
||||||
|
| `doc_entry` | DocEntry del draft creado en SAP |
|
||||||
|
| `veces_recibido` | Cuántos correos distintos trajeron este comprobante |
|
||||||
|
| `notificacion_estado`, `notificacion_error`, `notificacion_fecha` | `pendiente`, `enviada`, `fallida` u `omitida`, y su motivo |
|
||||||
|
| `creado_en`, `actualizado_en` | Marcas de tiempo |
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| Función | Qué hace |
|
||||||
|
|---------|----------|
|
||||||
|
| `registrar_comprobante(email_id, ruc_emisor, tipo, serie, numero)` | Crea el registro o recupera el existente. Devuelve la fila más `reenvio` (True si ya había llegado en **otro** correo). Reprocesar el mismo correo no cambia nada. |
|
||||||
|
| `registrar_correo(email_id)` | Igual, para un correo sin comprobante. Idempotente por correo. |
|
||||||
|
| `obtener(codigo)` | Devuelve el registro o `None`. |
|
||||||
|
| `actualizar_resultado(codigo, estado, observaciones=None, doc_entry=None)` | Guarda el resultado. `ValueError` si el estado no es válido, `LookupError` si no existe el código. |
|
||||||
|
| `registrar_notificacion(codigo, estado, error=None)` | Guarda el estado de la notificación. Mismas excepciones. |
|
||||||
|
|
||||||
|
## Uso en el pipeline
|
||||||
|
|
||||||
|
En `pipeline._procesar_un_correo`, cada factura se registra al empezar (`_registrar_comprobante`) y su resultado se guarda al terminar (`_registrar_resultado`):
|
||||||
|
|
||||||
|
| Resultado del proceso | `estado` | Detalle guardado |
|
||||||
|
|-----------------------|----------|------------------|
|
||||||
|
| Draft creado en SAP | `aceptado` | `doc_entry` |
|
||||||
|
| Precios no coinciden con SAP | `rechazado` | `[{"codigo": "precios_no_coinciden"}]` |
|
||||||
|
| Error al crear el draft | `error` | `[{"codigo": "error_sap", "detalle": "..."}]` |
|
||||||
|
|
||||||
|
El seguimiento **nunca impide procesar la factura**: si falla la base de datos, se registra un `WARNING` y el flujo continúa. Si el comprobante ya había llegado antes, se registra un `WARNING` con las veces recibidas y el estado anterior, pero **no se bloquea** la creación de otro draft.
|
||||||
|
|
||||||
|
## Estado actual y pendiente
|
||||||
|
|
||||||
|
- Los `observaciones` guardados hoy son mínimos. Se reemplazarán por observaciones estructuradas (título, filas, cómo corregir) cuando se definan las validaciones de rechazo.
|
||||||
|
- `registrar_correo` y `registrar_notificacion` están implementados y probados, pero el pipeline todavía no los llama: los usarán las notificaciones al proveedor.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- `database_utilities.DB_NAME` es `"emails.db"`, una ruta **relativa al directorio de trabajo**. Si el demonio se lanza desde otra carpeta, usará (y creará) otra base y los códigos ya emitidos no se encontrarían. Hoy funciona porque se ejecuta desde la raíz del proyecto.
|
||||||
|
- Las pruebas redirigen `DB_NAME` a una base temporal (fixture automático en `tests/conftest.py`) para no escribir en `emails.db`.
|
||||||
+39
@@ -21,11 +21,46 @@ import persistencia
|
|||||||
import draft_sap
|
import draft_sap
|
||||||
import notificaciones
|
import notificaciones
|
||||||
import sapb1_manager
|
import sapb1_manager
|
||||||
|
import seguimiento
|
||||||
from pipeline_utils import log
|
from pipeline_utils import log
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SEGUIMIENTO (códigos y resultado por comprobante)
|
||||||
|
# ============================================================================
|
||||||
|
def _registrar_comprobante(email: dict, factura: dict) -> dict | None:
|
||||||
|
"""
|
||||||
|
Registra la factura en ``seguimiento`` y devuelve su registro (con el código
|
||||||
|
de seguimiento), o None si falla: el seguimiento nunca debe impedir
|
||||||
|
procesar la factura.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
registro = seguimiento.registrar_comprobante(
|
||||||
|
email["id"], factura.get("ruc_emisor") or "", factura.get("tipo", "01"),
|
||||||
|
factura.get("serie", ""), factura.get("numero", ""),
|
||||||
|
)
|
||||||
|
except Exception as ex:
|
||||||
|
log(f"⚠️ No se pudo registrar el comprobante en seguimiento: {ex}", severity=logging.WARNING)
|
||||||
|
return None
|
||||||
|
|
||||||
|
log(f"🔖 Código de seguimiento: {registro['codigo_seguimiento']}")
|
||||||
|
if registro["reenvio"]:
|
||||||
|
log(f"⚠️ Comprobante ya recibido antes ({registro['veces_recibido']} veces, estado: {registro['estado']})", severity=logging.WARNING)
|
||||||
|
return registro
|
||||||
|
|
||||||
|
|
||||||
|
def _registrar_resultado(registro: dict | None, estado: str, **kwargs) -> None:
|
||||||
|
"""Guarda el resultado del comprobante; si falla, solo lo registra en el log."""
|
||||||
|
if not registro:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
seguimiento.actualizar_resultado(registro["codigo_seguimiento"], estado, **kwargs)
|
||||||
|
except Exception as ex:
|
||||||
|
log(f"⚠️ No se pudo guardar el resultado en seguimiento: {ex}", severity=logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PASO 1: Autenticación Microsoft Graph
|
# PASO 1: Autenticación Microsoft Graph
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -153,6 +188,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
|||||||
numero = factura.get("numero", "?")
|
numero = factura.get("numero", "?")
|
||||||
|
|
||||||
log(f"📄 Factura: {serie}-{numero}")
|
log(f"📄 Factura: {serie}-{numero}")
|
||||||
|
registro = _registrar_comprobante(email, factura)
|
||||||
|
|
||||||
# 12. Validar precios (consulta SAP DNs, NO requiere draft)
|
# 12. Validar precios (consulta SAP DNs, NO requiere draft)
|
||||||
todas_coinciden, resultados_val = draft_sap.validate_prices(factura)
|
todas_coinciden, resultados_val = draft_sap.validate_prices(factura)
|
||||||
@@ -179,12 +215,15 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
|||||||
sap_result = sapb1_manager.create_draft(draft)
|
sap_result = sapb1_manager.create_draft(draft)
|
||||||
doc_entry = sap_result.get("DocEntry")
|
doc_entry = sap_result.get("DocEntry")
|
||||||
log(f"✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
|
log(f"✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
|
||||||
|
_registrar_resultado(registro, "aceptado", doc_entry=doc_entry)
|
||||||
html_body = notificaciones.build_notification_body("created", factura, validation_table, valor_referencial_table, doc_entry=doc_entry)
|
html_body = notificaciones.build_notification_body("created", factura, validation_table, valor_referencial_table, doc_entry=doc_entry)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log(f"❌ Error creando draft {serie}-{numero}: {ex}", severity=logging.ERROR)
|
log(f"❌ Error creando draft {serie}-{numero}: {ex}", severity=logging.ERROR)
|
||||||
|
_registrar_resultado(registro, "error", observaciones=[{"codigo": "error_sap", "detalle": str(ex)}])
|
||||||
html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=ex)
|
html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=ex)
|
||||||
else:
|
else:
|
||||||
log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló")
|
log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló")
|
||||||
|
_registrar_resultado(registro, "rechazado", observaciones=[{"codigo": "precios_no_coinciden"}])
|
||||||
html_body = notificaciones.build_notification_body("skipped", factura, validation_table, valor_referencial_table)
|
html_body = notificaciones.build_notification_body("skipped", factura, validation_table, valor_referencial_table)
|
||||||
|
|
||||||
# 16. Enviar notificación al remitente original, con copia al equipo
|
# 16. Enviar notificación al remitente original, con copia al equipo
|
||||||
|
|||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
"""
|
||||||
|
Registro de comprobantes y códigos de seguimiento.
|
||||||
|
|
||||||
|
Cada comprobante que llega por correo recibe un ``codigo_seguimiento`` (por
|
||||||
|
ejemplo ``ABR-261019-00042``) que se comunica al proveedor y que sirve para
|
||||||
|
identificarlo si lo reenvía. El registro también guarda el resultado del
|
||||||
|
proceso (aceptado, rechazado o error) y el estado de la notificación enviada.
|
||||||
|
|
||||||
|
Un comprobante se identifica por (RUC emisor, tipo, serie, número): si vuelve
|
||||||
|
a llegar, en el mismo correo o en otro, conserva su código. Los correos que no
|
||||||
|
traen ningún comprobante identificable (sin adjuntos, sin XML válido…) se
|
||||||
|
registran aparte con ``registrar_correo``: una fila por correo, sin serie ni
|
||||||
|
número.
|
||||||
|
|
||||||
|
El esquema se crea en ``database_utilities.init_db``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import database_utilities
|
||||||
|
|
||||||
|
PREFIJO_CODIGO = "ABR"
|
||||||
|
|
||||||
|
ESTADOS = ("pendiente", "aceptado", "rechazado", "error")
|
||||||
|
ESTADOS_NOTIFICACION = ("pendiente", "enviada", "fallida", "omitida")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
@contextmanager
|
||||||
|
def _conexion():
|
||||||
|
"""Conexión con filas tipo diccionario; confirma al salir o revierte si hubo error."""
|
||||||
|
conn = sqlite3.connect(database_utilities.DB_NAME)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _ahora() -> str:
|
||||||
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _a_dict(fila: sqlite3.Row | None) -> dict | None:
|
||||||
|
if fila is None:
|
||||||
|
return None
|
||||||
|
registro = dict(fila)
|
||||||
|
if registro.get("observaciones") is not None:
|
||||||
|
registro["observaciones"] = json.loads(registro["observaciones"])
|
||||||
|
return registro
|
||||||
|
|
||||||
|
|
||||||
|
def _insertar(conn: sqlite3.Connection, email_id: str, ruc_emisor, tipo, serie, numero) -> dict:
|
||||||
|
"""Inserta la fila y le asigna el código, que se deriva de su ``id`` (único por construcción)."""
|
||||||
|
ahora = _ahora()
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""INSERT INTO comprobantes (email_id, ruc_emisor, tipo, serie, numero, creado_en, actualizado_en)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(email_id, ruc_emisor, tipo, serie, numero, ahora, ahora),
|
||||||
|
)
|
||||||
|
codigo = f"{PREFIJO_CODIGO}-{datetime.now():%y%m%d}-{cursor.lastrowid:05d}"
|
||||||
|
conn.execute("UPDATE comprobantes SET codigo_seguimiento = ? WHERE id = ?", (codigo, cursor.lastrowid))
|
||||||
|
return _a_dict(conn.execute("SELECT * FROM comprobantes WHERE id = ?", (cursor.lastrowid,)).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def registrar_comprobante(email_id: str, ruc_emisor: str, tipo: str, serie: str, numero: str) -> dict:
|
||||||
|
"""
|
||||||
|
Registra un comprobante, o recupera el registro existente si ya había llegado.
|
||||||
|
|
||||||
|
Si el comprobante ya existía y llega en un correo distinto, se conserva su
|
||||||
|
código, se actualiza ``email_id`` al correo más reciente y se incrementa
|
||||||
|
``veces_recibido``. Si es el mismo correo (reproceso), no cambia nada.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict — La fila de ``comprobantes`` (con ``codigo_seguimiento``) más la
|
||||||
|
clave ``reenvio``: True si el comprobante ya había llegado en otro correo.
|
||||||
|
"""
|
||||||
|
with _conexion() as conn:
|
||||||
|
fila = conn.execute(
|
||||||
|
"SELECT * FROM comprobantes WHERE ruc_emisor = ? AND tipo = ? AND serie = ? AND numero = ?",
|
||||||
|
(ruc_emisor, tipo, serie, numero),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if fila is None:
|
||||||
|
registro = _insertar(conn, email_id, ruc_emisor, tipo, serie, numero)
|
||||||
|
registro["reenvio"] = False
|
||||||
|
return registro
|
||||||
|
|
||||||
|
reenvio = fila["email_id"] != email_id
|
||||||
|
if reenvio:
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE comprobantes
|
||||||
|
SET email_id = ?, veces_recibido = veces_recibido + 1, actualizado_en = ?
|
||||||
|
WHERE id = ?""",
|
||||||
|
(email_id, _ahora(), fila["id"]),
|
||||||
|
)
|
||||||
|
registro = _a_dict(conn.execute("SELECT * FROM comprobantes WHERE id = ?", (fila["id"],)).fetchone())
|
||||||
|
registro["reenvio"] = reenvio
|
||||||
|
return registro
|
||||||
|
|
||||||
|
|
||||||
|
def registrar_correo(email_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Registra un correo que no trae ningún comprobante identificable (una fila
|
||||||
|
por correo, sin RUC, serie ni número) y devuelve su registro. Idempotente.
|
||||||
|
"""
|
||||||
|
with _conexion() as conn:
|
||||||
|
fila = conn.execute(
|
||||||
|
"SELECT * FROM comprobantes WHERE email_id = ? AND serie IS NULL", (email_id,)
|
||||||
|
).fetchone()
|
||||||
|
if fila is not None:
|
||||||
|
return _a_dict(fila)
|
||||||
|
return _insertar(conn, email_id, None, None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def obtener(codigo_seguimiento: str) -> dict | None:
|
||||||
|
"""Retorna el registro con ese código, o None si no existe."""
|
||||||
|
with _conexion() as conn:
|
||||||
|
return _a_dict(conn.execute(
|
||||||
|
"SELECT * FROM comprobantes WHERE codigo_seguimiento = ?", (codigo_seguimiento,)
|
||||||
|
).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
def actualizar_resultado(codigo_seguimiento: str, estado: str, observaciones: list | None = None,
|
||||||
|
doc_entry: int | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Guarda el resultado del proceso de un comprobante.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
codigo_seguimiento: Código devuelto por ``registrar_comprobante``/``registrar_correo``.
|
||||||
|
estado: Uno de ``ESTADOS``.
|
||||||
|
observaciones: Lista serializable a JSON (motivos de rechazo o detalle del error).
|
||||||
|
doc_entry: DocEntry del draft creado en SAP, si corresponde.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Si ``estado`` no es válido.
|
||||||
|
LookupError: Si no existe el código.
|
||||||
|
"""
|
||||||
|
if estado not in ESTADOS:
|
||||||
|
raise ValueError(f"Estado inválido: {estado!r} (válidos: {', '.join(ESTADOS)})")
|
||||||
|
|
||||||
|
with _conexion() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""UPDATE comprobantes
|
||||||
|
SET estado = ?, observaciones = ?, doc_entry = ?, actualizado_en = ?
|
||||||
|
WHERE codigo_seguimiento = ?""",
|
||||||
|
(estado, None if observaciones is None else json.dumps(observaciones, ensure_ascii=False),
|
||||||
|
doc_entry, _ahora(), codigo_seguimiento),
|
||||||
|
)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
|
||||||
|
|
||||||
|
|
||||||
|
def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Guarda el estado de la notificación enviada al proveedor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
codigo_seguimiento: Código del registro.
|
||||||
|
estado: Uno de ``ESTADOS_NOTIFICACION``.
|
||||||
|
error: Motivo, si la notificación falló o se omitió.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Si ``estado`` no es válido.
|
||||||
|
LookupError: Si no existe el código.
|
||||||
|
"""
|
||||||
|
if estado not in ESTADOS_NOTIFICACION:
|
||||||
|
raise ValueError(f"Estado de notificación inválido: {estado!r} (válidos: {', '.join(ESTADOS_NOTIFICACION)})")
|
||||||
|
|
||||||
|
ahora = _ahora()
|
||||||
|
with _conexion() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""UPDATE comprobantes
|
||||||
|
SET notificacion_estado = ?, notificacion_error = ?, notificacion_fecha = ?, actualizado_en = ?
|
||||||
|
WHERE codigo_seguimiento = ?""",
|
||||||
|
(estado, error, ahora, ahora, codigo_seguimiento),
|
||||||
|
)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
|
||||||
@@ -2,6 +2,8 @@ import os
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import database_utilities
|
||||||
|
|
||||||
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
FIXTURES = os.path.join(RAIZ, "tests", "fixtures", "correos_prueba")
|
FIXTURES = os.path.join(RAIZ, "tests", "fixtures", "correos_prueba")
|
||||||
GOLDEN = os.path.join(RAIZ, "tests", "golden")
|
GOLDEN = os.path.join(RAIZ, "tests", "golden")
|
||||||
@@ -11,3 +13,13 @@ GOLDEN = os.path.join(RAIZ, "tests", "golden")
|
|||||||
def actualizar_golden() -> bool:
|
def actualizar_golden() -> bool:
|
||||||
"""True si se ejecutó con ``UPDATE_GOLDEN=1`` (regenera los resultados esperados)."""
|
"""True si se ejecutó con ``UPDATE_GOLDEN=1`` (regenera los resultados esperados)."""
|
||||||
return os.environ.get("UPDATE_GOLDEN") == "1"
|
return os.environ.get("UPDATE_GOLDEN") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def base_de_datos_temporal(tmp_path_factory, monkeypatch):
|
||||||
|
"""
|
||||||
|
Toda prueba usa una BD SQLite propia: ``DB_NAME`` es relativo al directorio
|
||||||
|
de trabajo, así que sin esto se escribiría en el ``emails.db`` real.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(database_utilities, "DB_NAME", str(tmp_path_factory.mktemp("bd") / "emails_prueba.db"))
|
||||||
|
database_utilities.init_db()
|
||||||
@@ -4,8 +4,10 @@ BD simulados. Usa el correo de prueba 001 (factura F002-2450, una línea de
|
|||||||
624.38 con la guía T001-128912).
|
624.38 con la guía T001-128912).
|
||||||
"""
|
"""
|
||||||
import glob
|
import glob
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ import hana_manager
|
|||||||
import outlook_utilities
|
import outlook_utilities
|
||||||
import pipeline
|
import pipeline
|
||||||
import sapb1_manager
|
import sapb1_manager
|
||||||
|
import seguimiento
|
||||||
from conftest import FIXTURES
|
from conftest import FIXTURES
|
||||||
|
|
||||||
CASO = "001_correo"
|
CASO = "001_correo"
|
||||||
@@ -144,3 +147,78 @@ def test_guarda_resultados_y_draft_en_disco(entorno):
|
|||||||
nombres = os.listdir(entorno.salida)
|
nombres = os.listdir(entorno.salida)
|
||||||
assert any(n.startswith("xml_resultado_F002-2450") for n in nombres)
|
assert any(n.startswith("xml_resultado_F002-2450") for n in nombres)
|
||||||
assert any(n.startswith("draft_sapb1_F002-2450") for n in nombres)
|
assert any(n.startswith("draft_sapb1_F002-2450") for n in nombres)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Seguimiento
|
||||||
|
# ============================================================================
|
||||||
|
def _registros():
|
||||||
|
conn = sqlite3.connect(database_utilities.DB_NAME)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
return [dict(f) for f in conn.execute("SELECT * FROM comprobantes ORDER BY id")]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_creado_deja_el_comprobante_aceptado(entorno):
|
||||||
|
_procesar(entorno)
|
||||||
|
|
||||||
|
(registro,) = _registros()
|
||||||
|
assert registro["estado"] == "aceptado"
|
||||||
|
assert registro["doc_entry"] == 777
|
||||||
|
assert registro["codigo_seguimiento"].startswith("ABR-")
|
||||||
|
assert (registro["serie"], registro["numero"], registro["tipo"]) == ("F002", "2450", "01")
|
||||||
|
assert registro["ruc_emisor"] == "10060028686"
|
||||||
|
|
||||||
|
|
||||||
|
def test_precios_que_no_coinciden_dejan_el_comprobante_rechazado(entorno):
|
||||||
|
entorno.line_total_sap = TOTAL_LINEA + 50
|
||||||
|
|
||||||
|
_procesar(entorno)
|
||||||
|
|
||||||
|
(registro,) = _registros()
|
||||||
|
assert registro["estado"] == "rechazado"
|
||||||
|
assert registro["doc_entry"] is None
|
||||||
|
assert seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"] == [{"codigo": "precios_no_coinciden"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_al_crear_draft_deja_el_comprobante_en_error(entorno):
|
||||||
|
entorno.error_al_crear = RuntimeError("Service Layer caído")
|
||||||
|
|
||||||
|
_procesar(entorno)
|
||||||
|
|
||||||
|
(registro,) = _registros()
|
||||||
|
assert registro["estado"] == "error"
|
||||||
|
obs = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"]
|
||||||
|
assert obs == [{"codigo": "error_sap", "detalle": "Service Layer caído"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_comprobante_reenviado_conserva_el_codigo_y_se_advierte(entorno, caplog):
|
||||||
|
_procesar(entorno)
|
||||||
|
(primero,) = _registros()
|
||||||
|
|
||||||
|
entorno.descarga = entorno.descarga.parent / "descarga2" # otra descarga, otro correo
|
||||||
|
with caplog.at_level(logging.WARNING, logger="pipeline"):
|
||||||
|
pipeline._procesar_un_correo(
|
||||||
|
{"id": "otro-correo", "fecha": "2026-10-19 11:00:00", "subject": "Reenvío", "sender": "p@e.com"},
|
||||||
|
"token", "buzon@aberio.com", str(entorno.descarga), str(entorno.salida),
|
||||||
|
)
|
||||||
|
|
||||||
|
(registro,) = _registros() # sigue habiendo una sola fila
|
||||||
|
assert registro["codigo_seguimiento"] == primero["codigo_seguimiento"]
|
||||||
|
assert registro["veces_recibido"] == 2
|
||||||
|
assert any("ya recibido antes" in r.getMessage() for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_si_falla_el_seguimiento_igual_se_crea_el_draft(entorno, monkeypatch):
|
||||||
|
def roto(*a, **k):
|
||||||
|
raise sqlite3.OperationalError("database is locked")
|
||||||
|
|
||||||
|
monkeypatch.setattr(seguimiento, "registrar_comprobante", roto)
|
||||||
|
monkeypatch.setattr(seguimiento, "actualizar_resultado", roto)
|
||||||
|
|
||||||
|
_procesar(entorno)
|
||||||
|
|
||||||
|
assert len(entorno.drafts) == 1
|
||||||
|
assert len(entorno.correos) == 1
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import database_utilities
|
||||||
|
import seguimiento
|
||||||
|
|
||||||
|
COMPROBANTE = ("20537929520", "01", "F002", "486")
|
||||||
|
|
||||||
|
|
||||||
|
def test_registrar_crea_codigo_y_estado_inicial():
|
||||||
|
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
|
||||||
|
|
||||||
|
assert re.fullmatch(r"ABR-\d{6}-\d{5}", r["codigo_seguimiento"])
|
||||||
|
assert r["estado"] == "pendiente"
|
||||||
|
assert r["notificacion_estado"] == "pendiente"
|
||||||
|
assert r["veces_recibido"] == 1
|
||||||
|
assert r["reenvio"] is False
|
||||||
|
assert (r["ruc_emisor"], r["tipo"], r["serie"], r["numero"]) == COMPROBANTE
|
||||||
|
assert r["observaciones"] is None and r["doc_entry"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reprocesar_el_mismo_correo_conserva_todo():
|
||||||
|
primero = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
|
||||||
|
segundo = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
|
||||||
|
|
||||||
|
assert segundo["codigo_seguimiento"] == primero["codigo_seguimiento"]
|
||||||
|
assert segundo["veces_recibido"] == 1
|
||||||
|
assert segundo["reenvio"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mismo_comprobante_en_otro_correo_conserva_el_codigo():
|
||||||
|
primero = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
|
||||||
|
segundo = seguimiento.registrar_comprobante("correo-2", *COMPROBANTE)
|
||||||
|
|
||||||
|
assert segundo["codigo_seguimiento"] == primero["codigo_seguimiento"]
|
||||||
|
assert segundo["veces_recibido"] == 2
|
||||||
|
assert segundo["email_id"] == "correo-2" # queda el correo más reciente
|
||||||
|
assert segundo["reenvio"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_el_reenvio_conserva_el_resultado_anterior():
|
||||||
|
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
|
||||||
|
seguimiento.actualizar_resultado(r["codigo_seguimiento"], "aceptado", doc_entry=2021)
|
||||||
|
|
||||||
|
reenviado = seguimiento.registrar_comprobante("correo-2", *COMPROBANTE)
|
||||||
|
|
||||||
|
assert reenviado["estado"] == "aceptado" and reenviado["doc_entry"] == 2021
|
||||||
|
|
||||||
|
|
||||||
|
def test_comprobantes_distintos_tienen_codigos_distintos():
|
||||||
|
a = seguimiento.registrar_comprobante("c1", "20537929520", "01", "F002", "486")
|
||||||
|
b = seguimiento.registrar_comprobante("c1", "20537929520", "01", "F002", "487") # otro número
|
||||||
|
c = seguimiento.registrar_comprobante("c1", "20111111111", "01", "F002", "486") # otro proveedor
|
||||||
|
d = seguimiento.registrar_comprobante("c1", "20537929520", "07", "F002", "486") # otro tipo
|
||||||
|
|
||||||
|
codigos = {x["codigo_seguimiento"] for x in (a, b, c, d)}
|
||||||
|
assert len(codigos) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_registrar_correo_es_idempotente_por_correo():
|
||||||
|
a = seguimiento.registrar_correo("correo-1")
|
||||||
|
de_nuevo = seguimiento.registrar_correo("correo-1")
|
||||||
|
otro = seguimiento.registrar_correo("correo-2")
|
||||||
|
|
||||||
|
assert de_nuevo["codigo_seguimiento"] == a["codigo_seguimiento"]
|
||||||
|
assert otro["codigo_seguimiento"] != a["codigo_seguimiento"]
|
||||||
|
assert a["serie"] is None and a["ruc_emisor"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_correo_sin_comprobante_y_comprobante_del_mismo_correo_coexisten():
|
||||||
|
sin_comprobante = seguimiento.registrar_correo("correo-1")
|
||||||
|
con_comprobante = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
|
||||||
|
|
||||||
|
assert sin_comprobante["codigo_seguimiento"] != con_comprobante["codigo_seguimiento"]
|
||||||
|
# y cada uno se sigue recuperando por separado
|
||||||
|
assert seguimiento.registrar_correo("correo-1")["codigo_seguimiento"] == sin_comprobante["codigo_seguimiento"]
|
||||||
|
assert seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)["codigo_seguimiento"] == con_comprobante["codigo_seguimiento"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_obtener():
|
||||||
|
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
|
||||||
|
assert seguimiento.obtener(r["codigo_seguimiento"])["numero"] == "486"
|
||||||
|
assert seguimiento.obtener("ABR-000000-99999") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_actualizar_resultado_guarda_estado_observaciones_y_doc_entry():
|
||||||
|
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
|
||||||
|
codigo = r["codigo_seguimiento"]
|
||||||
|
|
||||||
|
seguimiento.actualizar_resultado(codigo, "rechazado", observaciones=[{"codigo": "precio", "detalle": "ñandú"}])
|
||||||
|
rechazado = seguimiento.obtener(codigo)
|
||||||
|
assert rechazado["estado"] == "rechazado"
|
||||||
|
assert rechazado["observaciones"] == [{"codigo": "precio", "detalle": "ñandú"}] # JSON de ida y vuelta
|
||||||
|
|
||||||
|
seguimiento.actualizar_resultado(codigo, "aceptado", doc_entry=2021)
|
||||||
|
aceptado = seguimiento.obtener(codigo)
|
||||||
|
assert (aceptado["estado"], aceptado["doc_entry"], aceptado["observaciones"]) == ("aceptado", 2021, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_actualizar_resultado_valida_estado_y_codigo():
|
||||||
|
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
seguimiento.actualizar_resultado(r["codigo_seguimiento"], "inventado")
|
||||||
|
with pytest.raises(LookupError):
|
||||||
|
seguimiento.actualizar_resultado("ABR-000000-99999", "aceptado")
|
||||||
|
|
||||||
|
|
||||||
|
def test_registrar_notificacion():
|
||||||
|
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
|
||||||
|
codigo = r["codigo_seguimiento"]
|
||||||
|
assert r["notificacion_fecha"] is None
|
||||||
|
|
||||||
|
seguimiento.registrar_notificacion(codigo, "fallida", error="Graph 503")
|
||||||
|
fallida = seguimiento.obtener(codigo)
|
||||||
|
assert (fallida["notificacion_estado"], fallida["notificacion_error"]) == ("fallida", "Graph 503")
|
||||||
|
assert fallida["notificacion_fecha"] is not None
|
||||||
|
|
||||||
|
seguimiento.registrar_notificacion(codigo, "enviada")
|
||||||
|
assert seguimiento.obtener(codigo)["notificacion_error"] is None
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
seguimiento.registrar_notificacion(codigo, "inventado")
|
||||||
|
with pytest.raises(LookupError):
|
||||||
|
seguimiento.registrar_notificacion("ABR-000000-99999", "enviada")
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_db_es_idempotente_y_conserva_los_datos():
|
||||||
|
seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
|
||||||
|
database_utilities.save_emails([{"id": "correo-1", "fecha": "2026-10-19 10:00:00", "sender": "a@b.c",
|
||||||
|
"subject": "s", "has_attach": True}])
|
||||||
|
|
||||||
|
database_utilities.init_db() # segunda vez: no debe borrar nada
|
||||||
|
|
||||||
|
assert database_utilities.get_existing_ids(["correo-1"]) == ["correo-1"]
|
||||||
|
assert seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)["veces_recibido"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_tabla_emails_existente_no_se_modifica():
|
||||||
|
"""Una BD anterior (solo con ``emails``) debe actualizarse al esquema nuevo sin perder correos."""
|
||||||
|
conn = sqlite3.connect(database_utilities.DB_NAME)
|
||||||
|
conn.execute("DROP TABLE comprobantes")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
database_utilities.init_db()
|
||||||
|
|
||||||
|
assert seguimiento.registrar_correo("correo-1")["codigo_seguimiento"].startswith("ABR-")
|
||||||
Reference in new issue
Block a user