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:
GeorgeandClaude Sonnet 5.5 committed 2026-10-20 22:03:38 -05:00
1 parent 1d6d869111
commit 30e9b160f8
9 files changed
+563 -6

No files matched your search

+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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).
--- ---
+57
View File
@@ -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
View File
@@ -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
View File
@@ -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}")
+12
View File
@@ -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()
+78
View File
@@ -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
+149
View File
@@ -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-")