Automatizacion de tareas
This commit is contained in:
parent
249de94ecf
commit
6930c63ca5
|
|
@ -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)
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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.")
|
||||||
|
|
@ -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)
|
||||||
302
index.html
302
index.html
|
|
@ -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>
|
||||||
|
|
@ -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.
Loading…
Reference in New Issue