caja menor y libro diario
This commit is contained in:
+363
@@ -0,0 +1,363 @@
|
||||
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
|
||||
app = Flask(__name__, static_folder="../fronted", static_url_path="")
|
||||
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 ============
|
||||
@app.route("/")
|
||||
def index():
|
||||
return send_from_directory(app.static_folder, "index.html")
|
||||
|
||||
@app.route("/<path:archivo>")
|
||||
def archivos_estaticos(archivo):
|
||||
return send_from_directory(app.static_folder, archivo)
|
||||
|
||||
# ============ 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)
|
||||
|
||||
Reference in New Issue
Block a user