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
9d295d6485
commit
297cfde0e1
9 files changed
+563
-6
No files matched your search
+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}")
|
||||
Reference in new issue
Block a user