Subir tickets con foto de ticket

This commit is contained in:
Tatiana Villa Ema 2026-04-25 13:17:38 +02:00
parent e658a8f8f1
commit 5ecc8e7073
8 changed files with 378 additions and 18 deletions

View File

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

136
app.py
View File

@ -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
# -----------------------------------------------------------------------

View File

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

View File

@ -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")

View File

@ -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 <script src> sin servidor HTTP)

View File

@ -2,4 +2,7 @@
gunicorn>=21.0
werkzeug>=3.0
PyPDF2>=3.0
pandas>=2.0
pandas>=2.0
easyocr>=1.7
Pillow>=10.0
opencv-python-headless>=4.8

View File

@ -111,6 +111,7 @@ h2 {
.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; }
.badge-temporada { font-size: .75rem; cursor: default; margin-left: .25rem; }
/* ---- Panel lateral ---- */
.panel {

View File

@ -61,6 +61,17 @@
<div id="pdfEstado" style="font-size:.8rem; color:#8b949e; margin-top:.4rem;"></div>
<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;">
<button class="btn btn-secondary btn-sm" onclick="regenerar()" id="btnRegenerar">
Regenerar predicciones
@ -151,6 +162,13 @@
li.appendChild(chk);
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.addEventListener('click', e => {
if (e.target !== chk) { chk.checked = !chk.checked; toggleSeleccionado(prod.producto, chk.checked, span); }
@ -266,6 +284,46 @@
salida.style.display = 'block';
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>
</body>
</html>