import os import sys import json import time import signal import random from datetime import datetime, timedelta from pathlib import Path from dotenv import load_dotenv import urllib3 # ============================================================================ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) load_dotenv() from hana_manager import obtener_pendientes, marcar_pdf_descargado, marcar_xml_descargado, cerrar_conexion from logger_utilities import setup_logger, rotar_log, limpiar_logs_antiguos from gestor_fallas import init_db, registrar_falla, eliminar_falla, obtener_fallas_pendientes, obtener_docentries_falla, total_fallas from smb_manager import sincronizar_a_smb from consulta_cpe import ( TokenExpiredError, load_token, get_headers, consultar_cpe, descargar_archivo, cpe_id, API_HOST, BASE_PATH, DOWNLOAD_DIR ) # ============================================================================ # CONFIGURACIÓN DE HORARIOS (desde scheduler.json) # ============================================================================ SCHEDULER_FILE = Path(__file__).parent / "scheduler.json" def _cargar_scheduler(): with open(SCHEDULER_FILE, encoding="utf-8") as f: data = json.load(f) horarios = [tuple(h) for h in data["HORARIOS"]] return data["DIAS_ACTIVOS"], horarios try: DIAS_ACTIVOS, HORARIOS = _cargar_scheduler() except Exception as e: print(f"⚠️ Error cargando {SCHEDULER_FILE}: {e}. Usando defaults.") # (hora_inicio, min_inicio, hora_fin, min_fin, intervalo_segundos, modo) HORARIOS = [ (6, 30, 13, 15, 300, "rapido"), # 06:30 - 13:15 -> cada 5 minutos (13, 15, 14, 0, 3600, "full"), # 13:15 - 14:00 -> cada 1 hora (14, 0, 17, 30, 300, "rapido"), # 14:00 - 17:30 -> cada 5 minutos (17, 30, 21, 30, 3600, "full"), # 17:30 - 21:30 -> cada 1 hora ] # 0=lun, 1=mar, 2=mié, 3=jue, 4=vie, 5=sáb, 6=dom DIAS_ACTIVOS = [0, 1, 2, 3, 4] _running = True # ─── Colores ──────────────────────────────────────────── color_verde = "\033[92m" color_amarillo = "\033[93m" color_rojo = "\033[91m" color_fin = "\033[0m" # ============================================================================ def ic(ok, texto_ok, texto_fail): """Icono + texto según estado.""" icono = "✅" if ok else "⚠️" color = color_verde if ok else color_amarillo return f"{icono} {color}{texto_ok if ok else texto_fail}{color_fin}" # ============================================================================ def obtener_intervalo(dt: datetime) -> tuple[int, str] | None: minutos = dt.hour * 60 + dt.minute for hi, mi, hf, mf, intervalo, modo in HORARIOS: inicio = hi * 60 + mi fin = hf * 60 + mf if inicio <= minutos < fin: return (intervalo, modo) return None # ============================================================================ def segundos_hasta_proximo_inicio(dt: datetime) -> int: target = dt.replace(hour=6, minute=30, second=0, microsecond=0) if dt >= target: target += timedelta(days=1) return int((target - dt).total_seconds()) # ============================================================================ def segundos_hasta_proximo_dia_activo(dt: datetime) -> int: for dias in range(1, 8): prox = dt + timedelta(days=dias) if prox.weekday() in DIAS_ACTIVOS: target = prox.replace(hour=6, minute=30, second=0, microsecond=0) return int((target - dt).total_seconds()) return 86400 # ============================================================================ def obtener_token(): """Obtiene o renueva el token si es necesario.""" token = load_token() if token: print(" 🔑 Token existente válido, reutilizando") return token print("\n🔑 Token no disponible o expirado. Obteniendo nuevo...") from get_token_headless import get_token_headless as obtener_headless, chrome_instalado if not chrome_instalado(): print(" ⚠️ Google Chrome no instalado. Saltando headless...") else: try: if obtener_headless(): print(" ✅ Token obtenido vía get_token_headless (headless)") return load_token() except Exception as e: print(f" ⚠️ Headless falló: {e}") print(" ⚠️ Headless no disponible. Intentando get_token.py con navegador visible...") from get_token import get_token as obtener_token_playwright obtener_token_playwright(headless=False, timeout=120000) for _ in range(30): tk = load_token() if tk: print(" ✅ Token obtenido vía Playwright (get_token.py)") return tk time.sleep(1) print(" ⚠️ No se detectó token tras 30s. Continuando...") return load_token() # ============================================================================ def procesar_lote(token): """Procesa documentos pendientes desde HANA.""" print(f"\n📋 Buscando documentos pendientes en HANA...") docs_hana = obtener_pendientes() fallas_docentries = obtener_docentries_falla() docs_nuevos = [d for d in docs_hana if d.get("DocEntry") not in fallas_docentries] docs_retry_raw = obtener_fallas_pendientes() docs_retry = [] for d in docs_retry_raw: docs_retry.append({ "DocEntry": d["docentry"], "DocNum": d.get("numero", ""), "LicTradNum": d["ruc"], "Indicator": d["tipo"], "FolioPref": d["serie"], "FolioNum": d["numero"], "tienePDF": "N", "tieneXML": "N", }) total_fallas_count = total_fallas() if not docs_nuevos and not docs_retry: print(" ✅ No hay documentos pendientes") return print(f" 📄 {len(docs_hana)} en HANA | 🆕 {len(docs_nuevos)} nuevos | 🔄 {len(docs_retry)} reintentos | 🗂️ {total_fallas_count} en fallas\n") DOWNLOAD_DIR.mkdir(exist_ok=True) # ======================================================================== def _procesar(doc, idx, total, es_retry=False): nonlocal token if not _running: return ruc = (doc.get("LicTradNum") or "").strip() tipo_cpe = (doc.get("Indicator") or "").strip() serie = (doc.get("FolioPref") or "").strip() numero = doc.get("FolioNum") or 0 docentry = doc.get("DocEntry") or 0 docnum = doc.get("DocNum") or 0 tiene_pdf = (doc.get("tienePDF") or "N").strip() tiene_xml = (doc.get("tieneXML") or "N").strip() if not ruc or not tipo_cpe or not serie or not numero: print(f" ⚠️ DocEntry={docentry} DocNum={docnum}: datos incompletos, saltando") return ultimo = idx == total - 1 raiz = "└──" if ultimo else "├──" tag = "🔄" if es_retry else "📄" print(f"{raiz} {tag} [{idx+1}/{total}] {ruc}-{tipo_cpe}-{serie}-{numero} [DocEntry={docentry} | DocNum={docnum}]") prefijo = " " if ultimo else "│ " prefijo_hijo = prefijo cid = cpe_id(ruc, tipo_cpe, serie, numero) for intento_token in range(2): ok_pdf = True ok_xml = True hubo_error = False try: time.sleep(random.uniform(1.0, 2.0)) data, status_code = consultar_cpe(token, ruc, tipo_cpe, serie, numero, descargar=False, verbose=False) if not data: print(f"{prefijo_hijo}└── Consulta: {color_rojo}Error {status_code}{color_fin}") registrar_falla(docentry, ruc, tipo_cpe, serie, numero, f"Error HTTP {status_code}") return print(f"{prefijo_hijo}├── Consulta: {color_verde}✅ OK{color_fin}") # ─── PDF ─── if tiene_pdf == "Y": pdf_msg = f"{color_verde}✅ ya tenía{color_fin}" else: time.sleep(random.uniform(1.0, 2.0)) ok_pdf = descargar_archivo(token, cid, "01", datetime.now().strftime("%Y%m%d_%H%M%S"), verbose=False) if ok_pdf: dl_url = f"{API_HOST}{BASE_PATH}/{cid}/01" local_path = str(DOWNLOAD_DIR / f"{cid[:-2] if cid.endswith('-2') else cid}.pdf") marcar_pdf_descargado(docentry, dl_url, local_path) pdf_msg = f"{color_verde}📥 descargado{color_fin}" else: pdf_msg = f"{color_amarillo}⚠️ error{color_fin}" hubo_error = True print(f"{prefijo_hijo}├── PDF: {pdf_msg}") # ─── XML ─── if tiene_xml == "Y": xml_msg = f"{color_verde}✅ ya tenía{color_fin}" else: time.sleep(random.uniform(1.0, 2.0)) ok_xml = descargar_archivo(token, cid, "02", datetime.now().strftime("%Y%m%d_%H%M%S"), verbose=False) if ok_xml: dl_url = f"{API_HOST}{BASE_PATH}/{cid}/02" local_path = str(DOWNLOAD_DIR / f"{cid[:-2] if cid.endswith('-2') else cid}.xml") marcar_xml_descargado(docentry, dl_url, local_path) xml_msg = f"{color_verde}📥 descargado{color_fin}" else: xml_msg = f"{color_amarillo}⚠️ error{color_fin}" hubo_error = True print(f"{prefijo_hijo}├── XML: {xml_msg}") print(f"{prefijo_hijo}├── HANA: {ic(ok_pdf and ok_xml, '✅ actualizado', '⚠️ pendiente')}") if ok_pdf and ok_xml: if es_retry: print(f"{prefijo_hijo}├── SQLite: {color_amarillo}🗑️ quitando registro{color_fin}") eliminar_falla(docentry) print(f"{prefijo_hijo}└── Resultado: {color_verde}✅ OK{color_fin}") else: fallos = [s for s, ok in [("PDF", ok_pdf), ("XML", ok_xml)] if not ok] print(f"{prefijo_hijo}└── Resultado: {color_amarillo}⚠️ parcial{color_fin} ({', '.join(fallos)})") if hubo_error: registrar_falla(docentry, ruc, tipo_cpe, serie, numero, f"falló: {', '.join(fallos)}") break except TokenExpiredError: if intento_token == 0: print(f"{prefijo_hijo}├── Token expirado, renovando...") nuevo_token = obtener_token() if nuevo_token: token = nuevo_token print(f"{prefijo_hijo}├── Token renovado, reintentando...") continue print(f"{prefijo_hijo}└── Resultado: {color_rojo}Token no renovado{color_fin}") else: print(f"{prefijo_hijo}└── Resultado: {color_rojo}Token expiró{color_fin}") registrar_falla(docentry, ruc, tipo_cpe, serie, numero, "Token expiró") break except Exception as e: print(f"{prefijo_hijo}└── Resultado: {color_rojo}error{color_fin} ({e})") registrar_falla(docentry, ruc, tipo_cpe, serie, numero, str(e)) break # ======================================================================== for idx, doc in enumerate(docs_nuevos): _procesar(doc, idx, len(docs_nuevos), es_retry=False) if docs_retry: print() for idx, doc in enumerate(docs_retry): _procesar(doc, idx, len(docs_retry), es_retry=True) # ============================================================================ def signal_handler(signum, frame): global _running print("\n\n⏹️ Señal de parada recibida. Deteniendo...") _running = False # ============================================================================ def main(): global _running signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) log_path = setup_logger() limpiar_logs_antiguos(dias=7) init_db() print(f"📄 Log: {log_path}") print("=" * 60) print("🚀 DAEMON DESCARGA XML/PDF SUNAT") print("=" * 60) print(f"\n📅 Días activos: {DIAS_ACTIVOS} (0=lun, 6=dom)") print(f"⏰ Horarios:") for hi, mi, hf, mf, intervalo, modo in HORARIOS: print(f" {hi:02d}:{mi:02d} - {hf:02d}:{mf:02d} | cada {intervalo}s | {modo}") print() token = None while _running: ahora = datetime.now() rotar_log() if ahora.weekday() not in DIAS_ACTIVOS: espera = segundos_hasta_proximo_dia_activo(ahora) prox = ahora + timedelta(seconds=espera) print(f"⏸️ Día no activo. Siguiente inicio: {prox.strftime('%a %Y-%m-%d %H:%M:%S')}") for _ in range(int(espera)): if not _running: break time.sleep(1) continue intervalo_info = obtener_intervalo(ahora) if not intervalo_info: espera = segundos_hasta_proximo_inicio(ahora) prox = ahora + timedelta(seconds=espera) print(f"⏸️ Fuera de horario. Siguiente inicio: {prox.strftime('%H:%M:%S')}") for _ in range(int(espera)): if not _running: break time.sleep(1) continue intervalo, modo = intervalo_info print(f"\n{'#'*60}") print(f"# 🔄 CICLO: {ahora.strftime('%Y-%m-%d %H:%M:%S')} | modo={modo} | intervalo={intervalo}s") print(f"{'#'*60}") if not token: token = obtener_token() if not token: print(" ❌ No se pudo obtener token. Esperando 60s...") time.sleep(60) continue procesar_lote(token) try: sincronizar_a_smb(str(DOWNLOAD_DIR)) except Exception as e: print(f" ⚠️ Error en sincronización SMB: {e}") # Verificar si el token sigue válido para el próximo ciclo from consulta_cpe import TOKEN_FILE as tf if tf.exists(): try: data = json.loads(tf.read_text()) expires = data.get("expires_at", 0) remaining = expires - time.time() if remaining < 300: print(f"\n⚠️ Token por expirar ({int(remaining)}s). Se renovará en próximo ciclo.") token = None except: pass print(f"\n⏳ Esperando {intervalo}s hasta próximo ciclo...") for _ in range(intervalo): if not _running: break time.sleep(1) cerrar_conexion() print("\n👋 Daemon detenido.") # ============================================================================ if __name__ == "__main__": main() # ============================================================================