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 logger_manager import get_logger, setup_logging, limpiar_logs_antiguos, LOG_FILE from hana_manager import obtener_pendientes, marcar_pdf_descargado, marcar_xml_descargado, cerrar_conexion from gestor_fallas import init_db, registrar_falla, eliminar_falla, obtener_fallas_pendientes, obtener_docentries_falla, total_fallas from historial_manager import init_db as init_historial_db, registrar_intento, limpiar_historial_antiguo 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 ) logger = get_logger(__name__) # ============================================================================ # 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: logger.warning(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: logger.info("Token existente válido, reutilizando") return token logger.info("Token no disponible o expirado. Obteniendo nuevo...") from get_token_headless import get_token_headless as obtener_headless, firefox_instalado if not firefox_instalado(): logger.warning("Firefox no instalado. Saltando headless...") else: try: if obtener_headless(): logger.info("Token obtenido vía get_token_headless (headless)") return load_token() except Exception as e: logger.warning(f"Headless falló: {e}") logger.warning("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: logger.info("Token obtenido vía Playwright (get_token.py)") return tk time.sleep(1) logger.warning("No se detectó token tras 30s. Continuando...") return load_token() # ============================================================================ def procesar_lote(token): """Procesa documentos pendientes desde HANA.""" logger.info("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"], "ObjType": d.get("objtype", 18), "Intentos": d.get("intentos", 1), "tienePDF": "N", "tieneXML": "N", }) total_fallas_count = total_fallas() if not docs_nuevos and not docs_retry: logger.info("No hay documentos pendientes") return logger.info(f"{len(docs_hana)} en HANA | {len(docs_nuevos)} nuevos | {len(docs_retry)} reintentos | {total_fallas_count} en fallas") 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 obj_type = doc.get("ObjType") or 18 intento_num = doc.get("Intentos") or 1 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: logger.warning(f"DocEntry={docentry} DocNum={docnum}: datos incompletos, saltando") return ultimo = idx == total - 1 tag = "🔄" if es_retry else "📄" cid = cpe_id(ruc, tipo_cpe, serie, numero) task_cm = logger.last_task if ultimo else logger.task with task_cm(f"[{idx+1}/{total}] {ruc}-{tipo_cpe}-{serie}-{numero} [DocEntry={docentry} | DocNum={docnum}]", emoji=tag) as doc_log: 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, json_path = consultar_cpe(token, ruc, tipo_cpe, serie, numero, descargar=False, verbose=False) if not data: doc_log.error(f"Consulta: {color_rojo}Error {status_code}{color_fin}", _last=True) registrar_falla(docentry, ruc, tipo_cpe, serie, numero, obj_type, f"Error HTTP {status_code}") registrar_intento(docentry, docnum, ruc, tipo_cpe, serie, numero, obj_type, es_retry, intento_num, status_code, "no_aplica", "no_aplica", "error", f"Error HTTP {status_code}", None) return doc_log.info(f"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, obj_type) pdf_msg = f"{color_verde}📥 descargado{color_fin}" else: pdf_msg = f"{color_amarillo}⚠️ error{color_fin}" hubo_error = True doc_log.info(f"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, obj_type) xml_msg = f"{color_verde}📥 descargado{color_fin}" else: xml_msg = f"{color_amarillo}⚠️ error{color_fin}" hubo_error = True doc_log.info(f"XML: {xml_msg}") doc_log.info(f"HANA: {ic(ok_pdf and ok_xml, '✅ actualizado', '⚠️ pendiente')}") if ok_pdf and ok_xml: if es_retry: doc_log.info(f"SQLite: {color_amarillo}🗑️ quitando registro{color_fin}") eliminar_falla(docentry) doc_log.info(f"Resultado: {color_verde}✅ OK{color_fin}", _last=True) detalle_historial = "OK" else: fallos = [s for s, ok in [("PDF", ok_pdf), ("XML", ok_xml)] if not ok] doc_log.warning(f"Resultado: {color_amarillo}⚠️ parcial{color_fin} ({', '.join(fallos)})", _last=True) detalle_historial = f"falló: {', '.join(fallos)}" if hubo_error: registrar_falla(docentry, ruc, tipo_cpe, serie, numero, obj_type, detalle_historial) pdf_resultado = "ya_tenia" if tiene_pdf == "Y" else ("descargado" if ok_pdf else "error") xml_resultado = "ya_tenia" if tiene_xml == "Y" else ("descargado" if ok_xml else "error") registrar_intento(docentry, docnum, ruc, tipo_cpe, serie, numero, obj_type, es_retry, intento_num, status_code, pdf_resultado, xml_resultado, "ok" if (ok_pdf and ok_xml) else "parcial", detalle_historial, json_path) break except TokenExpiredError: if intento_token == 0: doc_log.info("Token expirado, renovando...") nuevo_token = obtener_token() if nuevo_token: token = nuevo_token doc_log.info("Token renovado, reintentando...") continue doc_log.error(f"Resultado: {color_rojo}Token no renovado{color_fin}", _last=True) else: doc_log.error(f"Resultado: {color_rojo}Token expiró{color_fin}", _last=True) registrar_falla(docentry, ruc, tipo_cpe, serie, numero, obj_type, "Token expiró") registrar_intento(docentry, docnum, ruc, tipo_cpe, serie, numero, obj_type, es_retry, intento_num, None, "no_aplica", "no_aplica", "error", "Token expiró", None) break except Exception as e: doc_log.error(f"Resultado: {color_rojo}error{color_fin} ({e})", _last=True) registrar_falla(docentry, ruc, tipo_cpe, serie, numero, obj_type, str(e)) registrar_intento(docentry, docnum, ruc, tipo_cpe, serie, numero, obj_type, es_retry, intento_num, None, "no_aplica", "no_aplica", "error", str(e), None) break # ======================================================================== for idx, doc in enumerate(docs_nuevos): _procesar(doc, idx, len(docs_nuevos), es_retry=False) for idx, doc in enumerate(docs_retry): _procesar(doc, idx, len(docs_retry), es_retry=True) # ============================================================================ def signal_handler(signum, frame): global _running logger.warning("Señal de parada recibida. Deteniendo...") _running = False # ============================================================================ def main(): global _running signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) setup_logging() limpiar_logs_antiguos(dias=7) init_db() init_historial_db() limpiar_historial_antiguo(dias=180) logger.info(f"Log: {LOG_FILE}") logger.info("=" * 60) logger.info("DAEMON DESCARGA XML/PDF SUNAT") logger.info("=" * 60) logger.info(f"Días activos: {DIAS_ACTIVOS} (0=lun, 6=dom)") logger.info("Horarios:") for hi, mi, hf, mf, intervalo, modo in HORARIOS: logger.info(f"{hi:02d}:{mi:02d} - {hf:02d}:{mf:02d} | cada {intervalo}s | {modo}") token = None while _running: ahora = datetime.now() if ahora.weekday() not in DIAS_ACTIVOS: espera = segundos_hasta_proximo_dia_activo(ahora) prox = ahora + timedelta(seconds=espera) logger.info(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) logger.info(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 logger.info("#" * 60) logger.info(f"CICLO: {ahora.strftime('%Y-%m-%d %H:%M:%S')} | modo={modo} | intervalo={intervalo}s") logger.info("#" * 60) if not token: token = obtener_token() if not token: logger.error("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: logger.warning(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: logger.warning(f"Token por expirar ({int(remaining)}s). Se renovará en próximo ciclo.") token = None except: pass logger.info(f"Esperando {intervalo}s hasta próximo ciclo...") for _ in range(intervalo): if not _running: break time.sleep(1) cerrar_conexion() logger.info("Daemon detenido.") # ============================================================================ if __name__ == "__main__": main() # ============================================================================