Automatizacion de tareas

This commit is contained in:
Tatiana Villa Ema 2026-04-24 23:32:18 +02:00
parent 249de94ecf
commit 6930c63ca5
10 changed files with 1838 additions and 95 deletions

202
app.py Normal file
View File

@ -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/<path:filename>")
@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)

269
css/style.css Normal file
View File

@ -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; }

724
datos.js Normal file
View File

@ -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
}
];

90
escanear_productos.html Normal file
View File

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Escáner de Productos</title>
<script src="https://unpkg.com/@zxing/browser@latest"></script>
<style>
body { font-family: sans-serif; padding: 20px; }
input, button, select { display: block; margin: 10px 0; padding: 8px; width: 100%; }
video { width: 100%; border: 1px solid #ccc; border-radius: 8px; margin-bottom: 15px; }
</style>
</head>
<body>
<h1>Escanear Producto</h1>
<video id="video"></video>
<button onclick="startScanner()">Iniciar escáner</button>
<form id="productoForm" onsubmit="guardarProducto(event)">
<label for="codigo">Código de barras</label>
<input type="text" id="codigo" name="codigo" readonly required>
<label for="nombre">Nombre del producto</label>
<input type="text" id="nombre" name="nombre" required>
<label for="categoria">Categoría</label>
<input type="text" id="categoria" name="categoria">
<label for="precio">Precio</label>
<input type="number" step="0.01" id="precio" name="precio">
<label for="supermercado">Supermercado habitual</label>
<input type="text" id="supermercado" name="supermercado">
<button type="submit">Guardar</button>
</form>
<script>
let selectedDeviceId;
const codeReader = new ZXing.BrowserBarcodeReader();
async function startScanner() {
try {
const devices = await codeReader.getVideoInputDevices();
selectedDeviceId = devices[0].deviceId;
codeReader.decodeFromVideoDevice(selectedDeviceId, 'video', (result, err) => {
if (result) {
document.getElementById('codigo').value = result.text;
codeReader.reset(); // Detener escaneo tras encontrar uno
}
});
} catch (e) {
console.error(e);
}
}
function guardarProducto(e) {
e.preventDefault();
const datos = {
codigo: document.getElementById('codigo').value,
nombre: document.getElementById('nombre').value,
categoria: document.getElementById('categoria').value,
precio: document.getElementById('precio').value,
supermercado: document.getElementById('supermercado').value
};
console.log('Producto guardado:', datos);
alert('Producto guardado (simulado). Puedes enviar estos datos por POST al servidor.');
// Aquí puedes hacer el fetch o POST a tu servidor:
fetch('https://tecnologia-facil.es/apis/guardar_producto.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos)
})
.then(res => res.json())
.then(data => {
if (data.success) {
alert('✅ Producto guardado con ID: ' + data.id);
form.reset();
} else {
alert('❌ Error al guardar: ' + data.error);
}
});
document.getElementById('productoForm').reset();
}
</script>
</body>
</html>

110
generar_lista.py Normal file
View File

@ -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 <script src> sin servidor HTTP)
ts = datetime.now().strftime('%d/%m/%Y %H:%M')
with open('datos.js', 'w', encoding='utf-8') as f:
f.write('// Generado el ' + ts + ' - ' + str(len(resultado)) + ' productos\n')
f.write('const GENERADO = "' + ts + '";\n')
f.write('const predicciones = ')
json.dump(resultado, f, ensure_ascii=False, indent=2)
f.write(';\n')
print(f"{len(resultado)} predicciones escritas en datos.js")
print(f" Abre index.html en el navegador para ver la lista.")

196
importar_tickets_email.py Normal file
View File

@ -0,0 +1,196 @@
"""
importar_tickets_email.py
-------------------------
Descarga los tickets PDF de Mercadona desde el correo (IMAP)
y los guarda en la carpeta tickets/ para ser procesados.
Uso:
python importar_tickets_email.py
Configuracion en config.ini (se crea automaticamente la primera vez).
Para Gmail necesitas una "contrasena de aplicacion":
Cuenta Google -> Seguridad -> Verificacion en dos pasos ->
Contrasenas de aplicacion -> Otra -> copiar los 16 caracteres.
"""
import imaplib
import email
import os
import configparser
import sys
import subprocess
from pathlib import Path
from datetime import datetime
from email.header import decode_header
# -----------------------------------------------------------------------
# Rutas
# -----------------------------------------------------------------------
BASE_DIR = Path(__file__).parent
TICKETS_DIR = BASE_DIR / "tickets"
CONFIG_FILE = BASE_DIR / "config.ini"
TICKETS_DIR.mkdir(exist_ok=True)
# -----------------------------------------------------------------------
# Configuracion
# -----------------------------------------------------------------------
DEFAULTS = {
"imap_host": "imap.gmail.com",
"imap_port": "993",
"correo": "",
"password": "",
"remitente": "noreply@mercadona.es",
"solo_nuevos": "true",
"ejecutar_pipeline": "false",
}
def leer_config():
cfg = configparser.ConfigParser()
if CONFIG_FILE.exists():
cfg.read(CONFIG_FILE, encoding="utf-8")
if "email" not in cfg:
cfg["email"] = {}
for k, v in DEFAULTS.items():
cfg["email"].setdefault(k, v)
return cfg
def guardar_config(cfg):
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
cfg.write(f)
def pedir_config():
"""Solicita los datos de acceso si no estan configurados."""
cfg = leer_config()
sec = cfg["email"]
if not sec["correo"] or not sec["password"]:
print("=== Configuracion inicial ===")
print("Necesito los datos de acceso al correo.")
print("Para Gmail usa una contrasena de aplicacion (no tu contrasena normal).")
print("https://myaccount.google.com/apppasswords\n")
correo = input("Correo electronico: ").strip()
pwd = input("Contrasena (o contrasena de aplicacion): ").strip()
host = input(f"Servidor IMAP [{sec['imap_host']}]: ").strip() or sec["imap_host"]
sec["correo"] = correo
sec["password"] = pwd
sec["imap_host"] = host
guardar_config(cfg)
print(f"\nConfiguracion guardada en {CONFIG_FILE}\n")
return cfg
# -----------------------------------------------------------------------
# Decodificacion de nombres de archivo
# -----------------------------------------------------------------------
def decodificar_nombre(nombre_raw):
if not nombre_raw:
return None
partes = decode_header(nombre_raw)
nombre = ""
for chunk, enc in partes:
if isinstance(chunk, bytes):
nombre += chunk.decode(enc or "utf-8", errors="replace")
else:
nombre += chunk
return nombre.strip()
# -----------------------------------------------------------------------
# Descarga de tickets
# -----------------------------------------------------------------------
def descargar_tickets():
cfg = pedir_config()
sec = cfg["email"]
host = sec["imap_host"]
port = int(sec["imap_port"])
correo = sec["correo"]
password = sec["password"]
remitente = sec["remitente"]
solo_nuevos = sec["solo_nuevos"].lower() == "true"
ejecutar_pipeline = sec["ejecutar_pipeline"].lower() == "true"
print(f"Conectando a {host}:{port} como {correo}...")
try:
conn = imaplib.IMAP4_SSL(host, port)
conn.login(correo, password)
except imaplib.IMAP4.error as e:
print(f"Error de autenticacion: {e}")
print("Comprueba el correo y la contrasena en config.ini")
sys.exit(1)
conn.select("INBOX")
criterio = f'(FROM "{remitente}")'
if solo_nuevos:
criterio = f'(UNSEEN FROM "{remitente}")'
_, ids = conn.search(None, criterio)
ids = ids[0].split()
if not ids:
print("No hay tickets nuevos de Mercadona.")
conn.logout()
return 0
print(f"Encontrados {len(ids)} correo(s) de Mercadona.")
descargados = 0
for uid in ids:
_, data = conn.fetch(uid, "(RFC822)")
msg = email.message_from_bytes(data[0][1])
# Fecha del correo para nombre de archivo
fecha_str = msg.get("Date", "")
try:
fecha_dt = email.utils.parsedate_to_datetime(fecha_str)
fecha_fmt = fecha_dt.strftime("%Y%m%d")
except Exception:
fecha_fmt = datetime.now().strftime("%Y%m%d")
adjuntos_guardados = 0
for part in msg.walk():
if part.get_content_type() == "application/pdf":
nombre_orig = decodificar_nombre(part.get_filename())
if not nombre_orig:
nombre_orig = f"ticket_{fecha_fmt}_{uid.decode()}.pdf"
# Evitar colisiones
destino = TICKETS_DIR / nombre_orig
if destino.exists():
destino = TICKETS_DIR / f"{fecha_fmt}_{nombre_orig}"
destino.write_bytes(part.get_payload(decode=True))
print(f" Guardado: {destino.name}")
adjuntos_guardados += 1
descargados += 1
if adjuntos_guardados == 0:
print(f" Correo {uid.decode()}: sin PDF adjunto, ignorado.")
# Marcar como leido
conn.store(uid, "+FLAGS", "\\Seen")
conn.logout()
print(f"\nTotal descargados: {descargados} archivo(s) en {TICKETS_DIR}")
# Ejecutar pipeline si esta configurado
if ejecutar_pipeline and descargados > 0:
print("\nEjecutando pipeline de procesado...")
subprocess.run([sys.executable, str(BASE_DIR / "autocompra7.py")], check=False)
subprocess.run([sys.executable, str(BASE_DIR / "generar_lista.py")], check=False)
print("Pipeline completado.")
return descargados
# -----------------------------------------------------------------------
# Entry point
# -----------------------------------------------------------------------
if __name__ == "__main__":
descargados = descargar_tickets()
if descargados == 0:
sys.exit(0)

View File

@ -1,128 +1,240 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es"> <html lang="es">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Mi Lista de la Compra</title> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> <title>Lista de la Compra Inteligente</title>
body { font-family: sans-serif; max-width: 800px; margin: auto; padding: 1em; } <link rel="stylesheet" href="css/style.css">
h1, h2 { color: #2c3e50; }
textarea { width: 100%; height: 150px; margin-top: 10px; }
input[type="text"], input[type="number"], input[type="file"] { width: 100%; padding: 5px; margin: 5px 0; }
button { padding: 10px; margin-top: 10px; cursor: pointer; }
ul { list-style: none; padding: 0; }
li { margin-bottom: 5px; }
.precio { color: #888; font-size: 0.9em; }
</style>
</head> </head>
<body> <body>
<h1>Mi Lista de la Compra</h1>
<h2>Subir ticket PDF</h2> <h1>Lista de la Compra Inteligente</h1>
<input type="file" id="ticketPDF" accept="application/pdf"> <p class="subtitle" id="subtitulo">Basada en el historial de tickets de Mercadona</p>
<button onclick="leerPDF()">Procesar ticket</button>
<pre id="pdfTexto" style="white-space: pre-wrap; background: #f4f4f4; padding: 10px; display:none;"></pre>
<h2>Productos frecuentes</h2> <div class="toolbar">
<ul id="listaProductos"> <button class="btn btn-primary btn-sm" onclick="marcarSemanal()">Marcar sugeridos esta semana</button>
<li><label><input type="checkbox" value="Leche semi 6x2"> Leche semi 6x2 <span class="precio">(1.76€/L)</span></label></li> <button class="btn btn-secondary btn-sm" onclick="desmarcarTodo()">Desmarcar todo</button>
<li><label><input type="checkbox" value="Huevos camperos 12"> Huevos camperos 12 <span class="precio">(3.22€)</span></label></li> </div>
<li><label><input type="checkbox" value="Yogur griego limón"> Yogur griego limón <span class="precio">(1.70€)</span></label></li>
<li><label><input type="checkbox" value="Plátano"> Plátano <span class="precio">(3.20€/kg)</span></label></li>
<li><label><input type="checkbox" value="Tomate receta artesana"> Tomate receta artesana <span class="precio">(2.10€)</span></label></li>
<li><label><input type="checkbox" value="Tortilla patata cebolla"> Tortilla patata cebolla <span class="precio">(2.60€)</span></label></li>
<li><label><input type="checkbox" value="Calabacín verde"> Calabacín verde <span class="precio">(1.30€/kg)</span></label></li>
</ul>
<h2>Añadir producto manualmente</h2> <div class="layout">
<input type="text" id="nuevoProducto" placeholder="Nombre del producto">
<input type="number" id="precioProducto" placeholder="Precio (€)">
<button onclick="agregarProducto()">Añadir</button>
<h2>Lista generada</h2> <!-- Columna izquierda: predicciones por frecuencia -->
<textarea id="listaGenerada" readonly></textarea> <div id="columnaProductos">
<button onclick="generarLista()">Generar Lista</button> <div class="no-datos" id="sinDatos">
<button onclick="copiarLista()">Copiar al portapapeles</button> <p>No hay datos cargados todavia.</p>
<p>Ejecuta este comando para generar el archivo de predicciones:</p>
<code>python generar_lista.py</code>
<p>Despues recarga esta pagina.</p>
</div>
</div>
<h2>Predicción de productos</h2> <!-- Panel lateral -->
<button onclick="predecirProductos()">Sugerir productos que podrías necesitar</button> <div class="panel">
<ul id="predicciones"></ul>
<h2>Lista de la compra <span class="count-badge" id="contadorSeleccionados">0</span></h2>
<textarea id="listaGenerada" placeholder="Marca productos a la izquierda..."></textarea>
<div style="margin-top:.75rem; display:flex; gap:.5rem;">
<button class="btn btn-primary" onclick="copiarLista()">Copiar</button>
<button class="btn btn-secondary" onclick="limpiarLista()">Limpiar</button>
</div>
<h2>Anadir manualmente</h2>
<div class="add-row">
<input type="text" id="nuevoProducto" placeholder="Producto..."
onkeydown="if(event.key==='Enter') agregarManual()">
<button class="btn btn-secondary btn-sm" onclick="agregarManual()">Anadir</button>
</div>
<h2>Subir ticket PDF</h2>
<div class="drop-zone" id="dropZone"
ondragover="dzOver(event)" ondragleave="dzLeave()" ondrop="dzDrop(event)">
<input type="file" id="ticketPDF" accept="application/pdf"
onchange="leerPDF(this.files[0])">
<span class="drop-zone-icon">📄</span>
Arrastra el PDF aqui o haz clic para seleccionarlo
</div>
<div id="pdfEstado" style="font-size:.8rem; color:#8b949e; margin-top:.4rem;"></div>
<pre id="pdfTexto"></pre>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.10.377/pdf.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.10.377/pdf.min.js"></script>
<script src="datos.js"></script>
<script> <script>
const historial = { // -----------------------------------------------------------------------
"Leche semi 6x2": ["2025-04-03", "2025-04-10", "2025-04-16"], // Estado
"Huevos camperos 12": ["2025-04-03", "2025-04-10"], // -----------------------------------------------------------------------
"Yogur griego limón": ["2025-04-03", "2025-04-10"], const seleccionados = new Set();
"Plátano": ["2025-04-03", "2025-04-10", "2025-04-16"], let productosManuales = [];
"Tomate receta artesana": ["2025-04-03", "2025-04-10", "2025-04-16"],
"Tortilla patata cebolla": ["2025-04-03", "2025-04-10", "2025-04-16"],
"Calabacín verde": ["2025-04-03", "2025-04-10", "2025-04-16"]
};
function agregarProducto() { // -----------------------------------------------------------------------
const nombre = document.getElementById('nuevoProducto').value.trim(); // Inicializacion
const precio = document.getElementById('precioProducto').value.trim(); // -----------------------------------------------------------------------
if (!nombre) return; window.addEventListener('load', () => {
const li = document.createElement('li'); if (typeof predicciones === 'undefined' || !predicciones.length) {
li.innerHTML = `<label><input type="checkbox" value="${nombre}"> ${nombre} <span class="precio">(${precio ? precio + '€' : ''})</span></label>`; document.getElementById('sinDatos').style.display = 'block';
document.getElementById('listaProductos').appendChild(li); return;
document.getElementById('nuevoProducto').value = ''; }
document.getElementById('precioProducto').value = ''; document.getElementById('sinDatos').style.display = 'none';
} if (typeof GENERADO !== 'undefined') {
document.getElementById('subtitulo').textContent =
'Predicciones generadas el ' + GENERADO + ' - ' + predicciones.length + ' productos';
}
renderizarPredicciones();
});
function generarLista() { // -----------------------------------------------------------------------
const checks = document.querySelectorAll('#listaProductos input[type="checkbox"]'); // Renderizado
let seleccionados = []; // -----------------------------------------------------------------------
checks.forEach(chk => { if (chk.checked) seleccionados.push(chk.value); }); function renderizarPredicciones() {
document.getElementById('listaGenerada').value = seleccionados.join('\n'); const contenedor = document.getElementById('columnaProductos');
} contenedor.innerHTML = '';
const grupos = [
function copiarLista() { { titulo: 'Compra semanal', badge: 'badge-rojo', label: 'cada semana', items: predicciones.filter(p => p.frecuencia_dias <= 8) },
const txt = document.getElementById('listaGenerada'); { titulo: 'Compra quincenal', badge: 'badge-naranja', label: 'cada 1-2 semanas', items: predicciones.filter(p => p.frecuencia_dias > 8 && p.frecuencia_dias <= 16) },
txt.select(); { titulo: 'Compra mensual', badge: 'badge-verde', label: 'cada 2-4 semanas', items: predicciones.filter(p => p.frecuencia_dias > 16 && p.frecuencia_dias <= 35) },
document.execCommand('copy'); { titulo: 'Compra esporadica', badge: 'badge-gris', label: 'mas de un mes', items: predicciones.filter(p => p.frecuencia_dias > 35) },
alert('Lista copiada al portapapeles.'); ];
} grupos.forEach(grupo => {
if (!grupo.items.length) return;
function predecirProductos() { const card = document.createElement('div');
const predicciones = document.getElementById('predicciones'); card.className = 'card';
predicciones.innerHTML = ''; card.innerHTML =
const hoy = new Date("2025-04-18"); '<div class="card-header"><strong>' + grupo.titulo + '</strong>' +
Object.keys(historial).forEach(producto => { '<span class="badge ' + grupo.badge + '">' + grupo.label + '</span></div>' +
const fechas = historial[producto].map(f => new Date(f)); '<div class="prod-list"></div>';
fechas.sort((a, b) => b - a); contenedor.appendChild(card);
if (fechas.length >= 2) { const lista = card.querySelector('.prod-list');
const ultima = fechas[0]; grupo.items.forEach(prod => lista.appendChild(crearItem(prod)));
const anterior = fechas[1];
const diff = (ultima - anterior) / (1000 * 60 * 60 * 24);
const diasDesdeUltima = (hoy - ultima) / (1000 * 60 * 60 * 24);
if (diasDesdeUltima >= diff - 1) {
const li = document.createElement('li');
li.textContent = `Puede que necesites: ${producto}`;
predicciones.appendChild(li);
}
}
}); });
} }
async function leerPDF() { function crearItem(prod) {
const archivo = document.getElementById('ticketPDF').files[0]; const li = document.createElement('div');
if (!archivo) return; li.className = 'prod-item';
li.dataset.nombre = prod.producto;
const pdfData = await archivo.arrayBuffer(); const chk = document.createElement('input');
chk.type = 'checkbox';
chk.checked = seleccionados.has(prod.producto);
chk.addEventListener('change', () => toggleSeleccionado(prod.producto, chk.checked, span));
const span = document.createElement('span');
span.className = 'prod-nombre' + (chk.checked ? ' tachado' : '');
span.textContent = prod.producto;
const freq = document.createElement('span');
freq.className = 'prod-freq';
freq.textContent = '~' + prod.frecuencia_dias + 'd';
freq.title = 'Estimado: ' + prod.fecha_estimada;
li.appendChild(chk);
li.appendChild(span);
li.appendChild(freq);
li.addEventListener('click', e => {
if (e.target !== chk) { chk.checked = !chk.checked; toggleSeleccionado(prod.producto, chk.checked, span); }
});
return li;
}
// -----------------------------------------------------------------------
// Seleccion
// -----------------------------------------------------------------------
function toggleSeleccionado(nombre, checked, span) {
if (checked) { seleccionados.add(nombre); if (span) span.classList.add('tachado'); }
else { seleccionados.delete(nombre); if (span) span.classList.remove('tachado'); }
actualizarLista();
}
function marcarSemanal() {
if (typeof predicciones === 'undefined') return;
predicciones.filter(p => p.frecuencia_dias <= 8).forEach(p => seleccionados.add(p.producto));
refrescarCheckboxes();
actualizarLista();
}
function desmarcarTodo() {
seleccionados.clear();
productosManuales = [];
refrescarCheckboxes();
actualizarLista();
}
function refrescarCheckboxes() {
document.querySelectorAll('.prod-item').forEach(li => {
const chk = li.querySelector('input[type="checkbox"]');
const span = li.querySelector('.prod-nombre');
const activo = seleccionados.has(li.dataset.nombre);
chk.checked = activo;
span.classList.toggle('tachado', activo);
});
}
// -----------------------------------------------------------------------
// Lista
// -----------------------------------------------------------------------
function actualizarLista() {
const todos = [...seleccionados, ...productosManuales];
document.getElementById('listaGenerada').value = todos.join('\n');
document.getElementById('contadorSeleccionados').textContent = todos.length;
}
function copiarLista() {
const txt = document.getElementById('listaGenerada').value;
if (!txt) return;
navigator.clipboard.writeText(txt)
.then(() => alert('Lista copiada al portapapeles'))
.catch(() => { const ta = document.getElementById('listaGenerada'); ta.select(); document.execCommand('copy'); });
}
function limpiarLista() {
seleccionados.clear();
productosManuales = [];
refrescarCheckboxes();
actualizarLista();
}
function agregarManual() {
const input = document.getElementById('nuevoProducto');
const nombre = input.value.trim();
if (!nombre) return;
productosManuales.push(nombre);
input.value = '';
actualizarLista();
}
// -----------------------------------------------------------------------
// Drag & Drop + lectura PDF
// -----------------------------------------------------------------------
function dzOver(e) { e.preventDefault(); document.getElementById('dropZone').classList.add('drag-over'); }
function dzLeave() { document.getElementById('dropZone').classList.remove('drag-over'); }
function dzDrop(e) {
e.preventDefault();
dzLeave();
const file = e.dataTransfer.files[0];
if (file && file.type === 'application/pdf') leerPDF(file);
}
async function leerPDF(file) {
if (!file) return;
const estado = document.getElementById('pdfEstado');
estado.textContent = 'Leyendo ' + file.name + '...';
const pdfData = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: pdfData }).promise; const pdf = await pdfjsLib.getDocument({ data: pdfData }).promise;
let texto = ''; let texto = '';
for (let i = 1; i <= pdf.numPages; i++) { for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i); const page = await pdf.getPage(i);
const content = await page.getTextContent(); const content = await page.getTextContent();
texto += content.items.map(item => item.str).join(' ') + '\n'; texto += content.items.map(item => item.str).join(' ') + '\n';
} }
estado.textContent = file.name + ' - ' + pdf.numPages + ' pagina(s)';
const salida = document.getElementById('pdfTexto'); const salida = document.getElementById('pdfTexto');
salida.style.display = 'block'; salida.style.display = 'block';
salida.textContent = texto.trim(); salida.textContent = texto.trim();
} }
</script> </script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,40 @@
<?php
// Configuración de conexión
$host = 'tecnologia-facil.es';
$db = 'autocompra';
$user = 'mytecda6d2e';
$pass = '3s5jJzz8';
header('Content-Type: application/json');
// Leer los datos enviados en formato JSON
$input = file_get_contents('php://input');
$data = json_decode($input, true);
// Validar
if (!$data || !isset($data['nombre']) || !isset($data['ean'])) {
http_response_code(400);
echo json_encode(['error' => '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()]);
}
?>

Binary file not shown.

Binary file not shown.