diff --git a/app.py b/app.py new file mode 100644 index 0000000..99da57d --- /dev/null +++ b/app.py @@ -0,0 +1,202 @@ +""" +app.py — Lista de la Compra Inteligente +Flask app con autenticacion de sesion. + +Arrancar en desarrollo: + python app.py + +En produccion usa gunicorn detras de nginx: + gunicorn -w 2 -b 127.0.0.1:5000 app:app +""" + +import os +import json +import subprocess +import sys +from pathlib import Path +from functools import wraps +from datetime import timedelta + +from flask import ( + Flask, render_template, request, redirect, + url_for, session, jsonify, abort, send_from_directory +) +from werkzeug.security import generate_password_hash, check_password_hash + +# ----------------------------------------------------------------------- +# Configuracion +# ----------------------------------------------------------------------- +BASE_DIR = Path(__file__).parent +DATOS_JSON = BASE_DIR / "datos.json" +USERS_FILE = BASE_DIR / "users.json" +TICKETS_DIR = BASE_DIR / "tickets" + +app = Flask(__name__, template_folder="templates", static_folder="static") + +app.secret_key = os.environ.get("SECRET_KEY", "cambia-esto-en-produccion-!!!!") +app.permanent_session_lifetime = timedelta(days=30) + +# ----------------------------------------------------------------------- +# Gestion de usuarios (users.json — NO subir al repo) +# ----------------------------------------------------------------------- +def cargar_usuarios(): + if not USERS_FILE.exists(): + return {} + with open(USERS_FILE, encoding="utf-8") as f: + return json.load(f) + +def guardar_usuarios(users): + with open(USERS_FILE, "w", encoding="utf-8") as f: + json.dump(users, f, indent=2, ensure_ascii=False) + +def inicializar_admin(): + """Crea el usuario admin la primera vez si no existe users.json.""" + if USERS_FILE.exists(): + return + pwd = os.environ.get("ADMIN_PASSWORD", "cambia-esta-password") + users = { + "admin": { + "password_hash": generate_password_hash(pwd), + "nombre": "Admin" + } + } + guardar_usuarios(users) + print(f"[init] Usuario admin creado. Password: {pwd}") + print(f"[init] Cambialo en users.json o con ADMIN_PASSWORD en el entorno.") + +# ----------------------------------------------------------------------- +# Decorador de autenticacion +# ----------------------------------------------------------------------- +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("usuario"): + return redirect(url_for("login", next=request.path)) + return f(*args, **kwargs) + return decorated + +# ----------------------------------------------------------------------- +# Rutas de autenticacion +# ----------------------------------------------------------------------- +@app.route("/login", methods=["GET", "POST"]) +def login(): + error = None + if request.method == "POST": + usuario = request.form.get("usuario", "").strip().lower() + password = request.form.get("password", "") + users = cargar_usuarios() + user = users.get(usuario) + if user and check_password_hash(user["password_hash"], password): + session.permanent = True + session["usuario"] = usuario + session["nombre"] = user.get("nombre", usuario) + next_url = request.form.get("next") or url_for("index") + return redirect(next_url) + error = "Usuario o contrasena incorrectos" + return render_template("login.html", error=error, + next=request.args.get("next", "")) + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + +# ----------------------------------------------------------------------- +# Pagina principal +# ----------------------------------------------------------------------- +@app.route("/") +@login_required +def index(): + return render_template("index.html", nombre=session.get("nombre", "")) + +# ----------------------------------------------------------------------- +# API: datos de predicciones +# ----------------------------------------------------------------------- +@app.route("/api/datos") +@login_required +def api_datos(): + if not DATOS_JSON.exists(): + return jsonify({"error": "datos.json no generado", "predicciones": []}), 404 + with open(DATOS_JSON, encoding="utf-8") as f: + datos = json.load(f) + return jsonify(datos) + +# ----------------------------------------------------------------------- +# API: forzar regeneracion del pipeline +# ----------------------------------------------------------------------- +@app.route("/api/regenerar", methods=["POST"]) +@login_required +def api_regenerar(): + try: + subprocess.run( + [sys.executable, str(BASE_DIR / "autocompra7.py")], + cwd=str(BASE_DIR), check=True, capture_output=True, timeout=120 + ) + subprocess.run( + [sys.executable, str(BASE_DIR / "generar_lista.py")], + cwd=str(BASE_DIR), check=True, capture_output=True, timeout=30 + ) + return jsonify({"ok": True, "mensaje": "Pipeline ejecutado correctamente"}) + except subprocess.CalledProcessError as e: + return jsonify({"ok": False, "mensaje": str(e)}), 500 + except subprocess.TimeoutExpired: + return jsonify({"ok": False, "mensaje": "Timeout al ejecutar el pipeline"}), 500 + +# ----------------------------------------------------------------------- +# Archivos estaticos de tickets (solo autenticados) +# ----------------------------------------------------------------------- +@app.route("/tickets/") +@login_required +def ticket_file(filename): + return send_from_directory(str(TICKETS_DIR), filename) + +# ----------------------------------------------------------------------- +# Gestion de usuarios (solo admin) +# ----------------------------------------------------------------------- +@app.route("/admin/usuarios") +@login_required +def admin_usuarios(): + if session.get("usuario") != "admin": + abort(403) + users = cargar_usuarios() + lista = [{"usuario": k, "nombre": v.get("nombre", k)} for k, v in users.items()] + return render_template("admin_usuarios.html", usuarios=lista) + +@app.route("/admin/usuarios/crear", methods=["POST"]) +@login_required +def admin_crear_usuario(): + if session.get("usuario") != "admin": + abort(403) + usuario = request.form.get("usuario", "").strip().lower() + nombre = request.form.get("nombre", "").strip() + password = request.form.get("password", "") + if not usuario or not password: + abort(400) + users = cargar_usuarios() + users[usuario] = { + "password_hash": generate_password_hash(password), + "nombre": nombre or usuario + } + guardar_usuarios(users) + return redirect(url_for("admin_usuarios")) + +@app.route("/admin/usuarios/eliminar", methods=["POST"]) +@login_required +def admin_eliminar_usuario(): + if session.get("usuario") != "admin": + abort(403) + usuario = request.form.get("usuario", "").strip().lower() + if usuario == "admin": + abort(400) + users = cargar_usuarios() + users.pop(usuario, None) + guardar_usuarios(users) + return redirect(url_for("admin_usuarios")) + +# ----------------------------------------------------------------------- +# Entry point +# ----------------------------------------------------------------------- +if __name__ == "__main__": + inicializar_admin() + debug = os.environ.get("FLASK_DEBUG", "false").lower() == "true" + app.run(host="127.0.0.1", port=5000, debug=debug) \ No newline at end of file diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..30ebdc4 --- /dev/null +++ b/css/style.css @@ -0,0 +1,269 @@ +/* ============================================================ + Lista de la Compra Inteligente — Dark Theme + ============================================================ */ + +:root { + --bg: #0d1117; + --bg-card: #161b22; + --bg-hover: #1c2128; + --bg-input: #0d1117; + --border: #30363d; + --text: #e6edf3; + --text-muted: #8b949e; + --primary: #388bfd; + --primary-h: #58a6ff; + + /* badges */ + --badge-sem-bg: #3d2b1f; + --badge-sem-tx: #f0883e; + --badge-qui-bg: #2d2816; + --badge-qui-tx: #d29922; + --badge-men-bg: #162518; + --badge-men-tx: #3fb950; + --badge-esp-bg: #21262d; + --badge-esp-tx: #8b949e; +} + +*, *::before, *::after { box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + max-width: 960px; + margin: 0 auto; + padding: 1rem; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +h1 { font-size: 1.6rem; margin: 0 0 .25rem; color: var(--text); } +h2 { + font-size: .85rem; + margin: 1.5rem 0 .5rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: .08em; + font-weight: 600; +} + +.subtitle { color: var(--text-muted); font-size: .85rem; margin-bottom: 1.5rem; } + +/* ---- Layout ---- */ +.layout { + display: grid; + grid-template-columns: 1fr 300px; + gap: 1.5rem; + align-items: start; +} +@media (max-width: 700px) { + .layout { grid-template-columns: 1fr; } +} + +/* ---- Cards ---- */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; +} +.card-header { + display: flex; + align-items: center; + gap: .5rem; + margin-bottom: .75rem; +} +.card-header strong { color: var(--text); font-size: .95rem; } + +/* ---- Badges ---- */ +.badge { + display: inline-block; + padding: .2rem .6rem; + border-radius: 20px; + font-size: .72rem; + font-weight: 600; +} +.badge-rojo { background: var(--badge-sem-bg); color: var(--badge-sem-tx); } +.badge-naranja { background: var(--badge-qui-bg); color: var(--badge-qui-tx); } +.badge-verde { background: var(--badge-men-bg); color: var(--badge-men-tx); } +.badge-gris { background: var(--badge-esp-bg); color: var(--badge-esp-tx); } + +/* ---- Producto item ---- */ +.prod-item { + display: flex; + align-items: center; + gap: .6rem; + padding: .45rem .25rem; + border-bottom: 1px solid var(--border); + cursor: pointer; + border-radius: 4px; + transition: background .1s; +} +.prod-item:last-child { border-bottom: none; } +.prod-item:hover { background: var(--bg-hover); } +.prod-item input[type="checkbox"] { + width: 15px; + height: 15px; + cursor: pointer; + flex-shrink: 0; + accent-color: var(--primary); +} +.prod-nombre { flex: 1; font-size: .92rem; color: var(--text); } +.prod-nombre.tachado { text-decoration: line-through; color: var(--text-muted); } +.prod-freq { font-size: .72rem; color: var(--text-muted); white-space: nowrap; } + +/* ---- Panel lateral ---- */ +.panel { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem 1.25rem; + position: sticky; + top: 1rem; +} +.panel h2 { margin-top: 0; } + +#listaGenerada { + width: 100%; + height: 220px; + font-size: .88rem; + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: .6rem; + resize: vertical; + font-family: inherit; +} +#listaGenerada:focus { outline: none; border-color: var(--primary); } + +/* ---- Botones ---- */ +.btn { + display: inline-flex; + align-items: center; + gap: .35rem; + padding: .5rem 1rem; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: .88rem; + font-weight: 500; + transition: background .15s; +} +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:hover { background: var(--primary-h); } +.btn-secondary { + background: #21262d; + color: var(--text); + border: 1px solid var(--border); + margin-left: .4rem; +} +.btn-secondary:hover { background: #30363d; } +.btn-sm { padding: .3rem .7rem; font-size: .8rem; } + +/* ---- Toolbar ---- */ +.toolbar { + display: flex; + gap: .5rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +/* ---- Input añadir manual ---- */ +.add-row { display: flex; gap: .5rem; margin-top: .5rem; } +.add-row input { + flex: 1; + padding: .42rem .7rem; + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + font-size: .88rem; +} +.add-row input:focus { outline: none; border-color: var(--primary); } + +/* ---- Sin datos ---- */ +.no-datos { + text-align: center; + padding: 2rem; + color: var(--text-muted); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; +} +.no-datos code { + display: block; + background: var(--bg); + color: var(--primary-h); + border: 1px solid var(--border); + padding: .5rem 1rem; + border-radius: 6px; + margin: .75rem auto; + width: fit-content; + font-size: .9rem; +} + +/* ---- Drag & drop zona ticket ---- */ +.drop-zone { + border: 2px dashed var(--border); + border-radius: 8px; + padding: 1.2rem; + text-align: center; + color: var(--text-muted); + font-size: .85rem; + cursor: pointer; + transition: border-color .2s, background .2s; + margin-top: .5rem; + position: relative; +} +.drop-zone:hover, +.drop-zone.drag-over { + border-color: var(--primary); + background: #0d2044; + color: var(--text); +} +.drop-zone input[type="file"] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + width: 100%; + height: 100%; +} +.drop-zone-icon { font-size: 1.5rem; display: block; margin-bottom: .3rem; } + +#pdfTexto { + white-space: pre-wrap; + background: var(--bg); + color: var(--text-muted); + border: 1px solid var(--border); + padding: .75rem; + border-radius: 6px; + font-size: .78rem; + max-height: 250px; + overflow-y: auto; + display: none; + margin-top: .75rem; + font-family: 'Courier New', monospace; +} + +/* ---- Count badge ---- */ +.count-badge { + background: var(--primary); + color: #fff; + border-radius: 50%; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: .7rem; + font-weight: 700; + margin-left: .4rem; + vertical-align: middle; +} + +/* ---- Scrollbar oscura ---- */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } \ No newline at end of file diff --git a/datos.js b/datos.js new file mode 100644 index 0000000..7cb150b --- /dev/null +++ b/datos.js @@ -0,0 +1,724 @@ +// Generado el 24/04/2026 23:10 - 120 productos +const GENERADO = "24/04/2026 23:10"; +const predicciones = [ + { + "producto": "Champiñon Limpio Lam", + "fecha_estimada": "20/04/2025", + "dias_hasta": -369, + "frecuencia_dias": 4.9 + }, + { + "producto": "Q. Lonchas Light", + "fecha_estimada": "22/04/2025", + "dias_hasta": -367, + "frecuencia_dias": 6.0 + }, + { + "producto": "+Prot Pud Caramelo", + "fecha_estimada": "22/04/2025", + "dias_hasta": -367, + "frecuencia_dias": 6.9 + }, + { + "producto": "+Prot Natilla Vaini", + "fecha_estimada": "22/04/2025", + "dias_hasta": -367, + "frecuencia_dias": 6.9 + }, + { + "producto": "Girasoles Quesos", + "fecha_estimada": "23/04/2025", + "dias_hasta": -366, + "frecuencia_dias": 7.0 + }, + { + "producto": "Q. Lonchas Cremoso", + "fecha_estimada": "23/04/2025", + "dias_hasta": -366, + "frecuencia_dias": 7.0 + }, + { + "producto": "Frankfurt Viena Pavo", + "fecha_estimada": "23/04/2025", + "dias_hasta": -366, + "frecuencia_dias": 7.0 + }, + { + "producto": "Yogur Limon", + "fecha_estimada": "23/04/2025", + "dias_hasta": -366, + "frecuencia_dias": 7.0 + }, + { + "producto": "Griego Natural", + "fecha_estimada": "23/04/2025", + "dias_hasta": -366, + "frecuencia_dias": 7.0 + }, + { + "producto": "14 Estaciones", + "fecha_estimada": "23/04/2025", + "dias_hasta": -366, + "frecuencia_dias": 7.0 + }, + { + "producto": "+Prot Natilla Choco", + "fecha_estimada": "23/04/2025", + "dias_hasta": -366, + "frecuencia_dias": 7.0 + }, + { + "producto": "Fritada Pisto", + "fecha_estimada": "23/04/2025", + "dias_hasta": -366, + "frecuencia_dias": 7.0 + }, + { + "producto": "112 Huevos Camperos", + "fecha_estimada": "23/04/2025", + "dias_hasta": -366, + "frecuencia_dias": 7.5 + }, + { + "producto": "Cebolla Dulce", + "fecha_estimada": "24/04/2025", + "dias_hasta": -365, + "frecuencia_dias": 8.0 + }, + { + "producto": "Burger Espinacas", + "fecha_estimada": "24/04/2025", + "dias_hasta": -365, + "frecuencia_dias": 8.0 + }, + { + "producto": "Uva Roja S/S", + "fecha_estimada": "24/04/2025", + "dias_hasta": -365, + "frecuencia_dias": 8.0 + }, + { + "producto": "Pan M. 55% Centeno", + "fecha_estimada": "24/04/2025", + "dias_hasta": -365, + "frecuencia_dias": 8.7 + }, + { + "producto": "Griego Limón", + "fecha_estimada": "24/04/2025", + "dias_hasta": -365, + "frecuencia_dias": 8.8 + }, + { + "producto": "Queso Cheddar Loncha", + "fecha_estimada": "24/04/2025", + "dias_hasta": -365, + "frecuencia_dias": 8.9 + }, + { + "producto": "Pan M 35% Avena", + "fecha_estimada": "24/04/2025", + "dias_hasta": -365, + "frecuencia_dias": 8.9 + }, + { + "producto": "Cebolla Tubo", + "fecha_estimada": "25/04/2025", + "dias_hasta": -364, + "frecuencia_dias": 9.0 + }, + { + "producto": "Zumo Fresco 1/2 L", + "fecha_estimada": "25/04/2025", + "dias_hasta": -364, + "frecuencia_dias": 9.7 + }, + { + "producto": "Q Rallado Fundir", + "fecha_estimada": "26/04/2025", + "dias_hasta": -363, + "frecuencia_dias": 10.0 + }, + { + "producto": "Hummus Clasico", + "fecha_estimada": "26/04/2025", + "dias_hasta": -363, + "frecuencia_dias": 10.2 + }, + { + "producto": "Rebuenas", + "fecha_estimada": "26/04/2025", + "dias_hasta": -363, + "frecuencia_dias": 10.4 + }, + { + "producto": "Mantequilla Past.Con", + "fecha_estimada": "26/04/2025", + "dias_hasta": -363, + "frecuencia_dias": 10.5 + }, + { + "producto": "Café Cleche D.Gusto", + "fecha_estimada": "26/04/2025", + "dias_hasta": -363, + "frecuencia_dias": 10.9 + }, + { + "producto": "Yogur Sabor Coco", + "fecha_estimada": "26/04/2025", + "dias_hasta": -363, + "frecuencia_dias": 10.9 + }, + { + "producto": "Tortilla Pat C/Ceb", + "fecha_estimada": "27/04/2025", + "dias_hasta": -362, + "frecuencia_dias": 11.2 + }, + { + "producto": "Patata Roja", + "fecha_estimada": "27/04/2025", + "dias_hasta": -362, + "frecuencia_dias": 11.9 + }, + { + "producto": "Zanahoria Bolsa", + "fecha_estimada": "28/04/2025", + "dias_hasta": -361, + "frecuencia_dias": 12.0 + }, + { + "producto": "Queso Vaca Havarti", + "fecha_estimada": "28/04/2025", + "dias_hasta": -361, + "frecuencia_dias": 12.2 + }, + { + "producto": "Croissant Rell Cacao", + "fecha_estimada": "28/04/2025", + "dias_hasta": -361, + "frecuencia_dias": 12.4 + }, + { + "producto": "Lenteja", + "fecha_estimada": "29/04/2025", + "dias_hasta": -360, + "frecuencia_dias": 13.0 + }, + { + "producto": "Choc 85% Cacao", + "fecha_estimada": "29/04/2025", + "dias_hasta": -360, + "frecuencia_dias": 13.5 + }, + { + "producto": "Limpiador Dentadura", + "fecha_estimada": "29/04/2025", + "dias_hasta": -360, + "frecuencia_dias": 13.5 + }, + { + "producto": "Seta Laminada", + "fecha_estimada": "29/04/2025", + "dias_hasta": -360, + "frecuencia_dias": 13.8 + }, + { + "producto": "Tom. Receta Artesana", + "fecha_estimada": "29/04/2025", + "dias_hasta": -360, + "frecuencia_dias": 13.9 + }, + { + "producto": "Queso Cottage", + "fecha_estimada": "30/04/2025", + "dias_hasta": -359, + "frecuencia_dias": 14.0 + }, + { + "producto": "Judia Redonda 500 G", + "fecha_estimada": "30/04/2025", + "dias_hasta": -359, + "frecuencia_dias": 14.0 + }, + { + "producto": "Tiburon", + "fecha_estimada": "30/04/2025", + "dias_hasta": -359, + "frecuencia_dias": 14.0 + }, + { + "producto": "Digestive Avena Choc", + "fecha_estimada": "30/04/2025", + "dias_hasta": -359, + "frecuencia_dias": 14.0 + }, + { + "producto": "Papel Higienico 4 Ca", + "fecha_estimada": "30/04/2025", + "dias_hasta": -359, + "frecuencia_dias": 14.5 + }, + { + "producto": "Leche Semi P6", + "fecha_estimada": "30/04/2025", + "dias_hasta": -359, + "frecuencia_dias": 14.9 + }, + { + "producto": "Caldo Verduras 12 P.", + "fecha_estimada": "01/05/2025", + "dias_hasta": -358, + "frecuencia_dias": 15.0 + }, + { + "producto": "Choco Gotas Fundir", + "fecha_estimada": "01/05/2025", + "dias_hasta": -358, + "frecuencia_dias": 15.0 + }, + { + "producto": "16 Huevos Camperos", + "fecha_estimada": "01/05/2025", + "dias_hasta": -358, + "frecuencia_dias": 15.0 + }, + { + "producto": "Gall Digestive", + "fecha_estimada": "01/05/2025", + "dias_hasta": -358, + "frecuencia_dias": 15.5 + }, + { + "producto": "Nuez Natural", + "fecha_estimada": "01/05/2025", + "dias_hasta": -358, + "frecuencia_dias": 15.7 + }, + { + "producto": "Judía Plana 750 Gr", + "fecha_estimada": "02/05/2025", + "dias_hasta": -357, + "frecuencia_dias": 16.0 + }, + { + "producto": "Calabaza Trozos", + "fecha_estimada": "02/05/2025", + "dias_hasta": -357, + "frecuencia_dias": 16.3 + }, + { + "producto": "Cafe Molido Natural", + "fecha_estimada": "02/05/2025", + "dias_hasta": -357, + "frecuencia_dias": 16.6 + }, + { + "producto": "Cuidacol Natural", + "fecha_estimada": "03/05/2025", + "dias_hasta": -356, + "frecuencia_dias": 17.0 + }, + { + "producto": "Solomillo Añojo", + "fecha_estimada": "03/05/2025", + "dias_hasta": -356, + "frecuencia_dias": 17.5 + }, + { + "producto": "Esp Verde Fino", + "fecha_estimada": "04/05/2025", + "dias_hasta": -355, + "frecuencia_dias": 18.0 + }, + { + "producto": "Zanahoria 500 G", + "fecha_estimada": "04/05/2025", + "dias_hasta": -355, + "frecuencia_dias": 18.2 + }, + { + "producto": "Tofu", + "fecha_estimada": "04/05/2025", + "dias_hasta": -355, + "frecuencia_dias": 18.3 + }, + { + "producto": "Mandarina 2 Kg", + "fecha_estimada": "05/05/2025", + "dias_hasta": -354, + "frecuencia_dias": 19.4 + }, + { + "producto": "Crema Tex-Mex", + "fecha_estimada": "06/05/2025", + "dias_hasta": -353, + "frecuencia_dias": 20.8 + }, + { + "producto": "Pan De Pueblo", + "fecha_estimada": "07/05/2025", + "dias_hasta": -352, + "frecuencia_dias": 21.0 + }, + { + "producto": "Mozzarella Fresca", + "fecha_estimada": "07/05/2025", + "dias_hasta": -352, + "frecuencia_dias": 21.0 + }, + { + "producto": "Burger Berenjena", + "fecha_estimada": "07/05/2025", + "dias_hasta": -352, + "frecuencia_dias": 21.0 + }, + { + "producto": "Pan 12 Cereal/Semill", + "fecha_estimada": "07/05/2025", + "dias_hasta": -352, + "frecuencia_dias": 21.0 + }, + { + "producto": "Plátano Manzana 120G", + "fecha_estimada": "08/05/2025", + "dias_hasta": -351, + "frecuencia_dias": 22.0 + }, + { + "producto": "Fresa Arándanos Aven", + "fecha_estimada": "08/05/2025", + "dias_hasta": -351, + "frecuencia_dias": 22.0 + }, + { + "producto": "Fresa Platano 120G", + "fecha_estimada": "08/05/2025", + "dias_hasta": -351, + "frecuencia_dias": 22.0 + }, + { + "producto": "Mezcla 4 Quesos", + "fecha_estimada": "08/05/2025", + "dias_hasta": -351, + "frecuencia_dias": 22.7 + }, + { + "producto": "Pistacho Tost 0% Sal", + "fecha_estimada": "09/05/2025", + "dias_hasta": -350, + "frecuencia_dias": 23.3 + }, + { + "producto": "Gall Digestive Avena", + "fecha_estimada": "10/05/2025", + "dias_hasta": -349, + "frecuencia_dias": 24.0 + }, + { + "producto": "Pan Campeon Mundo", + "fecha_estimada": "10/05/2025", + "dias_hasta": -349, + "frecuencia_dias": 24.2 + }, + { + "producto": "Q Mitad Semi", + "fecha_estimada": "10/05/2025", + "dias_hasta": -349, + "frecuencia_dias": 24.5 + }, + { + "producto": "Margarina Con Sal", + "fecha_estimada": "10/05/2025", + "dias_hasta": -349, + "frecuencia_dias": 24.5 + }, + { + "producto": "Pan Moño", + "fecha_estimada": "11/05/2025", + "dias_hasta": -348, + "frecuencia_dias": 25.0 + }, + { + "producto": "Ajo Seco 250 G", + "fecha_estimada": "11/05/2025", + "dias_hasta": -348, + "frecuencia_dias": 25.7 + }, + { + "producto": "Soja Con Chocolate", + "fecha_estimada": "13/05/2025", + "dias_hasta": -346, + "frecuencia_dias": 27.0 + }, + { + "producto": "Gall Digestive Choco", + "fecha_estimada": "13/05/2025", + "dias_hasta": -346, + "frecuencia_dias": 27.5 + }, + { + "producto": "Dátil Sin Hueso", + "fecha_estimada": "13/05/2025", + "dias_hasta": -346, + "frecuencia_dias": 27.5 + }, + { + "producto": "Leche Semi Calcio", + "fecha_estimada": "14/05/2025", + "dias_hasta": -345, + "frecuencia_dias": 28.0 + }, + { + "producto": "Uva Blanca S/Sem", + "fecha_estimada": "14/05/2025", + "dias_hasta": -345, + "frecuencia_dias": 28.0 + }, + { + "producto": "+ Proteínas Flan", + "fecha_estimada": "14/05/2025", + "dias_hasta": -345, + "frecuencia_dias": 28.0 + }, + { + "producto": "Cebolla Roja", + "fecha_estimada": "14/05/2025", + "dias_hasta": -345, + "frecuencia_dias": 28.0 + }, + { + "producto": "Atun Claro Natural", + "fecha_estimada": "14/05/2025", + "dias_hasta": -345, + "frecuencia_dias": 28.0 + }, + { + "producto": "Mermelada Fresa", + "fecha_estimada": "14/05/2025", + "dias_hasta": -345, + "frecuencia_dias": 28.0 + }, + { + "producto": "Queso Emmental Taco", + "fecha_estimada": "14/05/2025", + "dias_hasta": -345, + "frecuencia_dias": 28.7 + }, + { + "producto": "Levadura Panaderia", + "fecha_estimada": "15/05/2025", + "dias_hasta": -344, + "frecuencia_dias": 29.0 + }, + { + "producto": "Seta Bandeja", + "fecha_estimada": "15/05/2025", + "dias_hasta": -344, + "frecuencia_dias": 29.0 + }, + { + "producto": "Arroz Sos", + "fecha_estimada": "16/05/2025", + "dias_hasta": -343, + "frecuencia_dias": 30.3 + }, + { + "producto": "Hogaza Centeno 50%", + "fecha_estimada": "17/05/2025", + "dias_hasta": -342, + "frecuencia_dias": 31.0 + }, + { + "producto": "Nata Para Cocinar", + "fecha_estimada": "19/05/2025", + "dias_hasta": -340, + "frecuencia_dias": 33.0 + }, + { + "producto": "Crema 100% Cacahuete", + "fecha_estimada": "19/05/2025", + "dias_hasta": -340, + "frecuencia_dias": 33.0 + }, + { + "producto": "Salsa De Soja Salada", + "fecha_estimada": "20/05/2025", + "dias_hasta": -339, + "frecuencia_dias": 34.0 + }, + { + "producto": "Tortillas Mexicanas", + "fecha_estimada": "20/05/2025", + "dias_hasta": -339, + "frecuencia_dias": 34.0 + }, + { + "producto": "P. Pav Red. Sal Bipa", + "fecha_estimada": "21/05/2025", + "dias_hasta": -338, + "frecuencia_dias": 35.0 + }, + { + "producto": "Fresh 0%Alcohol Enj.", + "fecha_estimada": "21/05/2025", + "dias_hasta": -338, + "frecuencia_dias": 35.0 + }, + { + "producto": "Pañuelo Locion", + "fecha_estimada": "21/05/2025", + "dias_hasta": -338, + "frecuencia_dias": 35.0 + }, + { + "producto": "Cebolla 2 Kg", + "fecha_estimada": "21/05/2025", + "dias_hasta": -338, + "frecuencia_dias": 35.0 + }, + { + "producto": "Ravioli Req.Espinaca", + "fecha_estimada": "21/05/2025", + "dias_hasta": -338, + "frecuencia_dias": 35.0 + }, + { + "producto": "Macarron", + "fecha_estimada": "24/05/2025", + "dias_hasta": -335, + "frecuencia_dias": 38.5 + }, + { + "producto": "Caramelo Eucaliptus", + "fecha_estimada": "24/05/2025", + "dias_hasta": -335, + "frecuencia_dias": 38.5 + }, + { + "producto": "Croissant", + "fecha_estimada": "27/05/2025", + "dias_hasta": -332, + "frecuencia_dias": 41.0 + }, + { + "producto": "Miel De Flores Kg", + "fecha_estimada": "28/05/2025", + "dias_hasta": -331, + "frecuencia_dias": 42.0 + }, + { + "producto": "Azúcar Moreno 1Kg", + "fecha_estimada": "28/05/2025", + "dias_hasta": -331, + "frecuencia_dias": 42.0 + }, + { + "producto": "Medialunas Calabaza", + "fecha_estimada": "28/05/2025", + "dias_hasta": -331, + "frecuencia_dias": 42.0 + }, + { + "producto": "Batido Cacao Energy", + "fecha_estimada": "28/05/2025", + "dias_hasta": -331, + "frecuencia_dias": 42.0 + }, + { + "producto": "Azucar", + "fecha_estimada": "28/05/2025", + "dias_hasta": -331, + "frecuencia_dias": 42.0 + }, + { + "producto": "Cacao Puro 0%", + "fecha_estimada": "10/06/2025", + "dias_hasta": -318, + "frecuencia_dias": 55.0 + }, + { + "producto": "Espinaca Picada", + "fecha_estimada": "11/06/2025", + "dias_hasta": -317, + "frecuencia_dias": 56.0 + }, + { + "producto": "Pan Sem Y P Calabaza", + "fecha_estimada": "11/06/2025", + "dias_hasta": -317, + "frecuencia_dias": 56.0 + }, + { + "producto": "11 Corazon Romana", + "fecha_estimada": "17/06/2025", + "dias_hasta": -311, + "frecuencia_dias": 62.0 + }, + { + "producto": "Caramelos Lima 0%", + "fecha_estimada": "17/06/2025", + "dias_hasta": -311, + "frecuencia_dias": 62.0 + }, + { + "producto": "Pan Tostado Clasico", + "fecha_estimada": "17/06/2025", + "dias_hasta": -311, + "frecuencia_dias": 62.0 + }, + { + "producto": "Galleta Canela", + "fecha_estimada": "24/06/2025", + "dias_hasta": -304, + "frecuencia_dias": 69.0 + }, + { + "producto": "Spaghetti", + "fecha_estimada": "25/06/2025", + "dias_hasta": -303, + "frecuencia_dias": 70.0 + }, + { + "producto": "Salsa Fresca Quesos", + "fecha_estimada": "25/06/2025", + "dias_hasta": -303, + "frecuencia_dias": 70.0 + }, + { + "producto": "Vela Te Chai", + "fecha_estimada": "01/07/2025", + "dias_hasta": -297, + "frecuencia_dias": 76.0 + }, + { + "producto": "Champu Extra Suave", + "fecha_estimada": "01/07/2025", + "dias_hasta": -297, + "frecuencia_dias": 76.0 + }, + { + "producto": "Grana Padano Escamas", + "fecha_estimada": "08/07/2025", + "dias_hasta": -290, + "frecuencia_dias": 83.0 + }, + { + "producto": "Coca-Cola 12 Latas", + "fecha_estimada": "09/07/2025", + "dias_hasta": -289, + "frecuencia_dias": 84.0 + }, + { + "producto": "Vaselina Aroma Framb", + "fecha_estimada": "09/07/2025", + "dias_hasta": -289, + "frecuencia_dias": 84.0 + }, + { + "producto": "Nidos Al Huevo", + "fecha_estimada": "22/07/2025", + "dias_hasta": -276, + "frecuencia_dias": 97.0 + } +]; diff --git a/escanear_productos.html b/escanear_productos.html new file mode 100644 index 0000000..418787f --- /dev/null +++ b/escanear_productos.html @@ -0,0 +1,90 @@ + + + + + + Escáner de Productos + + + + +

Escanear Producto

+ + + + +
+ + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/generar_lista.py b/generar_lista.py new file mode 100644 index 0000000..f68c4c6 --- /dev/null +++ b/generar_lista.py @@ -0,0 +1,110 @@ +""" +generar_lista.py +Genera datos.js con predicciones de compra limpias a partir de lista_compra_estimado.csv +Uso: python generar_lista.py +""" +import pandas as pd +import json +import re +from datetime import datetime + +# --------------------------------------------------------------------------- +# Filtros de basura (líneas del ticket que no son productos) +# --------------------------------------------------------------------------- +BAD_PATTERNS = [ + r'^\s*%', + r'tarjeta', + r'^total(\s|$|\s*\()', + r'€/kg', + r'\d+,\d+\s*kg', + r'^n\.c', + r'^aut[\s\d]', + r'verificado', + r'visa', + r'bancaria', + r'devoluciones', + r'mastercard', + r'importe', + r'^iva\s', + r'^cuota\s', + r'^\d+,\d+\s*€', # líneas de precio suelto + r'^\d{1,3}\s*€', # líneas tipo "15 €" +] + +def es_basura(nombre): + n = nombre.lower().strip() + if not n or len(n) < 3: + return True + return any(re.search(p, n) for p in BAD_PATTERNS) + +def limpiar_nombre(nombre): + nombre = nombre.strip() + # Quitar el "1" pegado al inicio del nombre que viene de la cantidad en el ticket + # "1Yogur Limon" → "Yogur Limon" | "1+Prot Pud" → "+Prot Pud" + # Pero "16 Huevos Camperos" → "16 Huevos Camperos" (es un pack de 16 huevos) + nombre = re.sub(r'^1([A-ZÁÉÍÓÚÑ+a-záéíóúñ])', r'\1', nombre) + return nombre.strip() + +# --------------------------------------------------------------------------- +# Cargar datos +# --------------------------------------------------------------------------- +try: + # Intentar UTF-8 primero, si falla usar cp1252 (Windows) + try: + df = pd.read_csv('lista_compra_estimado.csv', encoding='utf-8') + except UnicodeDecodeError: + df = pd.read_csv('lista_compra_estimado.csv', encoding='cp1252') +except FileNotFoundError: + print("❌ No se encontró lista_compra_estimado.csv") + print(" Ejecuta primero: python autocompra5.py (o autocompra7.py)") + exit(1) + +print(f" Productos en CSV: {len(df)}") + +# Filtrar basura +df = df[~df['producto'].apply(es_basura)].copy() +print(f" Después de filtrar basura: {len(df)}") + +# Limpiar nombres +df['nombre'] = df['producto'].apply(limpiar_nombre) + +# Parsear fechas estimadas +df['fecha'] = pd.to_datetime(df['fecha_estimada_proxima_compra'], format='%d/%m/%Y', errors='coerce') +df = df.dropna(subset=['fecha', 'diferencia_dias']) +# Excluir productos sin frecuencia real (frecuencia = 0 significa comprado una sola vez) +df = df[df['diferencia_dias'] > 0] + +# Eliminar duplicados (mismo nombre → quedarse con el de menor frecuencia, más fiable) +df = df.sort_values('diferencia_dias') +df = df.drop_duplicates(subset='nombre', keep='first') + +# Calcular días hasta la próxima compra desde HOY +hoy = pd.Timestamp(datetime.now().date()) +df['dias_hasta'] = (df['fecha'] - hoy).dt.days + +# Ordenar: primero los más frecuentes (más urgentes a recoger), luego por fecha estimada +df = df.sort_values(['diferencia_dias', 'dias_hasta']) + +# --------------------------------------------------------------------------- +# Construir JSON de salida +# --------------------------------------------------------------------------- +resultado = [] +for _, row in df.iterrows(): + resultado.append({ + 'producto': row['nombre'], + 'fecha_estimada': row['fecha_estimada_proxima_compra'], + 'dias_hasta': int(row['dias_hasta']), + 'frecuencia_dias': round(float(row['diferencia_dias']), 1), + }) + +# Escribir datos.js (cargable como + - + \ No newline at end of file diff --git a/servidor/guardar_producto.php b/servidor/guardar_producto.php new file mode 100644 index 0000000..3dcaabe --- /dev/null +++ b/servidor/guardar_producto.php @@ -0,0 +1,40 @@ + 'Datos incompletos']); + exit; +} + +try { + $pdo = new PDO("mysql:host=$host;dbname=$db;charset=utf8mb4", $user, $pass); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // Insertar el producto + $stmt = $pdo->prepare("INSERT INTO productos (nombre, marca_id, ean, precio, cantidad) VALUES (:nombre, :marca_id, :ean, :precio, :cantidad)"); + $stmt->execute([ + ':nombre' => $data['nombre'], + ':marca_id' => !empty($data['marca_id']) ? $data['marca_id'] : null, + ':ean' => $data['ean'], + ':precio' => !empty($data['precio']) ? $data['precio'] : null, + ':cantidad' => !empty($data['cantidad']) ? $data['cantidad'] : null + ]); + + echo json_encode(['success' => true, 'id' => $pdo->lastInsertId()]); +} catch (PDOException $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} +?> diff --git a/tickets/20250424 Mercadona 166,83 €.pdf b/tickets/20250424 Mercadona 166,83 €.pdf new file mode 100644 index 0000000..61295c4 Binary files /dev/null and b/tickets/20250424 Mercadona 166,83 €.pdf differ diff --git a/tickets/20250430 Mercadona 148,40 €.pdf b/tickets/20250430 Mercadona 148,40 €.pdf new file mode 100644 index 0000000..475ec03 Binary files /dev/null and b/tickets/20250430 Mercadona 148,40 €.pdf differ