from flask import Flask, request, jsonify, send_from_directory from flask_cors import CORS import requests import sqlite3 import threading import time import os from datetime import datetime # Servir archivos estáticos desde la carpeta fronted (sin static_url_path para controlar rutas manualmente) app = Flask(__name__, static_folder="../fronted") CORS(app) # URL por defecto (se puede sobreescribir vía /config o por el body en /test_gs) API_URL = "https://script.google.com/macros/s/AKfycbwSLQWgSxHy51_vVopRyKss0UlrqCuKpO8LhxaEBztzsk-Idc-_LnvjsT5dUEwTlb9r/exec" BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DB_DIR = os.path.join(BASE_DIR, "..", "db") INTERVALO_SYNC = 600 # 10 minutos en segundos # ============ CONFIGURACIÓN DE MÓDULOS ============ # Cada módulo tiene su PROPIA base de datos SQLite y su hoja de Google Sheets MODULOS = { "cajaMenor": { "db": os.path.join(DB_DIR, "cajaMenor.db"), "tabla": "movimientos", "hoja_sheets": "CajaMenor" }, "libroDiario": { "db": os.path.join(DB_DIR, "libroDiario.db"), "tabla": "movimientos", "hoja_sheets": "libroDiario" } } # ============ BASE DE DATOS ============ def get_db(modulo): """Abre la base de datos del módulo indicado""" os.makedirs(DB_DIR, exist_ok=True) conn = sqlite3.connect(modulo["db"]) conn.row_factory = sqlite3.Row return conn def inicializar_db(): for modulo in MODULOS.values(): conn = get_db(modulo) conn.execute(f""" CREATE TABLE IF NOT EXISTS {modulo["tabla"]} ( id INTEGER PRIMARY KEY AUTOINCREMENT, fecha TEXT NOT NULL, tipo TEXT NOT NULL, descripcion TEXT NOT NULL, monto INTEGER NOT NULL, sincronizado INTEGER DEFAULT 0, borrado INTEGER DEFAULT 0, creado_en TEXT DEFAULT CURRENT_TIMESTAMP ) """) conn.commit() conn.close() # Migración para DBs existentes: agregar columna borrado si no existe for modulo in MODULOS.values(): conn = get_db(modulo) columnas = [c[1] for c in conn.execute(f"PRAGMA table_info({modulo['tabla']})").fetchall()] if "borrado" not in columnas: conn.execute(f"ALTER TABLE {modulo['tabla']} ADD COLUMN borrado INTEGER DEFAULT 0") conn.commit() conn.close() # ============ SERVIR EL FRONTEND ============ import os.path @app.route("/") def index(): return send_from_directory(os.path.join(app.static_folder, "html"), "index.html") @app.route("/") def archivos_estaticos(archivo): base_dir = app.static_folder # Extraer solo el nombre del archivo (sin carpetas previas) nombre_archivo = os.path.basename(archivo) # Determinar en qué carpeta buscar según la extensión if nombre_archivo.endswith(".html"): carpeta = "html" elif nombre_archivo.endswith(".css"): carpeta = "css" elif nombre_archivo.endswith(".js"): carpeta = "js" else: carpeta = "" ruta_completa = os.path.join(base_dir, carpeta, nombre_archivo) if carpeta else os.path.join(base_dir, nombre_archivo) # Debug: log en consola print(f"[STATIC] Recibido: {archivo} | Buscando: {ruta_completa}") if os.path.exists(ruta_completa): directorio = os.path.dirname(ruta_completa) nombre_archivo_final = os.path.basename(ruta_completa) return send_from_directory(directorio, nombre_archivo_final) else: return f"Archivo no encontrado: {ruta_completa}", 404 # ============ LEER MOVIMIENTOS (desde SQLite - instantáneo) ============ @app.route("/leer", methods=["GET"]) def leer(): nombre = request.args.get("hoja", "cajaMenor") modulo = MODULOS.get(nombre) if not modulo: return jsonify({"error": f"Módulo '{nombre}' no existe"}), 400 conn = get_db(modulo) filas = conn.execute( f"SELECT * FROM {modulo['tabla']} WHERE borrado = 0 ORDER BY fecha DESC, id DESC" ).fetchall() conn.close() datos = [ { "id": fila["id"], "Fecha": fila["fecha"], "Tipo": fila["tipo"], "Descripción": fila["descripcion"], "Monto": fila["monto"], "sincronizado": fila["sincronizado"] } for fila in filas ] return jsonify({"datos": datos}) # ============ GUARDAR MOVIMIENTO (SQLite - instantáneo) ============ @app.route("/guardar", methods=["POST"]) def guardar(): body = request.json nombre = body.get("hoja", "cajaMenor") modulo = MODULOS.get(nombre) if not modulo: return jsonify({"error": f"Módulo '{nombre}' no existe"}), 400 datos = body.get("datos", {}) conn = get_db(modulo) cursor = conn.execute( f"INSERT INTO {modulo['tabla']} (fecha, tipo, descripcion, monto) VALUES (?, ?, ?, ?)", ( datos.get("Fecha", ""), datos.get("Tipo", ""), datos.get("Descripción", ""), int(float(datos.get("Monto", 0))) ) ) conn.commit() nuevo_id = cursor.lastrowid conn.close() return jsonify({"ok": True, "id": nuevo_id, "mensaje": "Guardado localmente, se sincronizará con Google Sheets"}) # ============ ACTUALIZAR MOVIMIENTO ============ @app.route("/actualizar", methods=["PUT"]) def actualizar(): body = request.json nombre = body.get("hoja") modulo = MODULOS.get(nombre) if not modulo: return jsonify({"error": f"Módulo '{nombre}' no existe"}), 400 datos = body.get("datos", {}) id_reg = body.get("id") conn = get_db(modulo) fila = conn.execute(f"SELECT sincronizado FROM {modulo['tabla']} WHERE id = ?", (id_reg,)).fetchone() if not fila: conn.close() return jsonify({"error": "Registro no encontrado"}), 404 conn.execute( f"UPDATE {modulo['tabla']} SET fecha=?, tipo=?, descripcion=?, monto=? WHERE id=?", ( datos.get("Fecha", ""), datos.get("Tipo", ""), datos.get("Descripción", ""), int(float(datos.get("Monto", 0))), id_reg ) ) # Si ya había subido a Sheets, marcar pendiente para que el sync lo actualice allá if fila["sincronizado"] == 1: conn.execute(f"UPDATE {modulo['tabla']} SET sincronizado = 0 WHERE id = ?", (id_reg,)) conn.commit() conn.close() return jsonify({"ok": True}) # ============ BORRAR MOVIMIENTO ============ @app.route("/borrar", methods=["DELETE"]) def borrar(): body = request.json nombre = body.get("hoja") modulo = MODULOS.get(nombre) if not modulo: return jsonify({"error": f"Módulo '{nombre}' no existe"}), 400 id_reg = body.get("id") conn = get_db(modulo) fila = conn.execute(f"SELECT sincronizado FROM {modulo['tabla']} WHERE id = ?", (id_reg,)).fetchone() if not fila: conn.close() return jsonify({"error": "Registro no encontrado"}), 404 if fila["sincronizado"] == 1: # Ya está en Sheets: soft delete, el sync lo borrará allá conn.execute(f"UPDATE {modulo['tabla']} SET borrado = 1, sincronizado = 0 WHERE id = ?", (id_reg,)) else: # Nunca subió a Sheets: borrado definitivo conn.execute(f"DELETE FROM {modulo['tabla']} WHERE id = ?", (id_reg,)) conn.commit() conn.close() return jsonify({"ok": True}) # ============ SINCRONIZACIÓN CON GOOGLE SHEETS (background) ============ def sincronizar_tabla(nombre_modulo, modulo): """Sube los pendientes de un módulo a su hoja de Google Sheets""" conn = get_db(modulo) pendientes = conn.execute( f"SELECT * FROM {modulo['tabla']} WHERE sincronizado = 0" ).fetchall() if not pendientes: conn.close() return 0 ids_ok = [] for fila in pendientes: # Determinar la acción: borrar / actualizar / nuevo if fila["borrado"] == 1: accion = "borrar" datos = None else: # Si la última columna Sheets ya la tiene, es actualizar; si no, nuevo # (el Apps Script busca por id; si no existe, agrega fila nueva) accion = "actualizar" datos = { "Fecha": fila["fecha"], "Tipo": fila["tipo"], "Descripción": fila["descripcion"], "Monto": str(fila["monto"]) } payload = { "hoja": modulo["hoja_sheets"], "accion": accion, "id": fila["id"], "datos": datos } try: respuesta = requests.post(API_URL, json=payload, allow_redirects=True, timeout=30) print(f"[SYNC] POST {nombre_modulo} id={fila['id']} status={respuesta.status_code} content-type={respuesta.headers.get('content-type','?')}") if respuesta.status_code != 200 or "html" in respuesta.headers.get("content-type", "").lower(): print(f"[SYNC] Respuesta inesperada (HTML?): {respuesta.text[:300]}") continue resultado = respuesta.json() if resultado.get("ok"): if fila["borrado"] == 1: # Borrado exitoso en Sheets → eliminar definitivamente de SQLite conn.execute(f"DELETE FROM {modulo['tabla']} WHERE id = ?", (fila["id"],)) conn.commit() print(f"[SYNC] {nombre_modulo}: id {fila['id']} borrado de Google Sheets ({resultado.get('mensaje', '')})") else: ids_ok.append(fila["id"]) else: print(f"[SYNC] Apps Script respondió error: {resultado}") except Exception as e: print(f"[SYNC] Error subiendo {nombre_modulo} id {fila['id']}: {e}") if ids_ok: placeholders = ",".join("?" * len(ids_ok)) conn.execute(f"UPDATE {modulo['tabla']} SET sincronizado = 1 WHERE id IN ({placeholders})", ids_ok) conn.commit() print(f"[SYNC {datetime.now().strftime('%H:%M:%S')}] {nombre_modulo}: {len(ids_ok)} registros subidos a Google Sheets") conn.close() return len(pendientes) - len(ids_ok) # cuántos quedaron pendientes def sincronizar_pendientes(): """Sincroniza todos los módulos con Google Sheets""" total_pendientes = 0 for nombre, modulo in MODULOS.items(): total_pendientes += sincronizar_tabla(nombre, modulo) if total_pendientes == 0: print(f"[SYNC {datetime.now().strftime('%H:%M:%S')}] Todo sincronizado") return total_pendientes def ciclo_sincronizacion(): """Hilo que corre la sincronización cada INTERVALO_SYNC segundos""" while True: time.sleep(INTERVALO_SYNC) sincronizar_pendientes() # ============ FORZAR SINCRONIZACIÓN MANUAL ============ @app.route("/sincronizar", methods=["POST"]) def sincronizar_ahora(): pendientes = sincronizar_pendientes() return jsonify({"ok": True, "pendientes": pendientes}) # ============ MARCAR TODO COMO PENDIENTE (re-subir a Sheets) ============ @app.route("/reset_sync", methods=["POST"]) def reset_sync(): """Marca todos los registros activos como no sincronizados. Útil después de limpiar la hoja de Google para re-subir todo desde cero.""" body = request.json or {} nombre = body.get("hoja") if nombre: modulo = MODULOS.get(nombre) if not modulo: return jsonify({"error": f"Módulo '{nombre}' no existe"}), 400 modulos = {nombre: modulo} else: modulos = MODULOS total = 0 for modulo in modulos.values(): conn = get_db(modulo) cursor = conn.execute(f"UPDATE {modulo['tabla']} SET sincronizado = 0 WHERE borrado = 0") total += cursor.rowcount conn.commit() conn.close() return jsonify({"ok": True, "marcados": total, "mensaje": "Ahora llama a /sincronizar para re-subir todo"}) # ============ CONFIGURAR URL DE GOOGLE SHEETS ============ @app.route("/config", methods=["POST"]) def configurar_url(): """Permite cambiar la URL de Apps Script en runtime.""" global API_URL body = request.json or {} url = body.get("api_url") if not url: return jsonify({"error": "Falta api_url en el body"}), 400 API_URL = url return jsonify({"ok": True, "api_url": API_URL}) # ============ PROBAR CONEXIÓN DIRECTA A GOOGLE SHEETS ============ @app.route("/test_gs", methods=["POST"]) def test_gs(): """Endpoint para probar manualmente una URL de Apps Script. Si envías api_url en el body, se usa esa URL; si no, la global. """ body = request.json or {} url = body.get("api_url", API_URL) payload = { "hoja": body.get("hoja", "CajaMenor"), "accion": body.get("accion", "nuevo"), "id": body.get("id", "777"), "datos": body.get("datos", { "Fecha": "2026-09-03", "Tipo": "Ingreso", "Descripción": "Prueba backend", "Monto": "1" }) } try: respuesta = requests.post(url, json=payload, allow_redirects=True, timeout=30) return jsonify({ "url_usada": url, "status": respuesta.status_code, "content_type": respuesta.headers.get("content-type"), "primeros_300_chars": respuesta.text[:300], "json_parseable": respuesta.headers.get("content-type", "").lower().startswith("application/json") }) except Exception as e: return jsonify({"error": str(e)}), 500 # ============ INICIO ============ if __name__ == "__main__": inicializar_db() # Lanzar el hilo de sincronización hilo = threading.Thread(target=ciclo_sincronizacion, daemon=True) hilo.start() print(f"🚀 Servidor iniciado. Sincronización con Google Sheets cada {INTERVALO_SYNC // 60} minutos") print(f"📦 Módulos activos: {', '.join(MODULOS.keys())}") # Primera sincronización al arrancar (por si quedó algo pendiente de antes) sincronizar_pendientes() app.run(host="0.0.0.0", port=5000, debug=True, use_reloader=False)