Subir tickets con foto de ticket
This commit is contained in:
parent
e658a8f8f1
commit
5ecc8e7073
|
|
@ -2,9 +2,18 @@
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dependencias del sistema para opencv y easyocr
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Pre-descargar modelos de EasyOCR (es + en) para evitar esperas en produccion
|
||||||
|
RUN python -c "import easyocr; easyocr.Reader(['es', 'en'], gpu=False, verbose=False)"
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Crear carpetas que necesita la app
|
# Crear carpetas que necesita la app
|
||||||
|
|
|
||||||
136
app.py
136
app.py
|
|
@ -10,12 +10,14 @@ En produccion usa gunicorn detras de nginx:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import io
|
||||||
|
import re
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask, render_template, request, redirect,
|
Flask, render_template, request, redirect,
|
||||||
|
|
@ -31,11 +33,71 @@ DATOS_JSON = BASE_DIR / "datos.json"
|
||||||
USERS_FILE = BASE_DIR / "users.json"
|
USERS_FILE = BASE_DIR / "users.json"
|
||||||
TICKETS_DIR = BASE_DIR / "tickets"
|
TICKETS_DIR = BASE_DIR / "tickets"
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_TYPES = {'image/jpeg', 'image/jpg', 'image/png', 'image/webp'}
|
||||||
|
MAX_IMAGE_BYTES = 15 * 1024 * 1024 # 15 MB
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
|
|
||||||
app.secret_key = os.environ.get("SECRET_KEY", "cambia-esto-en-produccion-!!!!")
|
app.secret_key = os.environ.get("SECRET_KEY", "cambia-esto-en-produccion-!!!!")
|
||||||
app.permanent_session_lifetime = timedelta(days=30)
|
app.permanent_session_lifetime = timedelta(days=30)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# OCR — lector lazy (se inicializa la primera vez que se usa)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
_ocr_reader = None
|
||||||
|
|
||||||
|
def get_ocr_reader():
|
||||||
|
global _ocr_reader
|
||||||
|
if _ocr_reader is None:
|
||||||
|
import easyocr
|
||||||
|
_ocr_reader = easyocr.Reader(['es', 'en'], gpu=False, verbose=False)
|
||||||
|
return _ocr_reader
|
||||||
|
|
||||||
|
# Palabras que indican que la línea NO es un producto
|
||||||
|
_OCR_EXCLUDE = [
|
||||||
|
"TARJETA", "IVA", "CUOTA", "TOTAL", "DEVOLUCIONES", "N.C", "AUT",
|
||||||
|
"VERIFICADO", "VISA", "IMPORTE", "MASTERCARD", "MERCADONA", "SUMA",
|
||||||
|
"EFECTIVO", "CAMBIO", "TICKET", "FACTURA",
|
||||||
|
]
|
||||||
|
|
||||||
|
def parsear_texto_ticket(texto):
|
||||||
|
"""Extrae productos de texto OCR con el mismo formato que los tickets Mercadona."""
|
||||||
|
productos = []
|
||||||
|
for line in texto.splitlines():
|
||||||
|
linea = line.strip()
|
||||||
|
if not linea:
|
||||||
|
continue
|
||||||
|
if any(kw in linea.upper() for kw in _OCR_EXCLUDE):
|
||||||
|
continue
|
||||||
|
# "2 ROLLO HOGAR DOBLE 2,35 4,70"
|
||||||
|
m = re.match(r"^(\d+)\s+(.+?)\s+(\d+[,\.]\d{2})\s+(\d+[,\.]\d{2})$", linea)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
productos.append({
|
||||||
|
"cantidad": int(m.group(1)),
|
||||||
|
"producto": m.group(2).strip().upper(),
|
||||||
|
"precio_unitario": round(float(m.group(3).replace(',', '.')), 2),
|
||||||
|
"precio_total": round(float(m.group(4).replace(',', '.')), 2),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# "1 CROISSANT RELL CACAO 1,90"
|
||||||
|
m = re.match(r"^(\d+)\s+(.+?)\s+(\d+[,\.]\d{2})$", linea)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
cantidad = int(m.group(1))
|
||||||
|
precio = round(float(m.group(3).replace(',', '.')), 2)
|
||||||
|
productos.append({
|
||||||
|
"cantidad": cantidad,
|
||||||
|
"producto": m.group(2).strip().upper(),
|
||||||
|
"precio_unitario": round(precio / cantidad, 2),
|
||||||
|
"precio_total": precio,
|
||||||
|
})
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return productos
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Gestion de usuarios (users.json — NO subir al repo)
|
# Gestion de usuarios (users.json — NO subir al repo)
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|
@ -228,6 +290,78 @@ def admin_eliminar_usuario():
|
||||||
guardar_usuarios(users)
|
guardar_usuarios(users)
|
||||||
return redirect(url_for("admin_usuarios"))
|
return redirect(url_for("admin_usuarios"))
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# API: OCR de foto de ticket
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
@app.route("/api/ocr-ticket", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_ocr_ticket():
|
||||||
|
if 'imagen' not in request.files:
|
||||||
|
return jsonify({"ok": False, "mensaje": "No se recibio ninguna imagen"}), 400
|
||||||
|
|
||||||
|
file = request.files['imagen']
|
||||||
|
if not file or file.filename == '':
|
||||||
|
return jsonify({"ok": False, "mensaje": "Archivo vacio"}), 400
|
||||||
|
|
||||||
|
content_type = file.content_type or ''
|
||||||
|
if content_type not in ALLOWED_IMAGE_TYPES:
|
||||||
|
return jsonify({"ok": False, "mensaje": "Formato no soportado. Usa JPG, PNG o WEBP"}), 400
|
||||||
|
|
||||||
|
img_bytes = file.read()
|
||||||
|
if len(img_bytes) > MAX_IMAGE_BYTES:
|
||||||
|
return jsonify({"ok": False, "mensaje": "La imagen es demasiado grande (max 15 MB)"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageEnhance, ImageFilter
|
||||||
|
|
||||||
|
# Preprocesar para mejorar OCR: escala de grises + contraste
|
||||||
|
img = Image.open(io.BytesIO(img_bytes)).convert('RGB')
|
||||||
|
img = img.convert('L') # escala de grises
|
||||||
|
img = ImageEnhance.Contrast(img).enhance(2.0) # aumentar contraste
|
||||||
|
img = img.filter(ImageFilter.SHARPEN) # nitidez
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format='PNG')
|
||||||
|
img_procesada = buf.getvalue()
|
||||||
|
|
||||||
|
reader = get_ocr_reader()
|
||||||
|
lineas = reader.readtext(img_procesada, detail=0, paragraph=True)
|
||||||
|
texto = '\n'.join(lineas)
|
||||||
|
productos = parsear_texto_ticket(texto)
|
||||||
|
|
||||||
|
if not productos:
|
||||||
|
return jsonify({
|
||||||
|
"ok": False,
|
||||||
|
"mensaje": "No se encontraron productos. Prueba con una foto mas nitida.",
|
||||||
|
"texto": texto,
|
||||||
|
}), 422
|
||||||
|
|
||||||
|
# Extraer fecha del ticket si aparece en el texto
|
||||||
|
date_match = re.search(r"(\d{2}/\d{2}/\d{4})", texto)
|
||||||
|
fecha_ticket = date_match.group(1) if date_match else datetime.now().strftime('%d/%m/%Y')
|
||||||
|
|
||||||
|
# Guardar como JSON en la carpeta tickets/
|
||||||
|
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
ticket_path = TICKETS_DIR / f"ocr_{ts}.json"
|
||||||
|
TICKETS_DIR.mkdir(exist_ok=True)
|
||||||
|
with open(ticket_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump({
|
||||||
|
"fecha": fecha_ticket,
|
||||||
|
"fuente": "ocr",
|
||||||
|
"archivo": file.filename,
|
||||||
|
"productos": productos,
|
||||||
|
}, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"mensaje": f"{len(productos)} productos guardados",
|
||||||
|
"productos": productos,
|
||||||
|
"fecha": fecha_ticket,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "mensaje": f"Error al procesar la imagen: {str(e)}"}), 500
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Entry point
|
# Entry point
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import os
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from PyPDF2 import PdfReader
|
from PyPDF2 import PdfReader
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
# Carpeta con tus tickets PDF
|
# Carpeta con tus tickets PDF
|
||||||
ticket_folder = "tickets"
|
ticket_folder = "tickets"
|
||||||
|
|
@ -49,20 +50,68 @@ df.dropna(subset=["fecha"], inplace=True)
|
||||||
# Normalizar nombres de producto
|
# Normalizar nombres de producto
|
||||||
df["producto"] = df["producto"].str.upper().str.strip()
|
df["producto"] = df["producto"].str.upper().str.strip()
|
||||||
|
|
||||||
# Calcular el tiempo entre compras para cada producto
|
# ---------------------------------------------------------------------------
|
||||||
df["diferencia_dias"] = df.groupby("producto")["fecha"].diff().dt.days
|
# Cálculo de frecuencia con estacionalidad real por meses del año
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
hoy = datetime.now()
|
||||||
|
|
||||||
# Calcular la frecuencia de compra (promedio de días entre compras)
|
def _meses_activos(meses_compra):
|
||||||
frecuencia_compra = df.groupby("producto")["diferencia_dias"].mean().reset_index()
|
activos = set()
|
||||||
|
for m in meses_compra:
|
||||||
|
activos.add(m)
|
||||||
|
activos.add((m % 12) + 1)
|
||||||
|
activos.add(((m - 2) % 12) + 1)
|
||||||
|
return activos
|
||||||
|
|
||||||
# Estimación de la duración de los productos (cuánto duran en casa)
|
def _proximo_mes_activo(mes_hoy, meses_activos):
|
||||||
# Suponemos que compras aproximadamente la misma cantidad cada vez.
|
for i in range(1, 13):
|
||||||
# Si se desea una estimación más precisa, se pueden agregar más datos sobre cantidad.
|
if ((mes_hoy - 1 + i) % 12) + 1 in meses_activos:
|
||||||
frecuencia_compra["proxima_compra_estimado"] = df["fecha"].max() + pd.to_timedelta(frecuencia_compra["diferencia_dias"], unit="D")
|
return i
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def calcular_frecuencia_estacional(grupo):
|
||||||
|
fechas = grupo['fecha'].sort_values().dropna()
|
||||||
|
if len(fechas) < 1:
|
||||||
|
return pd.Series({'diferencia_dias': float('nan'), 'proxima_compra_estimado': pd.NaT})
|
||||||
|
|
||||||
|
meses_compra = set(int(m) for m in fechas.dt.month.unique())
|
||||||
|
es_estacional = len(meses_compra) <= 5
|
||||||
|
|
||||||
|
gaps = fechas.diff().dt.days.dropna()
|
||||||
|
gaps_validos = gaps[(gaps > 0) & (gaps <= 90)]
|
||||||
|
if len(gaps_validos) >= 1:
|
||||||
|
freq = float(gaps_validos.mean())
|
||||||
|
elif len(gaps[gaps > 0]) >= 1:
|
||||||
|
freq = float(gaps[gaps > 0].mean())
|
||||||
|
else:
|
||||||
|
return pd.Series({'diferencia_dias': float('nan'), 'proxima_compra_estimado': pd.NaT})
|
||||||
|
|
||||||
|
ultima = fechas.max()
|
||||||
|
proxima = ultima + timedelta(days=freq)
|
||||||
|
|
||||||
|
if es_estacional:
|
||||||
|
activos = _meses_activos(meses_compra)
|
||||||
|
if hoy.month not in activos:
|
||||||
|
avance = _proximo_mes_activo(hoy.month, activos)
|
||||||
|
mes_inicio = ((hoy.month - 1 + avance) % 12) + 1
|
||||||
|
anio = hoy.year if mes_inicio > hoy.month else hoy.year + 1
|
||||||
|
dia = min(int(ultima.day), 28)
|
||||||
|
try:
|
||||||
|
proxima = datetime(anio, mes_inicio, dia)
|
||||||
|
except ValueError:
|
||||||
|
proxima = datetime(anio, mes_inicio, 1)
|
||||||
|
|
||||||
|
return pd.Series({'diferencia_dias': freq, 'proxima_compra_estimado': pd.Timestamp(proxima)})
|
||||||
|
|
||||||
|
frecuencia_compra = (
|
||||||
|
df.groupby("producto", group_keys=False)
|
||||||
|
.apply(calcular_frecuencia_estacional)
|
||||||
|
.reset_index()
|
||||||
|
)
|
||||||
|
|
||||||
# Ahora seleccionamos los productos que más frecuentemente compras
|
# Ahora seleccionamos los productos que más frecuentemente compras
|
||||||
# y predecimos cuándo volverás a comprar basándonos en la frecuencia.
|
# y predecimos cuándo volverás a comprar basándonos en la frecuencia.
|
||||||
productos_estimados = frecuencia_compra.sort_values("diferencia_dias", ascending=True)
|
productos_estimados = frecuencia_compra.dropna(subset=["diferencia_dias"]).sort_values("diferencia_dias", ascending=True)
|
||||||
|
|
||||||
# Listar la compra estimada
|
# Listar la compra estimada
|
||||||
productos_estimados["producto"] = productos_estimados["producto"].str.title() # Capitalizar el nombre del producto
|
productos_estimados["producto"] = productos_estimados["producto"].str.title() # Capitalizar el nombre del producto
|
||||||
|
|
|
||||||
118
autocompra7.py
118
autocompra7.py
|
|
@ -1,7 +1,9 @@
|
||||||
import re
|
import re
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from PyPDF2 import PdfReader
|
from PyPDF2 import PdfReader
|
||||||
|
|
||||||
# Carpeta con los tickets PDF
|
# Carpeta con los tickets PDF
|
||||||
|
|
@ -55,9 +57,29 @@ def extract_data_from_pdf(file_path):
|
||||||
# Recolectar todos los datos de los tickets
|
# Recolectar todos los datos de los tickets
|
||||||
datos = []
|
datos = []
|
||||||
for file in os.listdir(ticket_folder):
|
for file in os.listdir(ticket_folder):
|
||||||
if file.endswith(".pdf"):
|
|
||||||
path = os.path.join(ticket_folder, file)
|
path = os.path.join(ticket_folder, file)
|
||||||
|
if file.endswith(".pdf"):
|
||||||
datos.extend(extract_data_from_pdf(path))
|
datos.extend(extract_data_from_pdf(path))
|
||||||
|
elif file.endswith(".json"):
|
||||||
|
# Tickets procesados con OCR desde foto de móvil
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
ticket = json.load(f)
|
||||||
|
fecha_str = ticket.get("fecha", "")
|
||||||
|
try:
|
||||||
|
fecha = datetime.strptime(fecha_str, "%d/%m/%Y")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
for p in ticket.get("productos", []):
|
||||||
|
datos.append((
|
||||||
|
fecha,
|
||||||
|
p.get("cantidad", 1),
|
||||||
|
str(p.get("producto", "")).upper(),
|
||||||
|
float(p.get("precio_unitario", 0.0)),
|
||||||
|
float(p.get("precio_total", 0.0)),
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[warn] No se pudo leer {file}: {e}")
|
||||||
|
|
||||||
# Crear DataFrame
|
# Crear DataFrame
|
||||||
columnas = ["fecha", "cantidad", "producto", "precio_unitario", "precio_total"]
|
columnas = ["fecha", "cantidad", "producto", "precio_unitario", "precio_total"]
|
||||||
|
|
@ -82,13 +104,87 @@ df["mes"] = df["fecha"].dt.to_period("M")
|
||||||
gasto_mensual = df.groupby("mes")["precio_total"].sum()
|
gasto_mensual = df.groupby("mes")["precio_total"].sum()
|
||||||
gasto_mensual.to_csv("gasto_mensual.csv")
|
gasto_mensual.to_csv("gasto_mensual.csv")
|
||||||
|
|
||||||
# Calcular próxima compra estimada por media de días entre compras
|
# ---------------------------------------------------------------------------
|
||||||
df["dias_entre_compras"] = df.groupby("producto")["fecha"].diff().dt.days
|
# Cálculo de frecuencia con estacionalidad real por meses del año
|
||||||
df["promedio_dias_entre_compras"] = df.groupby("producto")["dias_entre_compras"].transform("mean")
|
# ---------------------------------------------------------------------------
|
||||||
df["proxima_compra"] = df["fecha"] + pd.to_timedelta(df["promedio_dias_entre_compras"], unit='D')
|
# Algoritmo:
|
||||||
|
# 1. Detectar en qué meses del año se compra el producto.
|
||||||
|
# 2. Si se compra en ≤ 5 meses distintos → producto estacional.
|
||||||
|
# 3. Frecuencia = media de gaps entre compras consecutivas dentro de temporada (≤ 90 días).
|
||||||
|
# 4. Si hoy está en temporada → proyectar normalmente desde la última compra.
|
||||||
|
# 5. Si hoy está FUERA de temporada → proyectar al inicio del próximo período activo.
|
||||||
|
hoy = datetime.now()
|
||||||
|
|
||||||
# Crear la lista estimada para la próxima compra (esta semana)
|
def _meses_activos(meses_compra):
|
||||||
|
"""Devuelve el conjunto de meses activos con ±1 mes de tolerancia, circular."""
|
||||||
|
activos = set()
|
||||||
|
for m in meses_compra:
|
||||||
|
activos.add(m)
|
||||||
|
activos.add((m % 12) + 1) # mes siguiente
|
||||||
|
activos.add(((m - 2) % 12) + 1) # mes anterior
|
||||||
|
return activos
|
||||||
|
|
||||||
|
def _proximo_mes_activo(mes_hoy, meses_activos):
|
||||||
|
"""Devuelve cuántos meses hay que avanzar para entrar en temporada."""
|
||||||
|
for i in range(1, 13):
|
||||||
|
if ((mes_hoy - 1 + i) % 12) + 1 in meses_activos:
|
||||||
|
return i
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def calcular_frecuencia_estacional(grupo):
|
||||||
|
fechas = grupo['fecha'].sort_values().dropna()
|
||||||
|
if len(fechas) < 1:
|
||||||
|
return pd.Series({'diferencia_dias': float('nan'), 'proxima_compra': pd.NaT,
|
||||||
|
'es_estacional': False, 'meses_temporada': ''})
|
||||||
|
|
||||||
|
meses_compra = set(int(m) for m in fechas.dt.month.unique())
|
||||||
|
es_estacional = len(meses_compra) <= 5
|
||||||
|
|
||||||
|
# Frecuencia: solo gaps dentro de temporada (cortos)
|
||||||
|
gaps = fechas.diff().dt.days.dropna()
|
||||||
|
gaps_validos = gaps[(gaps > 0) & (gaps <= 90)]
|
||||||
|
if len(gaps_validos) >= 1:
|
||||||
|
freq = float(gaps_validos.mean())
|
||||||
|
elif len(gaps[gaps > 0]) >= 1:
|
||||||
|
freq = float(gaps[gaps > 0].mean())
|
||||||
|
else:
|
||||||
|
return pd.Series({'diferencia_dias': float('nan'), 'proxima_compra': pd.NaT,
|
||||||
|
'es_estacional': es_estacional,
|
||||||
|
'meses_temporada': ','.join(str(m) for m in sorted(meses_compra))})
|
||||||
|
|
||||||
|
ultima = fechas.max()
|
||||||
|
proxima = ultima + timedelta(days=freq)
|
||||||
|
|
||||||
|
if es_estacional:
|
||||||
|
activos = _meses_activos(meses_compra)
|
||||||
|
if hoy.month not in activos:
|
||||||
|
# Fuera de temporada: buscar el próximo mes activo
|
||||||
|
avance = _proximo_mes_activo(hoy.month, activos)
|
||||||
|
mes_inicio = ((hoy.month - 1 + avance) % 12) + 1
|
||||||
|
anio = hoy.year if mes_inicio > hoy.month else hoy.year + 1
|
||||||
|
dia = min(int(ultima.day), 28)
|
||||||
|
try:
|
||||||
|
proxima = datetime(anio, mes_inicio, dia)
|
||||||
|
except ValueError:
|
||||||
|
proxima = datetime(anio, mes_inicio, 1)
|
||||||
|
# Si estamos en temporada, la proyección normal (ultima + freq) ya es correcta
|
||||||
|
|
||||||
|
return pd.Series({
|
||||||
|
'diferencia_dias': freq,
|
||||||
|
'proxima_compra': pd.Timestamp(proxima),
|
||||||
|
'es_estacional': es_estacional,
|
||||||
|
'meses_temporada': ','.join(str(m) for m in sorted(meses_compra)),
|
||||||
|
})
|
||||||
|
|
||||||
|
resumen_estacional = (
|
||||||
|
df.groupby("producto", group_keys=False)
|
||||||
|
.apply(calcular_frecuencia_estacional)
|
||||||
|
.reset_index()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calcular próxima compra y filtrar para esta semana (pipeline original)
|
||||||
proxima_semana = datetime.now() + pd.Timedelta(days=7)
|
proxima_semana = datetime.now() + pd.Timedelta(days=7)
|
||||||
|
df = df.merge(resumen_estacional, on="producto", how="left")
|
||||||
compra_estimacion = df[df["proxima_compra"] <= proxima_semana]
|
compra_estimacion = df[df["proxima_compra"] <= proxima_semana]
|
||||||
|
|
||||||
# Guardar lista estimada
|
# Guardar lista estimada
|
||||||
|
|
@ -105,3 +201,11 @@ print("- resumen_productos.csv")
|
||||||
print("- gasto_mensual.csv")
|
print("- gasto_mensual.csv")
|
||||||
print("- compra_estimacion.csv")
|
print("- compra_estimacion.csv")
|
||||||
print("- lista_compra_estimada.html")
|
print("- lista_compra_estimada.html")
|
||||||
|
|
||||||
|
# Generar lista_compra_estimado.csv (usado por generar_lista.py)
|
||||||
|
lista_estimado = resumen_estacional.dropna(subset=["diferencia_dias"]).copy()
|
||||||
|
lista_estimado["producto"] = lista_estimado["producto"].str.title()
|
||||||
|
lista_estimado["fecha_estimada_proxima_compra"] = lista_estimado["proxima_compra"].dt.strftime("%d/%m/%Y")
|
||||||
|
lista_estimado = lista_estimado.sort_values("diferencia_dias", ascending=True)
|
||||||
|
lista_estimado.to_csv("lista_compra_estimado.csv", index=False)
|
||||||
|
print("- lista_compra_estimado.csv")
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,8 @@ for _, row in df.iterrows():
|
||||||
'fecha_estimada': row['fecha_estimada_proxima_compra'],
|
'fecha_estimada': row['fecha_estimada_proxima_compra'],
|
||||||
'dias_hasta': int(row['dias_hasta']),
|
'dias_hasta': int(row['dias_hasta']),
|
||||||
'frecuencia_dias': round(float(row['diferencia_dias']), 1),
|
'frecuencia_dias': round(float(row['diferencia_dias']), 1),
|
||||||
|
'es_estacional': bool(row['es_estacional']) if 'es_estacional' in row and pd.notna(row['es_estacional']) else False,
|
||||||
|
'meses_temporada': str(row['meses_temporada']) if 'meses_temporada' in row and pd.notna(row['meses_temporada']) else '',
|
||||||
})
|
})
|
||||||
|
|
||||||
# Escribir datos.js (cargable como <script src> sin servidor HTTP)
|
# Escribir datos.js (cargable como <script src> sin servidor HTTP)
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,6 @@ gunicorn>=21.0
|
||||||
werkzeug>=3.0
|
werkzeug>=3.0
|
||||||
PyPDF2>=3.0
|
PyPDF2>=3.0
|
||||||
pandas>=2.0
|
pandas>=2.0
|
||||||
|
easyocr>=1.7
|
||||||
|
Pillow>=10.0
|
||||||
|
opencv-python-headless>=4.8
|
||||||
|
|
@ -111,6 +111,7 @@ h2 {
|
||||||
.prod-nombre { flex: 1; font-size: .92rem; color: var(--text); }
|
.prod-nombre { flex: 1; font-size: .92rem; color: var(--text); }
|
||||||
.prod-nombre.tachado { text-decoration: line-through; color: var(--text-muted); }
|
.prod-nombre.tachado { text-decoration: line-through; color: var(--text-muted); }
|
||||||
.prod-freq { font-size: .72rem; color: var(--text-muted); white-space: nowrap; }
|
.prod-freq { font-size: .72rem; color: var(--text-muted); white-space: nowrap; }
|
||||||
|
.badge-temporada { font-size: .75rem; cursor: default; margin-left: .25rem; }
|
||||||
|
|
||||||
/* ---- Panel lateral ---- */
|
/* ---- Panel lateral ---- */
|
||||||
.panel {
|
.panel {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,17 @@
|
||||||
<div id="pdfEstado" style="font-size:.8rem; color:#8b949e; margin-top:.4rem;"></div>
|
<div id="pdfEstado" style="font-size:.8rem; color:#8b949e; margin-top:.4rem;"></div>
|
||||||
<pre id="pdfTexto"></pre>
|
<pre id="pdfTexto"></pre>
|
||||||
|
|
||||||
|
<h2>Subir foto del ticket 📷</h2>
|
||||||
|
<div class="drop-zone" id="dropZoneFoto"
|
||||||
|
ondragover="dzOver(event)" ondragleave="dzLeaveFoto()" ondrop="dzDropFoto(event)">
|
||||||
|
<input type="file" id="ticketFoto" accept="image/jpeg,image/png,image/webp"
|
||||||
|
capture="environment" onchange="subirFoto(this.files[0])">
|
||||||
|
<span class="drop-zone-icon">📷</span>
|
||||||
|
Foto del ticket (JPG, PNG, WEBP)
|
||||||
|
</div>
|
||||||
|
<div id="fotoEstado" style="font-size:.8rem; color:#8b949e; margin-top:.4rem;"></div>
|
||||||
|
<div id="fotoProductos" style="margin-top:.5rem;"></div>
|
||||||
|
|
||||||
<div style="margin-top:1.25rem; border-top:1px solid var(--border); padding-top:1rem;">
|
<div style="margin-top:1.25rem; border-top:1px solid var(--border); padding-top:1rem;">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="regenerar()" id="btnRegenerar">
|
<button class="btn btn-secondary btn-sm" onclick="regenerar()" id="btnRegenerar">
|
||||||
Regenerar predicciones
|
Regenerar predicciones
|
||||||
|
|
@ -151,6 +162,13 @@
|
||||||
|
|
||||||
li.appendChild(chk);
|
li.appendChild(chk);
|
||||||
li.appendChild(span);
|
li.appendChild(span);
|
||||||
|
if (prod.es_estacional) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'badge-temporada';
|
||||||
|
badge.textContent = '🌱';
|
||||||
|
badge.title = 'Producto de temporada (meses: ' + prod.meses_temporada + ')';
|
||||||
|
li.appendChild(badge);
|
||||||
|
}
|
||||||
li.appendChild(freq);
|
li.appendChild(freq);
|
||||||
li.addEventListener('click', e => {
|
li.addEventListener('click', e => {
|
||||||
if (e.target !== chk) { chk.checked = !chk.checked; toggleSeleccionado(prod.producto, chk.checked, span); }
|
if (e.target !== chk) { chk.checked = !chk.checked; toggleSeleccionado(prod.producto, chk.checked, span); }
|
||||||
|
|
@ -266,6 +284,46 @@
|
||||||
salida.style.display = 'block';
|
salida.style.display = 'block';
|
||||||
salida.textContent = texto.trim();
|
salida.textContent = texto.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Subir foto del ticket (OCR en servidor)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
function dzLeaveFoto() { document.getElementById('dropZoneFoto').classList.remove('drag-over'); }
|
||||||
|
function dzDropFoto(e) {
|
||||||
|
e.preventDefault(); dzLeaveFoto();
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file && file.type.startsWith('image/')) subirFoto(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subirFoto(file) {
|
||||||
|
if (!file) return;
|
||||||
|
const estado = document.getElementById('fotoEstado');
|
||||||
|
const productos = document.getElementById('fotoProductos');
|
||||||
|
estado.textContent = '⏳ Procesando con OCR... (puede tardar unos segundos)';
|
||||||
|
productos.innerHTML = '';
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('imagen', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ocr-ticket', { method: 'POST', body: form });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.ok) {
|
||||||
|
estado.textContent = '✅ ' + data.mensaje + ' (fecha: ' + data.fecha + ') — Pulsa "Regenerar predicciones" para actualizar la lista.';
|
||||||
|
productos.innerHTML = '<details style="margin-top:.4rem;"><summary style="cursor:pointer;font-size:.8rem;color:var(--text-muted)">Ver productos detectados</summary><ul style="font-size:.8rem;margin:.4rem 0 0 1rem;color:var(--text-muted);">' +
|
||||||
|
data.productos.map(p => `<li>${p.cantidad}× ${p.producto} — ${p.precio_total.toFixed(2)} €</li>`).join('') +
|
||||||
|
'</ul></details>';
|
||||||
|
} else {
|
||||||
|
estado.textContent = '❌ ' + data.mensaje;
|
||||||
|
if (data.texto) {
|
||||||
|
productos.innerHTML = '<details style="margin-top:.4rem;"><summary style="cursor:pointer;font-size:.8rem;color:var(--text-muted)">Texto extraido (sin productos)</summary><pre style="font-size:.72rem;color:var(--text-muted);white-space:pre-wrap;">' + data.texto + '</pre></details>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
estado.textContent = '❌ Error de conexion';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Loading…
Reference in New Issue