- sapb1_manager.buscar_factura_existente busca la factura (proveedor, tipo, serie, número) en facturas de compra no anuladas y en drafts; si existe no se crea otro draft y se responde "ya está registrada" sin pisar el registro de una aceptación anterior. - SapNoDisponible (subclase de ConnectionError) distingue una caída (timeout, sin conexión, HTTP 5xx) de un rechazo normal de SAP (HTTP 4xx). Una caída de Service Layer con HANA sin resultados ya no se lee como "guía no existe". - Con SAP o HANA caídos se avisa una sola vez al proveedor con la plantilla "En cola" y el correo se reintenta en los ciclos siguientes (hasta 24), sin repetir avisos; al agotarse se avisa al equipo. - La lógica por factura pasa a _procesar_factura; lo ya resuelto dentro de un correo no se repite al reintentarlo. - seguimiento: columna intentos (con migración), registrar_intento y el estado de notificación en_espera. - database_utilities.DB_NAME pasa a ser absoluto (junto al código). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
227 lines
9.0 KiB
Python
227 lines
9.0 KiB
Python
"""
|
|
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, timedelta
|
|
|
|
import database_utilities
|
|
|
|
PREFIJO_CODIGO = "ABR"
|
|
|
|
ESTADOS = ("pendiente", "aceptado", "rechazado", "error")
|
|
# en_espera: se avisó al proveedor que el servicio está fuera de línea y el comprobante se reintentará
|
|
ESTADOS_NOTIFICACION = ("pendiente", "enviada", "fallida", "omitida", "en_espera")
|
|
|
|
|
|
# ============================================================================
|
|
@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,
|
|
destinatario: 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ó.
|
|
destinatario: Direcciones a las que se envió realmente (en modo prueba no son las del remitente).
|
|
|
|
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 = ?,
|
|
notificacion_destinatario = ?, actualizado_en = ?
|
|
WHERE codigo_seguimiento = ?""",
|
|
(estado, error, ahora, destinatario, ahora, codigo_seguimiento),
|
|
)
|
|
if cursor.rowcount == 0:
|
|
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
|
|
|
|
|
|
def registrar_intento(codigo_seguimiento: str) -> int:
|
|
"""
|
|
Suma un intento fallido de procesar el comprobante (SAP fuera de línea) y devuelve el total.
|
|
|
|
Raises:
|
|
LookupError: Si no existe el código.
|
|
"""
|
|
with _conexion() as conn:
|
|
cursor = conn.execute(
|
|
"UPDATE comprobantes SET intentos = intentos + 1, actualizado_en = ? WHERE codigo_seguimiento = ?",
|
|
(_ahora(), codigo_seguimiento),
|
|
)
|
|
if cursor.rowcount == 0:
|
|
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
|
|
return conn.execute(
|
|
"SELECT intentos FROM comprobantes WHERE codigo_seguimiento = ?", (codigo_seguimiento,)
|
|
).fetchone()[0]
|
|
|
|
|
|
def contar_respuestas_correo(remitente: str, horas: int = 24) -> int:
|
|
"""
|
|
Cuenta las respuestas ya enviadas en las últimas ``horas`` a un remitente por
|
|
correos **sin comprobante** (sin serie ni número). Sirve de freno contra bucles
|
|
y contra remitentes que insisten con correos inválidos.
|
|
"""
|
|
desde = (datetime.now() - timedelta(hours=horas)).strftime("%Y-%m-%d %H:%M:%S")
|
|
with _conexion() as conn:
|
|
return conn.execute(
|
|
"""SELECT COUNT(*) FROM comprobantes c JOIN emails e ON e.id = c.email_id
|
|
WHERE c.serie IS NULL AND c.notificacion_estado = 'enviada'
|
|
AND c.notificacion_fecha >= ? AND lower(e.sender) = lower(?)""",
|
|
(desde, remitente),
|
|
).fetchone()[0]
|