Files
descarga_xml_sunat/historial_manager.py
GeorgeandClaude Sonnet 5 c2503745bf Reemplaza logging por consola/archivo, agrega historial de intentos y soporte para notas de crédito
- Nuevo logger_manager.py (reemplaza logger_utilities.py): logging con formato de árbol,
  rotación diaria a sunat.log, y consola por stdout (antes iba por stderr y se duplicaba
  en logs/error.log del launchd).
- Nuevo historial_manager.py: registra cada intento de consulta/descarga en historial.db
  para auditoría, con limpieza automática de registros de más de 180 días.
- consulta_cpe.py: corrige el id de notas de crédito/débito (usa F7/F8, no 07/08) y
  agrega reintentos ante errores 5xx.
- hana_manager.py: suma consulta a ORPC para notas de crédito (Indicator 07) y soporte
  de ObjType para actualizar la tabla correcta (OPCH/ORPC) al marcar PDF/XML descargado.
- gestor_fallas.py / main.py: propagan objtype end-to-end y dejan constancia de cada
  intento en historial_manager.
- Elimina login_api.py y reporte_fallas.py (código no usado / script manual sin uso reciente).
- Actualiza .gitignore (sunat-venv/, historial.db*) y docs/ para reflejar el estado real
  del proyecto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 12:31:48 -05:00

122 lines
5.5 KiB
Python

import sqlite3
from pathlib import Path
from datetime import datetime, timedelta
from logger_manager import get_logger
DB_PATH = Path(__file__).parent / "historial.db"
logger = get_logger(__name__)
# ============================================================================
def _conn():
return sqlite3.connect(str(DB_PATH))
# ============================================================================
def init_db():
with _conn() as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""
CREATE TABLE IF NOT EXISTS historial (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fecha TEXT NOT NULL,
docentry INTEGER NOT NULL,
docnum TEXT,
ruc TEXT NOT NULL,
tipo TEXT NOT NULL,
serie TEXT NOT NULL,
numero TEXT NOT NULL,
objtype INTEGER NOT NULL DEFAULT 18,
es_retry INTEGER NOT NULL DEFAULT 0,
intento_num INTEGER,
consulta_status INTEGER,
pdf_resultado TEXT,
xml_resultado TEXT,
resultado TEXT NOT NULL,
detalle TEXT,
json_path TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_historial_docentry ON historial(docentry)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_historial_fecha ON historial(fecha)")
# ============================================================================
def registrar_intento(docentry, docnum, ruc, tipo, serie, numero, objtype, es_retry,
intento_num, consulta_status, pdf_resultado, xml_resultado,
resultado, detalle="", json_path=None):
ahora = datetime.now().isoformat()
with _conn() as conn:
conn.execute("""
INSERT INTO historial
(fecha, docentry, docnum, ruc, tipo, serie, numero, objtype, es_retry,
intento_num, consulta_status, pdf_resultado, xml_resultado, resultado,
detalle, json_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (ahora, docentry, docnum, ruc, tipo, serie, numero, objtype, int(es_retry),
intento_num, consulta_status, pdf_resultado, xml_resultado, resultado,
str(detalle)[:500] if detalle else detalle, json_path))
# ============================================================================
def limpiar_historial_antiguo(dias=180):
limite = (datetime.now() - timedelta(days=dias)).isoformat()
with _conn() as conn:
cursor = conn.execute("DELETE FROM historial WHERE fecha < ?", (limite,))
count = cursor.rowcount
if count > 0:
logger.info(f"Se eliminaron {count} registros de historial de más de {dias} días.")
return count
# ============================================================================
def historial_docentry(docentry) -> list[dict]:
with _conn() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"SELECT * FROM historial WHERE docentry = ? ORDER BY id ASC", (docentry,)
)
return [dict(row) for row in cursor.fetchall()]
# ============================================================================
def buscar_intentos(ruc=None, serie=None, numero=None, tipo=None) -> list[dict]:
"""Busca intentos registrados filtrando por cualquier combinación de ruc/serie/numero/tipo."""
condiciones = []
params = []
if ruc:
condiciones.append("ruc = ?")
params.append(ruc)
if serie:
condiciones.append("serie = ?")
params.append(serie)
if numero:
condiciones.append("numero = ?")
params.append(str(numero))
if tipo:
condiciones.append("tipo = ?")
params.append(tipo)
where = " AND ".join(condiciones) if condiciones else "1=1"
with _conn() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(f"SELECT * FROM historial WHERE {where} ORDER BY fecha ASC", params)
return [dict(row) for row in cursor.fetchall()]
# ============================================================================
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Buscar intentos de descarga en historial.db")
parser.add_argument("--ruc", help="RUC del emisor")
parser.add_argument("--serie", help="Serie del comprobante (ej: F001)")
parser.add_argument("--numero", help="Número del comprobante")
parser.add_argument("--tipo", help="Tipo de comprobante (01, 03, 07, 08...)")
args = parser.parse_args()
if not any([args.ruc, args.serie, args.numero, args.tipo]):
parser.error("Indica al menos un filtro: --ruc, --serie, --numero o --tipo")
filas = buscar_intentos(ruc=args.ruc, serie=args.serie, numero=args.numero, tipo=args.tipo)
if not filas:
print("No se encontraron intentos con esos filtros.")
else:
print(f"{len(filas)} intento(s) encontrado(s):\n")
for f in filas:
print(
f"{f['fecha']} DocEntry={f['docentry']} "
f"{f['ruc']}-{f['tipo']}-{f['serie']}-{f['numero']} "
f"intento={f['intento_num']} retry={'sí' if f['es_retry'] else 'no'} "
f"status={f['consulta_status']} resultado={f['resultado']} "
f"pdf={f['pdf_resultado']} xml={f['xml_resultado']} detalle={f['detalle']}"
)
# ============================================================================