diff --git a/Dockerfile b/Dockerfile index d4cfe55..ffef638 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,18 @@ 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 . 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 . . # Crear carpetas que necesita la app diff --git a/app.py b/app.py index f204d13..cb104f6 100644 --- a/app.py +++ b/app.py @@ -10,12 +10,14 @@ En produccion usa gunicorn detras de nginx: """ import os +import io +import re import json import subprocess import sys from pathlib import Path from functools import wraps -from datetime import timedelta +from datetime import datetime, timedelta from flask import ( Flask, render_template, request, redirect, @@ -31,11 +33,71 @@ DATOS_JSON = BASE_DIR / "datos.json" USERS_FILE = BASE_DIR / "users.json" 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.secret_key = os.environ.get("SECRET_KEY", "cambia-esto-en-produccion-!!!!") 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) # ----------------------------------------------------------------------- @@ -228,6 +290,78 @@ def admin_eliminar_usuario(): guardar_usuarios(users) 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 # ----------------------------------------------------------------------- diff --git a/autocompra3.py b/autocompra3.py index 41f8a93..f5e9c47 100644 --- a/autocompra3.py +++ b/autocompra3.py @@ -4,6 +4,7 @@ import os from datetime import datetime, timedelta from PyPDF2 import PdfReader from collections import defaultdict +import numpy as np # Carpeta con tus tickets PDF ticket_folder = "tickets" @@ -49,20 +50,68 @@ df.dropna(subset=["fecha"], inplace=True) # Normalizar nombres de producto 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) -frecuencia_compra = df.groupby("producto")["diferencia_dias"].mean().reset_index() +def _meses_activos(meses_compra): + 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) -# Suponemos que compras aproximadamente la misma cantidad cada vez. -# Si se desea una estimación más precisa, se pueden agregar más datos sobre cantidad. -frecuencia_compra["proxima_compra_estimado"] = df["fecha"].max() + pd.to_timedelta(frecuencia_compra["diferencia_dias"], unit="D") +def _proximo_mes_activo(mes_hoy, meses_activos): + 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_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 # 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 productos_estimados["producto"] = productos_estimados["producto"].str.title() # Capitalizar el nombre del producto diff --git a/autocompra7.py b/autocompra7.py index 45ad204..95026a6 100644 --- a/autocompra7.py +++ b/autocompra7.py @@ -1,7 +1,9 @@ import re import pandas as pd +import numpy as np import os -from datetime import datetime +import json +from datetime import datetime, timedelta from PyPDF2 import PdfReader # Carpeta con los tickets PDF @@ -55,9 +57,29 @@ def extract_data_from_pdf(file_path): # Recolectar todos los datos de los tickets datos = [] for file in os.listdir(ticket_folder): + path = os.path.join(ticket_folder, file) if file.endswith(".pdf"): - path = os.path.join(ticket_folder, file) 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 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.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 -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') +# --------------------------------------------------------------------------- +# Cálculo de frecuencia con estacionalidad real por meses del año +# --------------------------------------------------------------------------- +# 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) +df = df.merge(resumen_estacional, on="producto", how="left") compra_estimacion = df[df["proxima_compra"] <= proxima_semana] # Guardar lista estimada @@ -105,3 +201,11 @@ print("- resumen_productos.csv") print("- gasto_mensual.csv") print("- compra_estimacion.csv") 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") diff --git a/generar_lista.py b/generar_lista.py index 7da8fff..6d28999 100644 --- a/generar_lista.py +++ b/generar_lista.py @@ -95,6 +95,8 @@ for _, row in df.iterrows(): 'fecha_estimada': row['fecha_estimada_proxima_compra'], 'dias_hasta': int(row['dias_hasta']), '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 \ No newline at end of file