From 297cfde0e1da09e6c5fd95297a2aecd0f43f80d5 Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 23:15:23 -0500 Subject: [PATCH] =?UTF-8?q?fase=201:=20registro=20de=20comprobantes=20con?= =?UTF-8?q?=20c=C3=B3digo=20de=20seguimiento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 5 +- database_utilities.py | 35 ++++++- docs/inventario.md | 7 +- docs/seguimiento.md | 57 +++++++++++ pipeline.py | 39 ++++++++ seguimiento.py | 187 +++++++++++++++++++++++++++++++++++ tests/conftest.py | 12 +++ tests/test_pipeline_flujo.py | 78 +++++++++++++++ tests/test_seguimiento.py | 149 ++++++++++++++++++++++++++++ 9 files changed, 563 insertions(+), 6 deletions(-) create mode 100644 docs/seguimiento.md create mode 100644 seguimiento.py create mode 100644 tests/test_seguimiento.py diff --git a/README.md b/README.md index 0dcef32..862c6dc 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, e | `adjuntos/` | PDF/XML descargados (`DOWNLOAD_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 | -| `emails.db` | Correos ya procesados | +| `emails.db` | Correos ya procesados y comprobantes con su código de seguimiento | ## 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 | | `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 | -| `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 | | `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/`) | @@ -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/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/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/hana_manager.md`](docs/hana_manager.md) | Consultas directas a SAP HANA | diff --git a/database_utilities.py b/database_utilities.py index 2418488..2ec14b4 100644 --- a/database_utilities.py +++ b/database_utilities.py @@ -9,7 +9,9 @@ DB_NAME = "emails.db" # ============================================================================ 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) 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() finally: conn.close() diff --git a/docs/inventario.md b/docs/inventario.md index 05cb5a7..d7b9f7a 100644 --- a/docs/inventario.md +++ b/docs/inventario.md @@ -5,7 +5,7 @@ | Archivo | Líneas | Rol | |---------|--------|-----| | `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()` | | `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 | @@ -17,14 +17,15 @@ | `sapb1_manager.py` | 401 | Comunicación con SAP B1 Service Layer | | `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) | -| `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/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) | | `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: `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). --- diff --git a/docs/seguimiento.md b/docs/seguimiento.md new file mode 100644 index 0000000..cb80af9 --- /dev/null +++ b/docs/seguimiento.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`. diff --git a/pipeline.py b/pipeline.py index a83fda0..e6865bd 100644 --- a/pipeline.py +++ b/pipeline.py @@ -21,11 +21,46 @@ import persistencia import draft_sap import notificaciones import sapb1_manager +import seguimiento from pipeline_utils import log 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 # ============================================================================ @@ -153,6 +188,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde numero = factura.get("numero", "?") log(f"📄 Factura: {serie}-{numero}") + registro = _registrar_comprobante(email, factura) # 12. Validar precios (consulta SAP DNs, NO requiere draft) 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) doc_entry = sap_result.get("DocEntry") 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) except Exception as ex: 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) else: 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) # 16. Enviar notificación al remitente original, con copia al equipo diff --git a/seguimiento.py b/seguimiento.py new file mode 100644 index 0000000..1fdf87c --- /dev/null +++ b/seguimiento.py @@ -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}") diff --git a/tests/conftest.py b/tests/conftest.py index ff2d77a..e274b9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,8 @@ import os import pytest +import database_utilities + RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) FIXTURES = os.path.join(RAIZ, "tests", "fixtures", "correos_prueba") GOLDEN = os.path.join(RAIZ, "tests", "golden") @@ -11,3 +13,13 @@ GOLDEN = os.path.join(RAIZ, "tests", "golden") def actualizar_golden() -> bool: """True si se ejecutó con ``UPDATE_GOLDEN=1`` (regenera los resultados esperados).""" 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() diff --git a/tests/test_pipeline_flujo.py b/tests/test_pipeline_flujo.py index d73a23a..9992188 100644 --- a/tests/test_pipeline_flujo.py +++ b/tests/test_pipeline_flujo.py @@ -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). """ import glob +import logging import os import shutil +import sqlite3 import pytest @@ -14,6 +16,7 @@ import hana_manager import outlook_utilities import pipeline import sapb1_manager +import seguimiento from conftest import FIXTURES CASO = "001_correo" @@ -144,3 +147,78 @@ def test_guarda_resultados_y_draft_en_disco(entorno): nombres = os.listdir(entorno.salida) assert any(n.startswith("xml_resultado_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 diff --git a/tests/test_seguimiento.py b/tests/test_seguimiento.py new file mode 100644 index 0000000..0bf448e --- /dev/null +++ b/tests/test_seguimiento.py @@ -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-")