Subir tickets con foto de ticket
This commit is contained in:
parent
e658a8f8f1
commit
5ecc8e7073
|
|
@ -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
136
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
|
||||
# -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
118
autocompra7.py
118
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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue