diff --git a/Dockerfile b/Dockerfile index f1f31d7..b077495 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,22 +2,25 @@ FROM python:3.11-slim WORKDIR /app -# Install dependencies +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy source code -COPY app.py . -COPY templates/ ./templates/ -COPY static/ ./static/ -#COPY .env . +COPY . . -# Create the folder structure for the volume mounts -RUN mkdir -p /app/static/cache +# Create necessary directories +RUN mkdir -p /app/db /app/static/cache +# Expose port EXPOSE 5000 -# Run with unbuffered output so you can actually see the logs in Portainer +# Run with unbuffered output ENV PYTHONUNBUFFERED=1 CMD ["python", "app.py"] \ No newline at end of file diff --git a/README.md b/README.md index d4133ac..cd9d29b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# SekiPOS v2.2 🍫🥤 +# SekiPOS v3.0 🍫🥤 A reactive POS inventory system for software engineers with a snack addiction. Features real-time UI updates, automatic product discovery via Open Food Facts, and local image caching. diff --git a/app.py b/app.py index f2fa90c..34717dc 100644 --- a/app.py +++ b/app.py @@ -1,821 +1,79 @@ import os import sys -import sqlite3 -import requests -from flask import send_file -from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, send_from_directory -from flask_socketio import SocketIO, emit -from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user -from werkzeug.security import generate_password_hash, check_password_hash -import mimetypes import time -import uuid -from datetime import datetime -import zipfile -import io -import webview import threading +from flask import Flask, redirect, url_for, send_file, jsonify +from flask_login import login_required, current_user +from werkzeug.security import generate_password_hash +import webview +from flask_socketio import SocketIO + +from core.utils import get_bundled_path, get_persistent_path +from core.db import init_db as init_db_core, get_db_connection +from core.events import socketio + +from blueprints.auth import auth_bp, init_login_manager +from blueprints.finance import finance_bp +from blueprints.inventory import inventory_bp +from blueprints.pos import pos_bp +from blueprints.sales import sales_bp + # --- PYINSTALLER WINDOWED MODE FIX --- -# If running as a compiled exe, redirect stdout/stderr so Flask doesn't crash if getattr(sys, 'frozen', False) and sys.platform == "win32": - # Force output to a real file instead of the console log_file = os.path.join(os.path.dirname(sys.executable), 'seki_crash.log') sys.stdout = open(log_file, 'w', encoding='utf-8') sys.stderr = sys.stdout -# from dotenv import load_dotenv - -# load_dotenv() - -# MP_ACCESS_TOKEN = os.getenv('MP_ACCESS_TOKEN') -# MP_TERMINAL_ID = os.getenv('MP_TERMINAL_ID') - -# --- PATH HELPERS FOR PYINSTALLER --- -def get_bundled_path(relative_path): - """Path for read-only files packed inside the .exe (templates, static)""" - if getattr(sys, 'frozen', False): - base_path = sys._MEIPASS - else: - base_path = os.path.abspath(os.path.dirname(__file__)) - return os.path.join(base_path, relative_path) - -def get_persistent_path(relative_path): - """Path for read/write files that must survive reboots (db, cache)""" - if getattr(sys, 'frozen', False): - base_path = os.path.dirname(sys.executable) - else: - base_path = os.path.abspath(os.path.dirname(__file__)) - return os.path.join(base_path, relative_path) - # --- FLASK INIT --- app = Flask( __name__, template_folder=get_bundled_path('templates'), static_folder=get_bundled_path('static') ) -app.config['SECRET_KEY'] = 'seki_super_secret_key_99' # Change this if you have actual friends -socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading') - -# --- AUTH SETUP (Do not delete this) --- -login_manager = LoginManager(app) -login_manager.login_view = 'login' +app.config['SECRET_KEY'] = 'seki_super_secret_key_99' # --- DIRECTORY SETUP --- DB_DIR = get_persistent_path('db') os.makedirs(DB_DIR, exist_ok=True) DB_FILE = os.path.join(DB_DIR, "pos_database.db") +app.config['DB_FILE'] = DB_FILE CACHE_DIR = get_persistent_path(os.path.join('static', 'cache')) os.makedirs(CACHE_DIR, exist_ok=True) +app.config['CACHE_DIR'] = CACHE_DIR -# --- MODELS --- -class User(UserMixin): - def __init__(self, id, username): - self.id = id - self.username = username +# --- BLUEPRINT REGISTRATION --- +app.register_blueprint(auth_bp) +app.register_blueprint(finance_bp) +app.register_blueprint(inventory_bp) +app.register_blueprint(pos_bp) +app.register_blueprint(sales_bp) -# --- DATABASE LOGIC --- -def init_db(): - with sqlite3.connect(DB_FILE) as conn: - conn.execute('''CREATE TABLE IF NOT EXISTS users - (id INTEGER PRIMARY KEY, username TEXT UNIQUE, password TEXT)''') - - # Updated table definition - conn.execute('''CREATE TABLE IF NOT EXISTS products - (barcode TEXT PRIMARY KEY, - name TEXT, - price REAL, - image_url TEXT, - stock REAL DEFAULT 0, - unit_type TEXT DEFAULT 'unit')''') - - # Add these two tables for sales history - conn.execute('''CREATE TABLE IF NOT EXISTS sales - (id INTEGER PRIMARY KEY AUTOINCREMENT, - date TEXT DEFAULT CURRENT_TIMESTAMP, - total REAL, - payment_method TEXT)''') - - conn.execute('''CREATE TABLE IF NOT EXISTS sale_items - (id INTEGER PRIMARY KEY AUTOINCREMENT, - sale_id INTEGER, - barcode TEXT, - name TEXT, - price REAL, - quantity REAL, - subtotal REAL, - FOREIGN KEY(sale_id) REFERENCES sales(id))''') - - conn.execute('''CREATE TABLE IF NOT EXISTS dicom - (id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE, - amount REAL DEFAULT 0, - notes TEXT, - image_url TEXT, - last_updated TEXT DEFAULT CURRENT_TIMESTAMP)''') - - # Default user logic remains same... - user = conn.execute('SELECT * FROM users WHERE username = ?', ('admin',)).fetchone() - if not user: - hashed_pw = generate_password_hash('choripan1234') - conn.execute('INSERT INTO users (username, password) VALUES (?, ?)', ('admin', hashed_pw)) - conn.commit() +init_login_manager(app) +socketio.init_app(app, cors_allowed_origins="*", async_mode='threading') -@login_manager.user_loader -def load_user(user_id): - with sqlite3.connect(DB_FILE) as conn: - user = conn.execute('SELECT id, username FROM users WHERE id = ?', (user_id,)).fetchone() - return User(user[0], user[1]) if user else None - -def download_image(url, barcode): - if not url or not url.startswith('http'): - return url - - try: - headers = {'User-Agent': 'SekiPOS/1.2'} - # Use stream=True to check headers before downloading the whole file - with requests.get(url, headers=headers, stream=True, timeout=5) as r: - r.raise_for_status() - - # Detect extension from Content-Type header - content_type = r.headers.get('content-type') - ext = mimetypes.guess_extension(content_type) or '.jpg' - - local_filename = f"{barcode}{ext}" - local_path = os.path.join(CACHE_DIR, local_filename) - - # Save the file - with open(local_path, 'wb') as f: - for chunk in r.iter_content(chunk_size=8192): - f.write(chunk) - - return f"/static/cache/{local_filename}" - except Exception as e: - print(f"Download failed: {e}") - return url - -def fetch_from_openfoodfacts(barcode): - url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json" - try: - headers = {'User-Agent': 'SekiPOS/1.0'} - resp = requests.get(url, headers=headers, timeout=5).json() - - if resp.get('status') == 1: - p = resp.get('product', {}) - name = p.get('product_name_es') or p.get('product_name') or p.get('brands', 'Unknown') - imgs = p.get('selected_images', {}).get('front', {}).get('display', {}) - img_url = imgs.get('es') or imgs.get('en') or p.get('image_url', '') - - if img_url: - local_img = download_image(img_url, barcode) - return {"name": name, "image": local_img} - return {"name": name, "image": None} - - except Exception as e: - print(f"API Error: {e}") - return None - -# --- ROUTES --- -@app.route('/login', methods=['GET', 'POST']) -def login(): - if request.method == 'POST': - user_in = request.form.get('username') - pass_in = request.form.get('password') - with sqlite3.connect(DB_FILE) as conn: - user = conn.execute('SELECT * FROM users WHERE username = ?', (user_in,)).fetchone() - if user and check_password_hash(user[2], pass_in): - login_user(User(user[0], user[1])) - return redirect(url_for('inventory')) - flash('Invalid credentials.') - return render_template('login.html') - -@app.route('/logout') -@login_required -def logout(): - logout_user() - return redirect(url_for('login')) +# --- DATABASE INITIALIZATION --- +init_db_core(DB_FILE) +# --- ROOT ROUTE --- @app.route('/') @login_required -def defaultRoute(): - return redirect(url_for('inventory')) - -@app.route('/inventory') -@login_required -def inventory(): - with sqlite3.connect(DB_FILE) as conn: - products = conn.execute('SELECT * FROM products').fetchall() - return render_template('inventory.html', active_page='inventory', products=products, user=current_user) - -@app.route("/checkout") -@login_required -def checkout(): - with sqlite3.connect(DB_FILE) as conn: - # Fetching the same columns the scanner expects - products = conn.execute('SELECT barcode, name, price, image_url, stock, unit_type FROM products').fetchall() - return render_template("checkout.html", active_page='checkout', user=current_user, products=products) - -@app.route('/dicom') -@login_required -def dicom(): - with sqlite3.connect(DB_FILE) as conn: - debtors = conn.execute('SELECT id, name, amount, notes, datetime(last_updated, "localtime"), image_url FROM dicom ORDER BY amount DESC').fetchall() - return render_template('dicom.html', active_page='dicom', user=current_user, debtors=debtors) - -@app.route('/sales') -@login_required -def sales(): - selected_date = request.args.get('date') - payment_method = request.args.get('payment_method') - page = request.args.get('page', 1, type=int) - per_page = 100 - - with sqlite3.connect(DB_FILE) as conn: - cur = conn.cursor() - - # 1. Calculate the top 3 cards (Now respecting the payment method!) - target_date = selected_date if selected_date else cur.execute("SELECT date('now', 'localtime')").fetchone()[0] - - daily_query = "SELECT SUM(total) FROM sales WHERE date(date, 'localtime') = ?" - week_query = "SELECT SUM(total) FROM sales WHERE date(date, 'localtime') >= date('now', 'localtime', '-7 days')" - month_query = "SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = strftime('%Y-%m', 'now', 'localtime')" - - daily_params = [target_date] - week_params = [] - month_params = [] - - # If a payment method is selected, inject it into the top card queries - if payment_method: - daily_query += " AND payment_method = ?" - week_query += " AND payment_method = ?" - month_query += " AND payment_method = ?" - - daily_params.append(payment_method) - week_params.append(payment_method) - month_params.append(payment_method) - - stats = { - "daily": cur.execute(daily_query, tuple(daily_params)).fetchone()[0] or 0, - "week": cur.execute(week_query, tuple(week_params)).fetchone()[0] or 0, - "month": cur.execute(month_query, tuple(month_params)).fetchone()[0] or 0 - } - - # 2. Dynamic query builder for the main table and pagination - base_query = "FROM sales WHERE 1=1" - params = [] - - if selected_date: - base_query += " AND date(date, 'localtime') = ?" - params.append(selected_date) - if payment_method: - base_query += " AND payment_method = ?" - params.append(payment_method) - - # Get total count and sum for the current table filters BEFORE applying limit/offset - stats_query = f"SELECT COUNT(*), SUM(total) {base_query}" - count_res, sum_res = cur.execute(stats_query, tuple(params)).fetchone() - - total_count = count_res or 0 - total_sum = sum_res or 0 - total_pages = (total_count + per_page - 1) // per_page - - filtered_stats = { - "total": total_sum, - "count": total_count - } - - # Fetch the actual 100 rows for the current page - offset = (page - 1) * per_page - data_query = f"SELECT id, date, total, payment_method {base_query} ORDER BY date DESC LIMIT ? OFFSET ?" - - sales_data = cur.execute(data_query, tuple(params) + (per_page, offset)).fetchall() - - return render_template('sales.html', - active_page='sales', - user=current_user, - sales=sales_data, - stats=stats, - filtered_stats=filtered_stats, - selected_date=selected_date, - selected_payment=payment_method, - current_page=page, - total_pages=total_pages) - - -@app.route("/upsert", methods=["POST"]) -@login_required -def upsert(): - d = request.form - barcode = d['barcode'] - - try: - price = float(d['price']) - stock = float(d.get('stock', 0)) # New field - except (ValueError, TypeError): - price = 0.0 - stock = 0.0 - - unit_type = d.get('unit_type', 'unit') # New field (unit or kg) - final_image_path = download_image(d['image_url'], barcode) - - with sqlite3.connect(DB_FILE) as conn: - # Updated UPSERT query - conn.execute('''INSERT INTO products (barcode, name, price, image_url, stock, unit_type) - VALUES (?,?,?,?,?,?) - ON CONFLICT(barcode) DO UPDATE SET - name=excluded.name, - price=excluded.price, - image_url=excluded.image_url, - stock=excluded.stock, - unit_type=excluded.unit_type''', - (barcode, d['name'], price, final_image_path, stock, unit_type)) - conn.commit() - return redirect(url_for('inventory')) - -@app.route('/delete/', methods=['POST']) -@login_required -def delete(barcode): - with sqlite3.connect(DB_FILE) as conn: - conn.execute('DELETE FROM products WHERE barcode = ?', (barcode,)) - conn.commit() - # Clean up cache - img_p = os.path.join(CACHE_DIR, f"{barcode}.jpg") - if os.path.exists(img_p): os.remove(img_p) - socketio.emit('product_deleted', {"barcode": barcode}) - return redirect(url_for('inventory')) - -@app.route('/scan', methods=['GET']) -def scan(): - barcode = request.args.get('content', '').replace('{content}', '') - if not barcode: - return jsonify({"status": "error", "message": "empty barcode"}), 400 - - with sqlite3.connect(DB_FILE) as conn: - # Fixed: Selecting all 6 necessary columns - p = conn.execute('SELECT barcode, name, price, image_url, stock, unit_type FROM products WHERE barcode = ?', (barcode,)).fetchone() - - if p: - # Now matches the 6 columns in the SELECT statement - barcode_val, name, price, image_path, stock, unit_type = p - - if image_path and image_path.startswith('/static/'): - clean_path = image_path.split('?')[0].lstrip('/') - if not os.path.exists(clean_path): - ext_data = fetch_from_openfoodfacts(barcode_val) - if ext_data and ext_data.get('image'): - image_path = ext_data['image'] - with sqlite3.connect(DB_FILE) as conn: - conn.execute('UPDATE products SET image_url = ? WHERE barcode = ?', (image_path, barcode_val)) - conn.commit() - - product_data = { - "barcode": barcode_val, - "name": name, - "price": int(price), - "image": image_path, - "stock": stock, - "unit_type": unit_type - } - - socketio.emit('new_scan', product_data) - return jsonify({"status": "ok", "data": product_data}), 200 - - # 2. Product not in DB, try external API - ext = fetch_from_openfoodfacts(barcode) - if ext: - # We found it externally, but it's still a 404 relative to our local DB - external_data = { - "barcode": barcode, - "name": ext['name'], - "image": ext['image'], - "source": "openfoodfacts" - } - socketio.emit('scan_error', external_data) - return jsonify({"status": "not_found", "data": external_data}), 404 - - # 3. Truly not found anywhere - socketio.emit('scan_error', {"barcode": barcode}) - return jsonify({"status": "not_found", "data": {"barcode": barcode}}), 404 - -@app.route('/static/cache/') -def serve_cache(filename): - return send_from_directory(CACHE_DIR, filename) - -@app.route('/bulk_price_update', methods=['POST']) -@login_required -def bulk_price_update(): - data = request.get_json() - barcodes = data.get('barcodes', []) - new_price = data.get('new_price') - - if not barcodes or new_price is None: - return jsonify({"error": "Missing data"}), 400 - - try: - with sqlite3.connect(DB_FILE) as conn: - # Use executemany for efficiency - params = [(float(new_price), b) for b in barcodes] - conn.executemany('UPDATE products SET price = ? WHERE barcode = ?', params) - conn.commit() - return jsonify({"status": "success"}), 200 - except Exception as e: - print(f"Bulk update failed: {e}") - return jsonify({"error": str(e)}), 500 - -@app.route('/bulk_delete', methods=['POST']) -@login_required -def bulk_delete(): - data = request.get_json() - barcodes = data.get('barcodes', []) - - if not barcodes: - return jsonify({"error": "No barcodes provided"}), 400 - - try: - with sqlite3.connect(DB_FILE) as conn: - # Delete records from DB - conn.execute(f'DELETE FROM products WHERE barcode IN ({",".join(["?"]*len(barcodes))})', barcodes) - conn.commit() - - # Clean up cache for each deleted product - for barcode in barcodes: - # This is a bit naive as it only checks .jpg, but matches your existing delete logic - img_p = os.path.join(CACHE_DIR, f"{barcode}.jpg") - if os.path.exists(img_p): - os.remove(img_p) - - return jsonify({"status": "success"}), 200 - except Exception as e: - print(f"Bulk delete failed: {e}") - return jsonify({"error": str(e)}), 500 - -@app.route('/upload_image', methods=['POST']) -@login_required -def upload_image(): - if 'image' not in request.files or 'barcode' not in request.form: - return jsonify({"error": "Missing data"}), 400 - file = request.files['image'] - barcode = request.form['barcode'] - if file.filename == '' or not barcode: - return jsonify({"error": "Invalid data"}), 400 - - filename = f"{barcode}.jpg" - filepath = os.path.join(CACHE_DIR, filename) - file.save(filepath) - timestamp = int(time.time()) - return jsonify({"status": "success", "image_url": f"/static/cache/{filename}?t={timestamp}"}), 200 - -@app.route('/api/scale/weight', methods=['POST']) -def update_scale_weight(): - data = request.get_json() - - # Assuming the scale sends {"weight": 1250} (in grams) - weight_grams = data.get('weight', 0) - - # Optional: Convert to kg if you prefer - weight_kg = round(weight_grams / 1000, 3) - - # Broadcast to all connected clients via SocketIO - socketio.emit('scale_update', { - "grams": weight_grams, - "kilograms": weight_kg, - "timestamp": time.time() - }) - - return jsonify({"status": "received"}), 200 - - -@app.route('/api/checkout', methods=['POST']) -@login_required -def process_checkout(): - try: - data = request.get_json() - cart = data.get('cart', []) - payment_method = data.get('payment_method', 'efectivo') - - if not cart: - return jsonify({"error": "Cart is empty"}), 400 - - # Recalculate total on the server because the frontend is a liar - total = sum(item.get('subtotal', 0) for item in cart) - - with sqlite3.connect(DB_FILE) as conn: - cur = conn.cursor() - - # Let SQLite handle the exact UTC timestamp internally - cur.execute('INSERT INTO sales (date, total, payment_method) VALUES (CURRENT_TIMESTAMP, ?, ?)', (total, payment_method)) - sale_id = cur.lastrowid - - # Record each item and deduct stock - for item in cart: - cur.execute('''INSERT INTO sale_items (sale_id, barcode, name, price, quantity, subtotal) - VALUES (?, ?, ?, ?, ?, ?)''', - (sale_id, item['barcode'], item['name'], item['price'], item['qty'], item['subtotal'])) - - # Deduct from inventory (Manual products will safely be ignored here) - cur.execute('UPDATE products SET stock = stock - ? WHERE barcode = ?', (item['qty'], item['barcode'])) - - conn.commit() - - return jsonify({"status": "success", "sale_id": sale_id}), 200 - - except Exception as e: - print(f"Checkout Error: {e}") - return jsonify({"error": str(e)}), 500 - -@app.route('/api/sale/') -@login_required -def get_sale_details(sale_id): - with sqlite3.connect(DB_FILE) as conn: - items = conn.execute('SELECT barcode, name, price, quantity, subtotal FROM sale_items WHERE sale_id = ?', (sale_id,)).fetchall() - - # Format it as a neat list of dictionaries for JavaScript to digest - item_list = [{"barcode": i[0], "name": i[1], "price": i[2], "qty": i[3], "subtotal": i[4]} for i in items] - return jsonify(item_list), 200 - -@app.route('/api/sale/', methods=['DELETE']) -@login_required -def reverse_sale(sale_id): - try: - with sqlite3.connect(DB_FILE) as conn: - cur = conn.cursor() - - # 1. Get the items and quantities from the receipt - items = cur.execute('SELECT barcode, quantity FROM sale_items WHERE sale_id = ?', (sale_id,)).fetchall() - - # 2. Add the stock back to the inventory - for barcode, qty in items: - # This safely ignores manual products since their fake barcodes won't match any row - cur.execute('UPDATE products SET stock = stock + ? WHERE barcode = ?', (qty, barcode)) - - # 3. Destroy the evidence - cur.execute('DELETE FROM sale_items WHERE sale_id = ?', (sale_id,)) - cur.execute('DELETE FROM sales WHERE id = ?', (sale_id,)) - - conn.commit() - - return jsonify({"status": "success"}), 200 - - except Exception as e: - print(f"Reverse Sale Error: {e}") - return jsonify({"error": str(e)}), 500 - - - -@app.route('/api/dicom/update', methods=['POST']) -@login_required -def update_dicom(): - data = request.get_json() - name = data.get('name', '').strip() - amount = float(data.get('amount', 0)) - notes = data.get('notes', '') - image_url = data.get('image_url', '') - action = data.get('action') - - if not name or amount <= 0: - return jsonify({"error": "Nombre y monto válidos son requeridos"}), 400 - - if action == 'add': - amount = -amount - - with sqlite3.connect(DB_FILE) as conn: - cur = conn.cursor() - cur.execute('''INSERT INTO dicom (name, amount, notes, image_url, last_updated) - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(name) DO UPDATE SET - amount = amount + excluded.amount, - notes = excluded.notes, - image_url = CASE WHEN excluded.image_url != "" THEN excluded.image_url ELSE dicom.image_url END, - last_updated = CURRENT_TIMESTAMP''', (name, amount, notes, image_url)) - conn.commit() - return jsonify({"status": "success"}), 200 - -@app.route('/api/dicom/', methods=['DELETE']) -@login_required -def delete_dicom(debtor_id): - try: - with sqlite3.connect(DB_FILE) as conn: - conn.execute('DELETE FROM dicom WHERE id = ?', (debtor_id,)) - conn.commit() - return jsonify({"status": "success"}), 200 - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route('/settings/update', methods=['POST']) -@login_required -def update_settings(): - new_password = request.form.get('password') - profile_pic = request.form.get('profile_pic') - - with sqlite3.connect(DB_FILE) as conn: - if new_password and len(new_password) > 0: - hashed_pw = generate_password_hash(new_password) - conn.execute('UPDATE users SET password = ? WHERE id = ?', (hashed_pw, current_user.id)) - - if profile_pic: - conn.execute('UPDATE users SET profile_pic = ? WHERE id = ?', (profile_pic, current_user.id)) - conn.commit() - - flash('Configuración actualizada') - return redirect(request.referrer) - -@app.route('/export/db') -@login_required -def export_db(): - if os.path.exists(DB_FILE): - return send_file(DB_FILE, as_attachment=True, download_name=f"SekiPOS_Backup_{datetime.now().strftime('%Y%m%d')}.db", mimetype='application/x-sqlite3') - return "Error: Database file not found", 404 - -@app.route('/export/images') -@login_required -def export_images(): - if not os.path.exists(CACHE_DIR) or not os.listdir(CACHE_DIR): - return "No images found to export", 404 - - # Create an in-memory byte stream to hold the zip data - memory_file = io.BytesIO() - - with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf: - for root, dirs, files in os.walk(CACHE_DIR): - for file in files: - file_path = os.path.join(root, file) - # Store files using their names only to avoid nesting inside the zip - zf.write(file_path, arcname=file) - - memory_file.seek(0) - - return send_file( - memory_file, - mimetype='application/zip', - as_attachment=True, - download_name=f"SekiPOS_Images_{datetime.now().strftime('%Y%m%d')}.zip" - ) - - -from datetime import datetime - -@app.route('/gastos') -@login_required -def gastos(): - # Default to the current month if no filter is applied - selected_month = request.args.get('month', datetime.now().strftime('%Y-%m')) - - with sqlite3.connect(DB_FILE) as conn: - cur = conn.cursor() - - # Auto-create the table so it doesn't crash on first load - cur.execute(''' - CREATE TABLE IF NOT EXISTS expenses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - description TEXT NOT NULL, - amount INTEGER NOT NULL - ) - ''') - - # Calculate totals for the selected month - sales_total = cur.execute("SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = ?", (selected_month,)).fetchone()[0] or 0 - expenses_total = cur.execute("SELECT SUM(amount) FROM expenses WHERE strftime('%Y-%m', date, 'localtime') = ?", (selected_month,)).fetchone()[0] or 0 - - # Fetch the expense list - expenses_list = cur.execute("SELECT id, date, description, amount FROM expenses WHERE strftime('%Y-%m', date, 'localtime') = ? ORDER BY date DESC", (selected_month,)).fetchall() - - return render_template('gastos.html', - active_page='gastos', - user=current_user, - sales_total=sales_total, - expenses_total=expenses_total, - net_profit=sales_total - expenses_total, - expenses=expenses_list, - selected_month=selected_month) - -@app.route('/api/gastos', methods=['POST']) -@login_required -def add_gasto(): - data = request.get_json() - desc = data.get('description') - amount = data.get('amount') - - if not desc or not amount: - return jsonify({"error": "Faltan datos"}), 400 - - with sqlite3.connect(DB_FILE) as conn: - cur = conn.cursor() - cur.execute("INSERT INTO expenses (description, amount) VALUES (?, ?)", (desc, int(amount))) - conn.commit() - return jsonify({"success": True}) - -@app.route('/api/gastos/', methods=['DELETE']) -@login_required -def delete_gasto(gasto_id): - with sqlite3.connect(DB_FILE) as conn: - cur = conn.cursor() - cur.execute("DELETE FROM expenses WHERE id = ?", (gasto_id,)) - conn.commit() - return jsonify({"success": True}) - - -# @app.route('/process_payment', methods=['POST']) -# @login_required -# def process_payment(): -# data = request.get_json() -# total_amount = data.get('total') - -# if not total_amount or total_amount <= 0: -# return jsonify({"error": "Invalid amount"}), 400 - -# url = "https://api.mercadopago.com/v1/orders" - -# headers = { -# "Authorization": f"Bearer {MP_ACCESS_TOKEN}", -# "Content-Type": "application/json", -# "X-Idempotency-Key": str(uuid.uuid4()) -# } - -# # MP Point API often prefers integer strings for CLP or exact strings -# # We use int() here if you are dealing with CLP (no cents) -# formatted_amount = str(int(float(total_amount))) - -# payload = { -# "type": "point", -# "external_reference": f"ref_{int(time.time())}", -# "description": "Venta SekiPOS", -# "expiration_time": "PT16M", -# "transactions": { -# "payments": [ -# { -# "amount": formatted_amount -# } -# ] -# }, -# "config": { -# "point": { -# "terminal_id": MP_TERMINAL_ID, -# "print_on_terminal": "no_ticket" -# }, -# "payment_method": { -# "default_type": "credit_card" -# } -# }, -# "integration_data": { -# "platform_id": "dev_1234567890", -# "integrator_id": "dev_1234567890" -# }, -# "taxes": [ -# { -# "payer_condition": "payment_taxable_iva" -# } -# ] -# } - -# try: -# # Verify the payload in your terminal if it fails again -# response = requests.post(url, json=payload, headers=headers) - -# if response.status_code != 201 and response.status_code != 200: -# print(f"DEBUG MP ERROR: {response.text}") - -# return jsonify(response.json()), response.status_code -# except Exception as e: -# return jsonify({"error": str(e)}), 500 - -# @app.route('/api/mp-webhook', methods=['POST']) -# def webhook_notify(): -# data = request.get_json() -# action = data.get('action', 'unknown') -# # Emitimos a todos los clientes conectados -# socketio.emit('payment_update', { -# "status": action, -# "id": data.get('data', {}).get('id') -# }) -# return jsonify({"status": "ok"}), 200 +def index(): + return redirect(url_for('inventory.inventory')) +# --- RUN FUNCTION --- def start_server(): - # Use socketio.run instead of default app.run - #socketio.run(app, host='127.0.0.1', port=5000) socketio.run(app, host='127.0.0.1', port=5000, log_output=False, allow_unsafe_werkzeug=True) - + def run_standalone(): t = threading.Thread(target=start_server) t.daemon = True t.start() - - # GIVE FLASK 2 SECONDS TO BOOT UP BEFORE OPENING THE BROWSER time.sleep(2) - webview.create_window('SekiPOS', 'http://127.0.0.1:5000', width=1366, height=768, resizable=True, fullscreen=False, min_size=(800, 600), maximized=True) - - # private_mode=False is the magic flag that allows localStorage to survive. - # It saves data to %APPDATA%\pywebview on Windows. webview.start(private_mode=False) - + if __name__ == '__main__': - - init_db() - - # For standalone desktop app with embedded browser, use - #run_standalone() - - # For docker or traditional server deployment, comment out run_standalone() and uncomment the line below: - socketio.run(app, host='0.0.0.0', port=5000, debug=True) + #run_standalone() # Uncomment for desktop app + socketio.run(app, host='0.0.0.0', port=5000, debug=True) \ No newline at end of file diff --git a/blueprints/__init__.py b/blueprints/__init__.py new file mode 100644 index 0000000..e459109 --- /dev/null +++ b/blueprints/__init__.py @@ -0,0 +1 @@ +# blueprints/__init__.py \ No newline at end of file diff --git a/blueprints/__pycache__/.gitignore b/blueprints/__pycache__/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/blueprints/__pycache__/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/blueprints/auth.py b/blueprints/auth.py new file mode 100644 index 0000000..207f7a1 --- /dev/null +++ b/blueprints/auth.py @@ -0,0 +1,60 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user +from werkzeug.security import check_password_hash, generate_password_hash +from core.db import get_db_connection + +auth_bp = Blueprint('auth', __name__) + +login_manager = LoginManager() +login_manager.login_view = 'auth.login' + +class User(UserMixin): + def __init__(self, id, username): + self.id = id + self.username = username + +@login_manager.user_loader +def load_user(user_id): + with get_db_connection() as conn: + user = conn.execute('SELECT id, username FROM users WHERE id = ?', (user_id,)).fetchone() + return User(user[0], user[1]) if user else None + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + user_in = request.form.get('username') + pass_in = request.form.get('password') + with get_db_connection() as conn: + user = conn.execute('SELECT * FROM users WHERE username = ?', (user_in,)).fetchone() + if user and check_password_hash(user[2], pass_in): + login_user(User(user[0], user[1])) + return redirect(url_for('inventory.inventory')) + flash('Invalid credentials.') + return render_template('login.html') + +@auth_bp.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('auth.login')) + +@auth_bp.route('/settings/update', methods=['POST']) +@login_required +def update_settings(): + new_password = request.form.get('password') + profile_pic = request.form.get('profile_pic') + + with get_db_connection() as conn: + if new_password and len(new_password) > 0: + hashed_pw = generate_password_hash(new_password) + conn.execute('UPDATE users SET password = ? WHERE id = ?', (hashed_pw, current_user.id)) + + if profile_pic: + conn.execute('UPDATE users SET profile_pic = ? WHERE id = ?', (profile_pic, current_user.id)) + conn.commit() + + flash('Configuración actualizada') + return redirect(request.referrer) + +def init_login_manager(app): + login_manager.init_app(app) \ No newline at end of file diff --git a/blueprints/finance.py b/blueprints/finance.py new file mode 100644 index 0000000..3bdefc0 --- /dev/null +++ b/blueprints/finance.py @@ -0,0 +1,398 @@ +from flask import Blueprint, render_template, request, jsonify +from flask_login import login_required, current_user +from core.db import get_db_connection + +finance_bp = Blueprint('finance', __name__) + +@finance_bp.route('/dicom') +@login_required +def dicom(): + with get_db_connection() as conn: + debtors = conn.execute('''SELECT d.id, d.name, d.contact_info, + COALESCE(SUM(t.total - t.amount_paid), 0) as total_balance + FROM debtors d + LEFT JOIN debtor_tickets t ON d.id = t.debtor_id + GROUP BY d.id + ORDER BY total_balance DESC''').fetchall() + return render_template('dicom.html', active_page='dicom', user=current_user, debtors=debtors) + +@finance_bp.route('/api/dicom/debtor/', methods=['GET']) +@login_required +def get_debtor_details(debtor_id): + with get_db_connection() as conn: + # Get tickets with their remaining balance + tickets = conn.execute('''SELECT id, date, total, amount_paid, status, + total - amount_paid as remaining + FROM debtor_tickets + WHERE debtor_id = ? + ORDER BY date DESC''', (debtor_id,)).fetchall() + + # Get items for each ticket + result = [] + for t in tickets: + items = conn.execute('''SELECT id, barcode, name, price, quantity, subtotal + FROM debtor_ticket_items + WHERE ticket_id = ?''', (t[0],)).fetchall() + result.append({ + "id": t[0], + "date": t[1], + "total": t[2], + "amount_paid": t[3], + "status": t[4], + "remaining": t[5], + "items": [{"id": i[0], "barcode": i[1], "name": i[2], "price": i[3], "qty": i[4], "subtotal": i[5]} for i in items] + }) + + return jsonify(result) + +@finance_bp.route('/api/dicom/debtor//pay', methods=['POST']) +@login_required +def pay_debtor_ticket(debtor_id): + data = request.get_json() + ticket_id = data.get('ticket_id') + amount = float(data.get('amount', 0)) + + if not ticket_id or amount <= 0: + return jsonify({"error": "Monto inválido"}), 400 + + with get_db_connection() as conn: + conn.execute('''UPDATE debtor_tickets + SET amount_paid = amount_paid + ?, + status = CASE WHEN (total - amount_paid - ?) <= 0 THEN 'paid' ELSE 'partial' END + WHERE id = ?''', (amount, amount, ticket_id)) + + # Update status based on final values + conn.execute('''UPDATE debtor_tickets + SET status = CASE + WHEN total - amount_paid <= 0 THEN 'paid' + WHEN amount_paid > 0 THEN 'partial' + ELSE 'unpaid' + END + WHERE id = ?''', (ticket_id,)) + conn.commit() + + return jsonify({"status": "success"}) + +@finance_bp.route('/api/dicom/pay', methods=['POST']) +@login_required +def dicom_pay(): + data = request.get_json() + ticket_id = data.get('ticket_id') + amount = float(data.get('amount', 0)) + payment_method = data.get('payment_method', 'efectivo') + + if not ticket_id or amount <= 0: + return jsonify({"error": "Monto inválido"}), 400 + + try: + with get_db_connection() as conn: + cur = conn.cursor() + + # Update the debtor ticket payment + cur.execute('UPDATE debtor_tickets SET amount_paid = amount_paid + ? WHERE id = ?', (amount, ticket_id)) + + # Update status based on final values + cur.execute('''UPDATE debtor_tickets + SET status = CASE + WHEN total - amount_paid <= 0 THEN 'paid' + WHEN amount_paid > 0 THEN 'partial' + ELSE 'unpaid' + END + WHERE id = ?''', (ticket_id,)) + + # Insert into sales table to track daily revenue + cur.execute('''INSERT INTO sales (date, total, payment_method) + VALUES (CURRENT_TIMESTAMP, ?, ?)''', (amount, payment_method)) + + conn.commit() + + return jsonify({"status": "success", "amount": amount}), 200 + + except Exception as e: + print(f"Dicom Pay Error: {e}") + return jsonify({"error": str(e)}), 500 + +@finance_bp.route('/api/dicom/update', methods=['POST']) +@login_required +def update_dicom(): + data = request.get_json() + name = data.get('name', '').strip() + amount = float(data.get('amount', 0)) + notes = data.get('notes', '') + image_url = data.get('image_url', '') + action = data.get('action') + + if not name or amount <= 0: + return jsonify({"error": "Nombre y monto válidos son requeridos"}), 400 + + if action == 'add': + amount = -amount + + with get_db_connection() as conn: + cur = conn.cursor() + cur.execute('''INSERT INTO dicom (name, amount, notes, image_url, last_updated) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(name) DO UPDATE SET + amount = amount + excluded.amount, + notes = excluded.notes, + image_url = CASE WHEN excluded.image_url != "" THEN excluded.image_url ELSE dicom.image_url END, + last_updated = CURRENT_TIMESTAMP''', (name, amount, notes, image_url)) + conn.commit() + return jsonify({"status": "success"}), 200 + +@finance_bp.route('/api/dicom/', methods=['DELETE']) +@login_required +def delete_dicom(debtor_id): + try: + with get_db_connection() as conn: + conn.execute('DELETE FROM dicom WHERE id = ?', (debtor_id,)) + conn.commit() + return jsonify({"status": "success"}), 200 + except Exception as e: + return jsonify({"error": str(e)}), 500 + +@finance_bp.route('/gastos') +@login_required +def gastos(): + from datetime import datetime + selected_month = request.args.get('month', datetime.now().strftime('%Y-%m')) + + with get_db_connection() as conn: + cur = conn.cursor() + + cur.execute(''' + CREATE TABLE IF NOT EXISTS expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + description TEXT NOT NULL, + amount INTEGER NOT NULL + ) + ''') + + sales_total = cur.execute("SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = ?", (selected_month,)).fetchone()[0] or 0 + expenses_total = cur.execute("SELECT SUM(amount) FROM expenses WHERE strftime('%Y-%m', date, 'localtime') = ?", (selected_month,)).fetchone()[0] or 0 + + expenses_list = cur.execute("SELECT id, date, description, amount FROM expenses WHERE strftime('%Y-%m', date, 'localtime') = ? ORDER BY date DESC", (selected_month,)).fetchall() + + return render_template('gastos.html', + active_page='gastos', + user=current_user, + sales_total=sales_total, + expenses_total=expenses_total, + net_profit=sales_total - expenses_total, + expenses=expenses_list, + selected_month=selected_month) + +@finance_bp.route('/api/gastos', methods=['POST']) +@login_required +def add_gasto(): + data = request.get_json() + desc = data.get('description') + amount = data.get('amount') + + if not desc or not amount: + return jsonify({"error": "Faltan datos"}), 400 + + with get_db_connection() as conn: + cur = conn.cursor() + cur.execute("INSERT INTO expenses (description, amount) VALUES (?, ?)", (desc, int(amount))) + conn.commit() + return jsonify({"success": True}) + +@finance_bp.route('/api/gastos/', methods=['DELETE']) +@login_required +def delete_gasto(gasto_id): + with get_db_connection() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM expenses WHERE id = ?", (gasto_id,)) + conn.commit() + return jsonify({"success": True}) + +@finance_bp.route('/api/dicom/debtors', methods=['GET']) +@login_required +def get_debtors(): + try: + with get_db_connection() as conn: + # Check if table exists + cur = conn.cursor() + cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='debtors'") + if not cur.fetchone(): + print("Debtors table does not exist!") + return jsonify([]) + + cur.execute('SELECT id, name, contact_info FROM debtors ORDER BY name') + debtors = cur.fetchall() + print(f"Found {len(debtors)} debtors:", debtors) + + return jsonify([{"id": d[0], "name": d[1], "contact_info": d[2]} for d in debtors]) + except Exception as e: + print(f"Error getting debtors: {e}") + import traceback + traceback.print_exc() + return jsonify([]) + +@finance_bp.route('/api/dicom/checkout', methods=['POST']) +@login_required +def dicom_checkout(): + try: + data = request.get_json() + cart = data.get('cart', []) + debtor_name = data.get('debtor_name', '').strip() + contact_info = data.get('contact_info', '').strip() + initial_payment = data.get('initial_payment', 0) or 0 + + if not cart: + return jsonify({"error": "Carrito vacío"}), 400 + if not debtor_name: + return jsonify({"error": "Nombre del deudor requerido"}), 400 + + total = sum(item.get('subtotal', 0) for item in cart) + + with get_db_connection() as conn: + cur = conn.cursor() + + # Upsert debtor + cur.execute('''INSERT INTO debtors (name, contact_info) + VALUES (?, ?) + ON CONFLICT(name) DO UPDATE SET + contact_info = excluded.contact_info''', + (debtor_name, contact_info)) + + # Get debtor ID + debtor_id = cur.execute('SELECT id FROM debtors WHERE name = ?', (debtor_name,)).fetchone()[0] + + # Insert debtor ticket + status = 'partial' if initial_payment > 0 else 'unpaid' + cur.execute('''INSERT INTO debtor_tickets (debtor_id, total, amount_paid, status) + VALUES (?, ?, ?, ?)''', + (debtor_id, total, initial_payment, status)) + ticket_id = cur.lastrowid + + # Insert ticket items and deduct stock + for item in cart: + cur.execute('''INSERT INTO debtor_ticket_items + (ticket_id, barcode, name, price, quantity, subtotal) + VALUES (?, ?, ?, ?, ?, ?)''', + (ticket_id, item.get('barcode', ''), item.get('name'), + item.get('price'), item.get('qty'), item.get('subtotal'))) + + # Deduct stock (skip for manual products) + if item.get('barcode') and not item.get('barcode', '').startswith(('MANUAL-', 'VARIOS-', 'RAPIDA-')): + cur.execute('UPDATE products SET stock = stock - ? WHERE barcode = ?', + (item.get('qty'), item.get('barcode'))) + + conn.commit() + + return jsonify({"status": "success", "ticket_id": ticket_id, "debtor": debtor_name}), 200 + + except Exception as e: + print(f"Dicom Checkout Error: {e}") + return jsonify({"error": str(e)}), 500 + +@finance_bp.route('/api/dicom/debtor/', methods=['DELETE']) +@login_required +def delete_debtor(debtor_id): + try: + with get_db_connection() as conn: + cur = conn.cursor() + # Delete items first + cur.execute('DELETE FROM debtor_ticket_items WHERE ticket_id IN (SELECT id FROM debtor_tickets WHERE debtor_id = ?)', (debtor_id,)) + # Delete tickets + cur.execute('DELETE FROM debtor_tickets WHERE debtor_id = ?', (debtor_id,)) + # Delete debtor + cur.execute('DELETE FROM debtors WHERE id = ?', (debtor_id,)) + conn.commit() + return jsonify({"status": "success"}), 200 + except Exception as e: + print(f"Delete Debtor Error: {e}") + return jsonify({"error": str(e)}), 500 + +@finance_bp.route('/api/dicom/ticket/', methods=['DELETE']) +@login_required +def delete_ticket(ticket_id): + try: + with get_db_connection() as conn: + cur = conn.cursor() + # Delete items first + cur.execute('DELETE FROM debtor_ticket_items WHERE ticket_id = ?', (ticket_id,)) + # Delete ticket + cur.execute('DELETE FROM debtor_tickets WHERE id = ?', (ticket_id,)) + conn.commit() + return jsonify({"status": "success"}), 200 + except Exception as e: + print(f"Delete Ticket Error: {e}") + return jsonify({"error": str(e)}), 500 + +@finance_bp.route('/api/dicom/item/', methods=['DELETE']) +@login_required +def delete_item(item_id): + try: + with get_db_connection() as conn: + cur = conn.cursor() + # Get item info to update ticket total + item = cur.execute('SELECT ticket_id, subtotal FROM debtor_ticket_items WHERE id = ?', (item_id,)).fetchone() + if not item: + return jsonify({"error": "Item no encontrado"}), 404 + + ticket_id, item_subtotal = item + + # Delete item + cur.execute('DELETE FROM debtor_ticket_items WHERE id = ?', (item_id,)) + + # Check if ticket has remaining items + remaining_items = cur.execute('SELECT COUNT(*) FROM debtor_ticket_items WHERE ticket_id = ?', (ticket_id,)).fetchone()[0] + + if remaining_items == 0: + # Delete ticket if no items left + cur.execute('DELETE FROM debtor_tickets WHERE id = ?', (ticket_id,)) + return jsonify({"status": "success", "ticket_deleted": True}), 200 + + # Update ticket total + cur.execute('UPDATE debtor_tickets SET total = total - ? WHERE id = ?', (item_subtotal, ticket_id)) + + conn.commit() + return jsonify({"status": "success", "ticket_deleted": False}), 200 + except Exception as e: + print(f"Delete Item Error: {e}") + return jsonify({"error": str(e)}), 500 + +@finance_bp.route('/api/dicom/debtor//pay-all', methods=['POST']) +@login_required +def pay_all_debtor(debtor_id): + try: + data = request.get_json() + amount = float(data.get('amount', 0)) + payment_method = data.get('payment_method', 'efectivo') + + if amount <= 0: + return jsonify({"error": "Monto inválido"}), 400 + + with get_db_connection() as conn: + cur = conn.cursor() + + # Get all unpaid/partial tickets for this debtor + tickets = cur.execute('''SELECT id, total, amount_paid, total - amount_paid as remaining + FROM debtor_tickets + WHERE debtor_id = ? AND status != 'paid' ''', (debtor_id,)).fetchall() + + for ticket in tickets: + ticket_id = ticket[0] + remaining = ticket[3] + + if remaining > 0: + # Pay remaining amount + cur.execute('UPDATE debtor_tickets SET amount_paid = amount_paid + ? WHERE id = ?', + (remaining, ticket_id)) + # Update status + cur.execute('''UPDATE debtor_tickets SET status = 'paid' WHERE id = ?''', (ticket_id,)) + # Record sale + cur.execute('''INSERT INTO sales (date, total, payment_method) + VALUES (CURRENT_TIMESTAMP, ?, ?)''', (remaining, payment_method)) + + conn.commit() + + return jsonify({"status": "success"}), 200 + + except Exception as e: + print(f"Pay All Debtor Error: {e}") + return jsonify({"error": str(e)}), 500 \ No newline at end of file diff --git a/blueprints/inventory.py b/blueprints/inventory.py new file mode 100644 index 0000000..c40c619 --- /dev/null +++ b/blueprints/inventory.py @@ -0,0 +1,112 @@ +import os +from flask import Blueprint, render_template, request, redirect, url_for, jsonify, current_app +from flask_login import login_required, current_user +from core.db import get_db_connection +from core.utils import download_image +from core.events import socketio + +inventory_bp = Blueprint('inventory', __name__) + +@inventory_bp.route('/inventory') +@login_required +def inventory(): + with get_db_connection() as conn: + products = conn.execute('SELECT * FROM products').fetchall() + return render_template('inventory.html', active_page='inventory', products=products, user=current_user) + +@inventory_bp.route("/upsert", methods=["POST"]) +@login_required +def upsert(): + d = request.form + barcode = d['barcode'] + + price_str = d.get('price', '0') + stock_str = d.get('stock', '0') + + try: + price = float(price_str) if price_str else 0.0 + stock = float(stock_str) if stock_str else 0.0 + except (ValueError, TypeError): + price = 0.0 + stock = 0.0 + + name = d.get('name', '') + image_url = d.get('image_url', '') + unit_type = d.get('unit_type', 'unit') + + cache_dir = current_app.config['CACHE_DIR'] + final_image_path = download_image(image_url, barcode, cache_dir) + + with get_db_connection() as conn: + conn.execute('''INSERT INTO products (barcode, name, price, image_url, stock, unit_type) + VALUES (?,?,?,?,?,?) + ON CONFLICT(barcode) DO UPDATE SET + name=excluded.name, + price=excluded.price, + image_url=excluded.image_url, + stock=excluded.stock, + unit_type=excluded.unit_type''', + (barcode, name, price, final_image_path, stock, unit_type)) + conn.commit() + return redirect(url_for('inventory.inventory')) + +@inventory_bp.route('/delete/', methods=['POST']) +@login_required +def delete(barcode): + cache_dir = current_app.config['CACHE_DIR'] + + with get_db_connection() as conn: + conn.execute('DELETE FROM products WHERE barcode = ?', (barcode,)) + conn.commit() + + img_p = os.path.join(cache_dir, f"{barcode}.jpg") + if os.path.exists(img_p): os.remove(img_p) + if socketio: + socketio.emit('product_deleted', {"barcode": barcode}) + return redirect(url_for('inventory.inventory')) + +@inventory_bp.route('/bulk_price_update', methods=['POST']) +@login_required +def bulk_price_update(): + data = request.get_json() + barcodes = data.get('barcodes', []) + new_price = data.get('new_price') + + if not barcodes or new_price is None: + return jsonify({"error": "Missing data"}), 400 + + try: + with get_db_connection() as conn: + params = [(float(new_price), b) for b in barcodes] + conn.executemany('UPDATE products SET price = ? WHERE barcode = ?', params) + conn.commit() + return jsonify({"status": "success"}), 200 + except Exception as e: + print(f"Bulk update failed: {e}") + return jsonify({"error": str(e)}), 500 + +@inventory_bp.route('/bulk_delete', methods=['POST']) +@login_required +def bulk_delete(): + cache_dir = current_app.config['CACHE_DIR'] + + data = request.get_json() + barcodes = data.get('barcodes', []) + + if not barcodes: + return jsonify({"error": "No barcodes provided"}), 400 + + try: + with get_db_connection() as conn: + conn.execute(f'DELETE FROM products WHERE barcode IN ({",".join(["?"]*len(barcodes))})', barcodes) + conn.commit() + + for barcode in barcodes: + img_p = os.path.join(cache_dir, f"{barcode}.jpg") + if os.path.exists(img_p): + os.remove(img_p) + + return jsonify({"status": "success"}), 200 + except Exception as e: + print(f"Bulk delete failed: {e}") + return jsonify({"error": str(e)}), 500 \ No newline at end of file diff --git a/blueprints/pos.py b/blueprints/pos.py new file mode 100644 index 0000000..ac9593f --- /dev/null +++ b/blueprints/pos.py @@ -0,0 +1,115 @@ +import os +import time +from flask import Blueprint, render_template, request, jsonify, current_app +from flask_login import login_required, current_user +from core.db import get_db_connection +from core.openfood import fetch_from_openfoodfacts +from core.events import socketio + +pos_bp = Blueprint('pos', __name__) + +@pos_bp.route('/checkout') +@login_required +def checkout(): + with get_db_connection() as conn: + products = conn.execute('SELECT barcode, name, price, image_url, stock, unit_type FROM products').fetchall() + return render_template("checkout.html", active_page='checkout', user=current_user, products=products) + +@pos_bp.route('/scan', methods=['GET']) +def scan(): + barcode = request.args.get('content', '').replace('{content}', '') + if not barcode: + return jsonify({"status": "error", "message": "empty barcode"}), 400 + + with get_db_connection() as conn: + p = conn.execute('SELECT barcode, name, price, image_url, stock, unit_type FROM products WHERE barcode = ?', (barcode,)).fetchone() + + if p: + barcode_val, name, price, image_path, stock, unit_type = p + + if image_path and image_path.startswith('/static/'): + clean_path = image_path.split('?')[0].lstrip('/') + if not os.path.exists(clean_path): + cache_dir = current_app.config['CACHE_DIR'] + ext_data = fetch_from_openfoodfacts(barcode_val, cache_dir) + if ext_data and ext_data.get('image'): + image_path = ext_data['image'] + with get_db_connection() as conn: + conn.execute('UPDATE products SET image_url = ? WHERE barcode = ?', (image_path, barcode_val)) + conn.commit() + + product_data = { + "barcode": barcode_val, + "name": name, + "price": int(price), + "image": image_path, + "stock": stock, + "unit_type": unit_type + } + + socketio.emit('new_scan', product_data) + return jsonify({"status": "ok", "data": product_data}), 200 + + cache_dir = current_app.config['CACHE_DIR'] + ext = fetch_from_openfoodfacts(barcode, cache_dir) + if ext: + external_data = { + "barcode": barcode, + "name": ext['name'], + "image": ext['image'], + "source": "openfoodfacts" + } + socketio.emit('scan_error', external_data) + return jsonify({"status": "not_found", "data": external_data}), 404 + + socketio.emit('scan_error', {"barcode": barcode}) + return jsonify({"status": "not_found", "data": {"barcode": barcode}}), 404 + +@pos_bp.route('/api/checkout', methods=['POST']) +@login_required +def process_checkout(): + try: + data = request.get_json() + cart = data.get('cart', []) + payment_method = data.get('payment_method', 'efectivo') + + if not cart: + return jsonify({"error": "Cart is empty"}), 400 + + total = sum(item.get('subtotal', 0) for item in cart) + + with get_db_connection() as conn: + cur = conn.cursor() + + cur.execute('INSERT INTO sales (date, total, payment_method) VALUES (CURRENT_TIMESTAMP, ?, ?)', (total, payment_method)) + sale_id = cur.lastrowid + + for item in cart: + cur.execute('''INSERT INTO sale_items (sale_id, barcode, name, price, quantity, subtotal) + VALUES (?, ?, ?, ?, ?, ?)''', + (sale_id, item['barcode'], item['name'], item['price'], item['qty'], item['subtotal'])) + + cur.execute('UPDATE products SET stock = stock - ? WHERE barcode = ?', (item['qty'], item['barcode'])) + + conn.commit() + + return jsonify({"status": "success", "sale_id": sale_id}), 200 + + except Exception as e: + print(f"Checkout Error: {e}") + return jsonify({"error": str(e)}), 500 + +@pos_bp.route('/api/scale/weight', methods=['POST']) +def update_scale_weight(): + data = request.get_json() + + weight_grams = data.get('weight', 0) + weight_kg = round(weight_grams / 1000, 3) + + socketio.emit('scale_update', { + "grams": weight_grams, + "kilograms": weight_kg, + "timestamp": time.time() + }) + + return jsonify({"status": "received"}), 200 \ No newline at end of file diff --git a/blueprints/sales.py b/blueprints/sales.py new file mode 100644 index 0000000..09466cd --- /dev/null +++ b/blueprints/sales.py @@ -0,0 +1,147 @@ +from flask import Blueprint, render_template, request, jsonify, send_file, current_app +from flask_login import login_required, current_user +from core.db import get_db_connection +import io +import zipfile +from datetime import datetime +import os + +sales_bp = Blueprint('sales', __name__) + +@sales_bp.route('/export/db') +@login_required +def export_db(): + db_file = current_app.config['DB_FILE'] + if os.path.exists(db_file): + return send_file(db_file, as_attachment=True, download_name=f"SekiPOS_Backup_{datetime.now().strftime('%Y%m%d')}.db", mimetype='application/x-sqlite3') + return "Error: Database file not found", 404 + +@sales_bp.route('/export/images') +@login_required +def export_images(): + cache_dir = current_app.config['CACHE_DIR'] + if not os.path.exists(cache_dir) or not os.listdir(cache_dir): + return "No images found to export", 404 + + memory_file = io.BytesIO() + + with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf: + for root, dirs, files in os.walk(cache_dir): + for file in files: + file_path = os.path.join(root, file) + zf.write(file_path, arcname=file) + + memory_file.seek(0) + + return send_file( + memory_file, + mimetype='application/zip', + as_attachment=True, + download_name=f"SekiPOS_Images_{datetime.now().strftime('%Y%m%d')}.zip" + ) + +@sales_bp.route('/sales') +@login_required +def sales(): + selected_date = request.args.get('date') + payment_method = request.args.get('payment_method') + page = request.args.get('page', 1, type=int) + per_page = 100 + + with get_db_connection() as conn: + cur = conn.cursor() + + target_date = selected_date if selected_date else cur.execute("SELECT date('now', 'localtime')").fetchone()[0] + + daily_query = "SELECT SUM(total) FROM sales WHERE date(date, 'localtime') = ?" + week_query = "SELECT SUM(total) FROM sales WHERE date(date, 'localtime') >= date('now', 'localtime', '-7 days')" + month_query = "SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = strftime('%Y-%m', 'now', 'localtime')" + + daily_params = [target_date] + week_params = [] + month_params = [] + + if payment_method: + daily_query += " AND payment_method = ?" + week_query += " AND payment_method = ?" + month_query += " AND payment_method = ?" + + daily_params.append(payment_method) + week_params.append(payment_method) + month_params.append(payment_method) + + stats = { + "daily": cur.execute(daily_query, tuple(daily_params)).fetchone()[0] or 0, + "week": cur.execute(week_query, tuple(week_params)).fetchone()[0] or 0, + "month": cur.execute(month_query, tuple(month_params)).fetchone()[0] or 0 + } + + base_query = "FROM sales WHERE 1=1" + params = [] + + if selected_date: + base_query += " AND date(date, 'localtime') = ?" + params.append(selected_date) + if payment_method: + base_query += " AND payment_method = ?" + params.append(payment_method) + + stats_query = f"SELECT COUNT(*), SUM(total) {base_query}" + count_res, sum_res = cur.execute(stats_query, tuple(params)).fetchone() + + total_count = count_res or 0 + total_sum = sum_res or 0 + total_pages = (total_count + per_page - 1) // per_page + + filtered_stats = { + "total": total_sum, + "count": total_count + } + + offset = (page - 1) * per_page + data_query = f"SELECT id, date, total, payment_method {base_query} ORDER BY date DESC LIMIT ? OFFSET ?" + + sales_data = cur.execute(data_query, tuple(params) + (per_page, offset)).fetchall() + + return render_template('sales.html', + active_page='sales', + user=current_user, + sales=sales_data, + stats=stats, + filtered_stats=filtered_stats, + selected_date=selected_date, + selected_payment=payment_method, + current_page=page, + total_pages=total_pages) + +@sales_bp.route('/api/sale/') +@login_required +def get_sale_details(sale_id): + with get_db_connection() as conn: + items = conn.execute('SELECT barcode, name, price, quantity, subtotal FROM sale_items WHERE sale_id = ?', (sale_id,)).fetchall() + + item_list = [{"barcode": i[0], "name": i[1], "price": i[2], "qty": i[3], "subtotal": i[4]} for i in items] + return jsonify(item_list), 200 + +@sales_bp.route('/api/sale/', methods=['DELETE']) +@login_required +def reverse_sale(sale_id): + try: + with get_db_connection() as conn: + cur = conn.cursor() + + items = cur.execute('SELECT barcode, quantity FROM sale_items WHERE sale_id = ?', (sale_id,)).fetchall() + + for barcode, qty in items: + cur.execute('UPDATE products SET stock = stock + ? WHERE barcode = ?', (qty, barcode)) + + cur.execute('DELETE FROM sale_items WHERE sale_id = ?', (sale_id,)) + cur.execute('DELETE FROM sales WHERE id = ?', (sale_id,)) + + conn.commit() + + return jsonify({"status": "success"}), 200 + + except Exception as e: + print(f"Reverse Sale Error: {e}") + return jsonify({"error": str(e)}), 500 \ No newline at end of file diff --git a/core/__pycache__/.gitignore b/core/__pycache__/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/core/__pycache__/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/core/db.py b/core/db.py new file mode 100644 index 0000000..e1425ad --- /dev/null +++ b/core/db.py @@ -0,0 +1,79 @@ +import sqlite3 + +_db_file = None + +def init_db(db_file): + global _db_file + _db_file = db_file + + with get_db_connection() as conn: + conn.execute('''CREATE TABLE IF NOT EXISTS users + (id INTEGER PRIMARY KEY, username TEXT UNIQUE, password TEXT)''') + + conn.execute('''CREATE TABLE IF NOT EXISTS products + (barcode TEXT PRIMARY KEY, + name TEXT, + price REAL, + image_url TEXT, + stock REAL DEFAULT 0, + unit_type TEXT DEFAULT 'unit')''') + + conn.execute('''CREATE TABLE IF NOT EXISTS sales + (id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT DEFAULT CURRENT_TIMESTAMP, + total REAL, + payment_method TEXT)''') + + conn.execute('''CREATE TABLE IF NOT EXISTS sale_items + (id INTEGER PRIMARY KEY AUTOINCREMENT, + sale_id INTEGER, + barcode TEXT, + name TEXT, + price REAL, + quantity REAL, + subtotal REAL, + FOREIGN KEY(sale_id) REFERENCES sales(id))''') + + conn.execute('''CREATE TABLE IF NOT EXISTS dicom + (id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE, + amount REAL DEFAULT 0, + notes TEXT, + image_url TEXT, + last_updated TEXT DEFAULT CURRENT_TIMESTAMP)''') + + conn.execute('''CREATE TABLE IF NOT EXISTS debtors + (id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE, + contact_info TEXT)''') + + conn.execute('''CREATE TABLE IF NOT EXISTS debtor_tickets + (id INTEGER PRIMARY KEY AUTOINCREMENT, + debtor_id INTEGER NOT NULL, + date TEXT DEFAULT CURRENT_TIMESTAMP, + total REAL NOT NULL, + amount_paid REAL DEFAULT 0, + status TEXT DEFAULT 'unpaid', + FOREIGN KEY(debtor_id) REFERENCES debtors(id) ON DELETE CASCADE)''') + + conn.execute('''CREATE TABLE IF NOT EXISTS debtor_ticket_items + (id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL, + barcode TEXT, + name TEXT, + price REAL, + quantity REAL, + subtotal REAL, + FOREIGN KEY(ticket_id) REFERENCES debtor_tickets(id) ON DELETE CASCADE)''') + + user = conn.execute('SELECT * FROM users WHERE username = ?', ('admin',)).fetchone() + if not user: + from werkzeug.security import generate_password_hash + hashed_pw = generate_password_hash('choripan1234') + conn.execute('INSERT INTO users (username, password) VALUES (?, ?)', ('admin', hashed_pw)) + conn.commit() + +def get_db_connection(): + if _db_file is None: + raise RuntimeError("Database not initialized. Call init_db(db_file) first.") + return sqlite3.connect(_db_file) \ No newline at end of file diff --git a/core/db/.gitignore b/core/db/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/core/db/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/core/events.py b/core/events.py new file mode 100644 index 0000000..fc02806 --- /dev/null +++ b/core/events.py @@ -0,0 +1,3 @@ +from flask_socketio import SocketIO + +socketio = SocketIO() \ No newline at end of file diff --git a/core/openfood.py b/core/openfood.py new file mode 100644 index 0000000..dc55909 --- /dev/null +++ b/core/openfood.py @@ -0,0 +1,23 @@ +import requests +from core.utils import download_image + +def fetch_from_openfoodfacts(barcode, cache_dir): + url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json" + try: + headers = {'User-Agent': 'SekiPOS/1.0'} + resp = requests.get(url, headers=headers, timeout=5).json() + + if resp.get('status') == 1: + p = resp.get('product', {}) + name = p.get('product_name_es') or p.get('product_name') or p.get('brands', 'Unknown') + imgs = p.get('selected_images', {}).get('front', {}).get('display', {}) + img_url = imgs.get('es') or imgs.get('en') or p.get('image_url', '') + + if img_url: + local_img = download_image(img_url, barcode, cache_dir) + return {"name": name, "image": local_img} + return {"name": name, "image": None} + + except Exception as e: + print(f"API Error: {e}") + return None \ No newline at end of file diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..d5982bc --- /dev/null +++ b/core/utils.py @@ -0,0 +1,46 @@ +import os +import sys +import mimetypes +import requests + +def get_bundled_path(relative_path): + """Path for read-only files packed inside the .exe (templates, static)""" + if getattr(sys, 'frozen', False): + base_path = sys._MEIPASS + else: + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + base_path = project_root + return os.path.join(base_path, relative_path) + +def get_persistent_path(relative_path): + """Path for read/write files that must survive reboots (db, cache)""" + if getattr(sys, 'frozen', False): + base_path = os.path.dirname(sys.executable) + else: + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + base_path = project_root + return os.path.join(base_path, relative_path) + +def download_image(url, barcode, cache_dir): + if not url or not url.startswith('http'): + return url + + try: + headers = {'User-Agent': 'SekiPOS/1.2'} + with requests.get(url, headers=headers, stream=True, timeout=5) as r: + r.raise_for_status() + + content_type = r.headers.get('content-type') + ext = mimetypes.guess_extension(content_type) or '.jpg' + + local_filename = f"{barcode}{ext}" + local_path = os.path.join(cache_dir, local_filename) + + with open(local_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + + return f"/static/cache/{local_filename}" + except Exception as e: + print(f"Download failed: {e}") + return url \ No newline at end of file diff --git a/static/style.css b/static/style.css index 7714010..d185b3c 100644 --- a/static/style.css +++ b/static/style.css @@ -12,6 +12,18 @@ --danger: #ed4245; } + html, body { + background-color: var(--bg); + transition: background-color 0.15s ease, color 0.15s ease; + } + + /* Prevent theme flash on initial load */ + html[data-theme="dark"] body, + html[data-theme="dark"] { + background-color: #36393f; + color: #dcddde; + } + [data-theme="dark"] { --bg: #36393f; --card-bg: #2f3136; diff --git a/templates/checkout.html b/templates/checkout.html index bd4e0bb..bb9ec64 100644 --- a/templates/checkout.html +++ b/templates/checkout.html @@ -312,7 +312,104 @@ + + + + + +
+ +
+ +
+
+
@@ -359,11 +456,44 @@
+ +
+
+
Comanda
+ Modo Comida +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
-

Último Escaneado

- product -
Esperando scan...
- +
+

Último Escaneado

+ product +
Esperando scan...
+ +

TOTAL

@@ -379,6 +509,9 @@ +
@@ -390,7 +523,12 @@ /* ========================================= 1. GLOBAL STATE & FORMATTERS ========================================= */ - let cart = []; + let cart = JSON.parse(localStorage.getItem('seki_cart') || '[]'); + + function saveCart() { + localStorage.setItem('seki_cart', JSON.stringify(cart)); + } + let pendingProduct = null; let missingProductData = null; let tempBarcode = null; @@ -407,6 +545,18 @@ // Fetch the pinned items from local storage let pinnedBarcodes = JSON.parse(localStorage.getItem('seki_pinned_products')) || []; + // Restaurant Mode (Modo Comida) initialization + const modoComida = localStorage.getItem('modo_comida') === 'true'; + if (modoComida) { + document.getElementById('restaurant-panel').classList.remove('d-none'); + } + + // Last Scanned panel toggle + const showLastScanned = localStorage.getItem('seki_last_scanned') !== 'false'; + if (!showLastScanned) { + document.getElementById('last-scanned-content').classList.add('d-none'); + } + let socket = io(); const clp = new Intl.NumberFormat('es-CL', { @@ -491,6 +641,16 @@ document.getElementById('grand-total').innerText = clp.format(total); saveCart(); + + // Enable/disable Mandar a Dicom button + const dicomBtn = document.getElementById('btn-mandar-dicom'); + if (cart.length === 0) { + dicomBtn.classList.add('disabled'); + dicomBtn.setAttribute('disabled', 'true'); + } else { + dicomBtn.classList.remove('disabled'); + dicomBtn.removeAttribute('disabled'); + } } function addToCart(product, qty) { @@ -499,8 +659,9 @@ cart[existingIndex].qty += qty; cart[existingIndex].subtotal = calculateSubtotal(cart[existingIndex].price, cart[existingIndex].qty); } else { - cart.push({ ...product, qty, subtotal: calculateSubtotal(product.price, qty) }); + cart.push({ ...product, qty, subtotal: calculateSubtotal(product.price, qty), printed_qty: 0 }); } + saveCart(); renderCart(); } @@ -511,6 +672,7 @@ removeItem(index, cart[index].name); } else { cart[index].subtotal = calculateSubtotal(cart[index].price, cart[index].qty); + saveCart(); renderCart(); } } @@ -520,6 +682,7 @@ if (isNaN(newQty) || newQty <= 0) return; cart[index].qty = newQty; cart[index].subtotal = calculateSubtotal(cart[index].price, cart[index].qty); + saveCart(); renderCart(); } @@ -532,6 +695,7 @@ function executeRemoveItem() { if (itemIndexToRemove !== null) { cart.splice(itemIndexToRemove, 1); + saveCart(); renderCart(); bootstrap.Modal.getInstance(document.getElementById('removeConfirmModal')).hide(); itemIndexToRemove = null; @@ -545,6 +709,7 @@ function executeClearCart() { cart = []; + saveCart(); renderCart(); clearLastScanned(); bootstrap.Modal.getInstance(document.getElementById('clearCartModal')).hide(); @@ -558,7 +723,16 @@ function loadCart() { const saved = localStorage.getItem('seki_cart'); if (saved) { - try { cart = JSON.parse(saved); renderCart(); } + try { + cart = JSON.parse(saved); + // Ensure all items have printed_qty property + cart.forEach(item => { + if (typeof item.printed_qty === 'undefined') { + item.printed_qty = 0; + } + }); + renderCart(); + } catch (e) { console.error(e); cart = []; } } } @@ -714,7 +888,8 @@ price: priceInput, image: '', stock: 0, - unit: unitInput + unit: unitInput, + printed_qty: 0 }; if (unitInput === 'kg') { @@ -751,7 +926,8 @@ subtotal: price, image: '', stock: 0, - unit: 'unit' + unit: 'unit', + printed_qty: 0 }, 1); bootstrap.Modal.getInstance(document.getElementById('variosModal')).hide(); } @@ -918,6 +1094,7 @@ bootstrap.Modal.getOrCreateInstance(document.getElementById('successModal')).show(); cart = []; + saveCart(); renderCart(); clearLastScanned(); setTimeout(() => bootstrap.Modal.getInstance(document.getElementById('successModal')).hide(), 2000); @@ -960,7 +1137,7 @@ const quickCart = [{ barcode: `RAPIDA-${Date.now().toString().slice(-6)}`, - name: '* Varios', price: amount, qty: 1, subtotal: amount, unit: 'unit' + name: '* Varios', price: amount, qty: 1, subtotal: amount, unit: 'unit', printed_qty: 0 }]; try { @@ -992,6 +1169,262 @@ } } + /* ========================================= + RESTAURANT MODE (MODO COMIDA) + ========================================= */ + function printKitchenTicket() { + const clientName = document.getElementById('restaurant-client-name').value.trim(); + const orderType = document.getElementById('restaurant-order-type').value; + const notes = document.getElementById('restaurant-notes').value.trim(); + + // Calculate delta items (qty - printed_qty) + const deltaItems = cart.filter(item => { + const printed = item.printed_qty || 0; + return item.qty > printed; + }).map(item => ({ + ...item, + delta: item.qty - (item.printed_qty || 0) + })); + + if (deltaItems.length === 0) { + alert('No hay items nuevos para imprimir.'); + return; + } + + // Update printed_qty for delta items + cart.forEach(item => { + if (item.qty > (item.printed_qty || 0)) { + item.printed_qty = item.qty; + } + }); + + // Show reset button if anything has been printed + const hasPrinted = cart.some(item => (item.printed_qty || 0) > 0); + document.getElementById('btn-reset-comanda').classList.toggle('d-none', !hasPrinted); + + // Get comanda size setting + const comandaSize = localStorage.getItem('seki_comanda_size') || 'medium'; + const sizeMap = { + small: { header: '14px', title: '16px', item: '12px', qty: '12px' }, + medium: { header: '16px', title: '20px', item: '14px', qty: '14px' }, + large: { header: '18px', title: '24px', item: '18px', qty: '18px' }, + xlarge: { header: '20px', title: '28px', item: '22px', qty: '22px' } + }; + const sizes = sizeMap[comandaSize]; + + // Build the kitchen ticket HTML + const ticketHtml = ` +
+
+ COMANDA
+ ${clientName ? `Cliente: ${clientName}
` : ''} + ${orderType === 'servir' ? '🍽️ PARA SERVIR' : '🥡 PARA LLEVAR'} + ${notes ? `
Nota: ${notes}` : ''} +
+ + + + + + + + + ${deltaItems.map(item => ` + + + + + `).join('')} + +
CantProducto
${item.unit === 'kg' ? item.delta.toFixed(3) : item.delta}${item.name}
+
+ ${new Date().toLocaleString('es-CL')} +
+
+ `; + + // Create a temporary print window + const printWindow = window.open('', '_blank'); + printWindow.document.write(` + + + + Comanda - ${clientName || 'Sin nombre'} + + + ${ticketHtml} + + `); + printWindow.document.close(); + printWindow.focus(); + setTimeout(() => { + printWindow.print(); + printWindow.close(); + }, 250); + } + + function resetKitchenTicket() { + // Reset printed_qty for all items + cart.forEach(item => { + item.printed_qty = 0; + }); + + // Clear inputs + document.getElementById('restaurant-client-name').value = ''; + document.getElementById('restaurant-notes').value = ''; + document.getElementById('restaurant-order-type').value = 'servir'; + + // Hide reset button + document.getElementById('btn-reset-comanda').classList.add('d-none'); + } + + /* ========================================= + DICOM CHECKOUT + ========================================= */ + function openDicomModal() { + const total = cart.reduce((sum, item) => sum + item.subtotal, 0); + document.getElementById('dicom-total').innerText = clp.format(total); + document.getElementById('dicom-remaining').innerText = clp.format(total); + document.getElementById('dicom-debtor-name').value = ''; + document.getElementById('dicom-contact-info').value = ''; + document.getElementById('dicom-initial-payment').value = ''; + + bootstrap.Modal.getOrCreateInstance(document.getElementById('dicomModal')).show(); + + fetchDebtorsList(); + } + + // Format payment input with dots + function formatDicomPayment(input) { + let value = input.value.replace(/\./g, '').replace(/[^0-9]/g, ''); + const total = cart.reduce((sum, item) => sum + item.subtotal, 0); + + if (value && parseInt(value) > total) { + value = total.toString(); + } + + if (value) { + value = parseInt(value).toLocaleString('es-CL'); + } + input.value = value; + + // Update remaining + const rawValue = parseInt(input.value.replace(/\./g, '')) || 0; + const remaining = Math.max(0, total - rawValue); + document.getElementById('dicom-remaining').innerText = clp.format(remaining); + } + + // Update remaining when initial payment changes + document.getElementById('dicom-initial-payment').addEventListener('input', function() { + formatDicomPayment(this); + }); + + // Load when modal is fully shown + document.getElementById('dicomModal').addEventListener('shown.bs.modal', function() { + fetchDebtorsList(); + }); + + async function fetchDebtorsList() { + try { + const res = await fetch('/api/dicom/debtors', { credentials: 'same-origin' }); + + if (!res.ok) return; + + const debtors = await res.json(); + + const select = document.getElementById('dicom-debtor-select'); + const countLabel = document.getElementById('debtor-count'); + + select.innerHTML = ''; + + if (debtors && debtors.length > 0) { + for (let d of debtors) { + const opt = document.createElement('option'); + opt.value = d.name; + opt.textContent = d.contact_info ? `${d.name} - ${d.contact_info}` : d.name; + select.appendChild(opt); + } + countLabel.textContent = `${debtors.length} deudor(es)`; + } else { + countLabel.textContent = 'No hay deudores'; + } + } catch (e) { + console.error('Error loading debtors:', e); + } + } + + function showNewDebtorInput() { + document.getElementById('dicom-debtor-select').value = ''; + document.getElementById('dicom-debtor-name').style.display = 'block'; + document.getElementById('dicom-debtor-name').focus(); + } + + function toggleNewDebtorInput() { + const select = document.getElementById('dicom-debtor-select'); + const nameInput = document.getElementById('dicom-debtor-name'); + if (select.value === '') { + nameInput.style.display = 'block'; + } else { + nameInput.style.display = 'none'; + nameInput.value = ''; + } + } + + async function processDicomCheckout() { + // Check if selecting existing or entering new + const select = document.getElementById('dicom-debtor-select'); + const nameInput = document.getElementById('dicom-debtor-name'); + + let debtorName = select.value || nameInput.value.trim(); + const contactInfo = document.getElementById('dicom-contact-info').value.trim(); + const paymentInput = document.getElementById('dicom-initial-payment').value.replace(/\./g, ''); + const initialPayment = parseInt(paymentInput) || 0; + + if (!debtorName) { + alert('Por favor ingresa el nombre del deudor.'); + return; + } + + try { + const res = await fetch('/api/dicom/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + cart: cart, + debtor_name: debtorName, + contact_info: contactInfo, + initial_payment: initialPayment + }) + }); + + const result = await res.json(); + + if (res.ok) { + bootstrap.Modal.getInstance(document.getElementById('dicomModal')).hide(); + document.getElementById('dicom-success-ticket-id').textContent = result.ticket_id; + document.getElementById('dicom-success-debtor').textContent = result.debtor; + bootstrap.Modal.getOrCreateInstance(document.getElementById('dicomSuccessModal')).show(); + + // Clear cart + cart = []; + saveCart(); + renderCart(); + clearLastScanned(); + } else { + alert('Error: ' + (result.error || 'Error desconocido')); + } + } catch (e) { + console.error(e); + alert('Error de conexión.'); + } + } + function printReceipt(total, saleId, paidAmount = 0) { const tbody = document.getElementById('receipt-items-print'); tbody.innerHTML = ''; diff --git a/templates/dicom.html b/templates/dicom.html index ca31680..f1707f9 100644 --- a/templates/dicom.html +++ b/templates/dicom.html @@ -1,155 +1,188 @@ {% extends "macros/base.html" %} -{% from 'macros/modals.html' import confirm_modal %} {% block title %}Dicom{% endblock %} {% block content %} + +
-
- -
-
- - -
- +
+
+

Deudores

+ {{ debtors|length }} registrados
- -
- - - - - - - - - - - - - {% for d in debtors %} - - - - - - - - - {% endfor %} - -
FotoNombreDeuda TotalÚltima NotaActualizadoAcciones
- {% if d[5] %} - - {% else %} -
- -
- {% endif %} -
{{ d[1] }}{{ d[3] }}{{ d[4] }} - - -
+ +
+ {% for d in debtors %} +
+
+
+
+ +
+
+
{{ d[1] }}
+
{{ d[2] or 'Sin contacto' }}
+
+
+
+
+ {% if d[3] > 0 %}Deuda pendiente{% else %}Saldo cero{% endif %} +
+
+ {% if d[3] > 0 %} + + {% endif %} + + +
+
+ + +
+
+ Cargando tickets... +
+
+
+ {% else %} +
+ +

No hay deudores registrados

+
+ {% endfor %}
-