From ccd1836d38af9f570a43bab33a02d3b5196dde7b Mon Sep 17 00:00:00 2001 From: SekiDesu01 Date: Tue, 23 Jun 2026 15:20:14 -0400 Subject: [PATCH] SekiPOS server sync --- app.py | 61 +++++- blueprints/auth.py | 66 ++++++- blueprints/finance.py | 285 ++++++++++++++------------- blueprints/inventory.py | 21 +- blueprints/pos.py | 4 +- blueprints/sales.py | 7 + blueprints/sync_server.py | 254 ++++++++++++++++++++++++ core/db.py | 43 +++++ core/sync.py | 360 +++++++++++++++++++++++++++++++++++ db_client/instance.json | 7 + db_client/pos_database.db | Bin 0 -> 86016 bytes db_server/instance.json | 7 + db_server/pos_database.db | Bin 0 -> 86016 bytes docker-compose.yml | 14 ++ templates/macros/modals.html | 83 +++++++- 15 files changed, 1063 insertions(+), 149 deletions(-) create mode 100644 blueprints/sync_server.py create mode 100644 core/sync.py create mode 100644 db_client/instance.json create mode 100644 db_client/pos_database.db create mode 100644 db_server/instance.json create mode 100644 db_server/pos_database.db create mode 100644 docker-compose.yml diff --git a/app.py b/app.py index 36e0caa..3a44951 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,7 @@ import os import sys +import json +import uuid import time import threading @@ -12,12 +14,14 @@ 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 core.sync import SyncManager 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 +from blueprints.sync_server import sync_bp # --- PYINSTALLER WINDOWED MODE FIX --- if getattr(sys, 'frozen', False) and sys.platform == "win32": @@ -33,8 +37,16 @@ app = Flask( ) app.config['SECRET_KEY'] = 'seki_super_secret_key_99' +# --- PORT --- +PORT = int(os.environ.get('PORT', 5000)) + +# --- SYNC SECRET --- +SYNC_SECRET = os.environ.get('SEKIPOS_SYNC_SECRET', '').strip() +if SEKIPOS_MODE == 'server' and not SYNC_SECRET: + print("[WARN] SEKIpos_SYNC_SECRET not set — sync API is unprotected!") + # --- DIRECTORY SETUP --- -DB_DIR = get_persistent_path('db') +DB_DIR = os.environ.get('SEKIPOS_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 @@ -43,6 +55,37 @@ CACHE_DIR = get_persistent_path(os.path.join('static', 'cache')) os.makedirs(CACHE_DIR, exist_ok=True) app.config['CACHE_DIR'] = CACHE_DIR +# --- INSTANCE CONFIG --- +SEKIPOS_MODE = os.environ.get('SEKIPOS_MODE', 'desktop') +INSTANCE_FILE = os.path.join(DB_DIR, 'instance.json') + +if not os.path.exists(INSTANCE_FILE): + config = { + "instance_id": str(uuid.uuid4()), + "display_name": "Caja Principal", + "mode": SEKIPOS_MODE, + "server_url": "", + "last_sync_at": None + } + with open(INSTANCE_FILE, 'w') as f: + json.dump(config, f, indent=2) +else: + with open(INSTANCE_FILE) as f: + config = json.load(f) + config['mode'] = SEKIPOS_MODE + with open(INSTANCE_FILE, 'w') as f: + json.dump(config, f, indent=2) + +INSTANCE_ID = config['instance_id'] +DISPLAY_NAME = config.get('display_name', 'Caja Principal') +SERVER_URL = config.get('server_url', '') + +app.config['INSTANCE_ID'] = INSTANCE_ID +app.config['DISPLAY_NAME'] = DISPLAY_NAME +app.config['SERVER_URL'] = SERVER_URL +app.config['MODE'] = SEKIPOS_MODE +app.config['SYNC_SECRET'] = SYNC_SECRET + # --- BLUEPRINT REGISTRATION --- app.register_blueprint(auth_bp) app.register_blueprint(finance_bp) @@ -50,6 +93,9 @@ app.register_blueprint(inventory_bp) app.register_blueprint(pos_bp) app.register_blueprint(sales_bp) +if SEKIPOS_MODE == 'server': + app.register_blueprint(sync_bp) + init_login_manager(app) socketio.init_app(app, cors_allowed_origins="*", async_mode='threading') @@ -62,18 +108,25 @@ init_db_core(DB_FILE) def index(): return redirect(url_for('inventory.inventory')) +# --- SYNC CLIENT --- +if SEKIPOS_MODE == 'desktop' and SERVER_URL: + sync_secret = config.get('sync_secret', '') + sync_mgr = SyncManager(DB_FILE, INSTANCE_ID, SERVER_URL, DISPLAY_NAME, sync_secret) + sync_mgr.start() + print(f"[Sync] Desktop mode — syncing to {SERVER_URL}") + # --- RUN FUNCTION --- def start_server(): - socketio.run(app, host='127.0.0.1', port=5000, log_output=False, allow_unsafe_werkzeug=True) + socketio.run(app, host='127.0.0.1', port=PORT, log_output=False, allow_unsafe_werkzeug=True) def run_standalone(): t = threading.Thread(target=start_server) t.daemon = True t.start() 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) + webview.create_window('SekiPOS', f'http://127.0.0.1:{PORT}', width=1366, height=768, resizable=True, fullscreen=False, min_size=(800, 600), maximized=True) webview.start(private_mode=False) if __name__ == '__main__': #run_standalone() # Uncomment for desktop app - socketio.run(app, host='0.0.0.0', port=5000, debug=True, allow_unsafe_werkzeug=True) \ No newline at end of file + socketio.run(app, host='0.0.0.0', port=PORT, debug=True, allow_unsafe_werkzeug=True) \ No newline at end of file diff --git a/blueprints/auth.py b/blueprints/auth.py index 207f7a1..44a0f86 100644 --- a/blueprints/auth.py +++ b/blueprints/auth.py @@ -1,4 +1,6 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash +import os +import json +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app 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 @@ -56,5 +58,67 @@ def update_settings(): flash('Configuración actualizada') return redirect(request.referrer) +@auth_bp.route('/api/instance/config') +@login_required +def get_instance_config(): + db_dir = os.path.dirname(current_app.config.get('DB_FILE', '')) + instance_file = os.path.join(db_dir, 'instance.json') + if os.path.exists(instance_file): + with open(instance_file) as f: + config = json.load(f) + else: + config = {} + return jsonify({ + "instance_id": config.get('instance_id', ''), + "display_name": config.get('display_name', 'Caja Principal'), + "server_url": config.get('server_url', ''), + "sync_secret": config.get('sync_secret', ''), + "mode": config.get('mode', 'desktop') + }) + +@auth_bp.route('/api/instance/config', methods=['POST']) +@login_required +def save_instance_config(): + data = request.get_json() + db_dir = os.path.dirname(current_app.config.get('DB_FILE', '')) + instance_file = os.path.join(db_dir, 'instance.json') + if os.path.exists(instance_file): + with open(instance_file) as f: + config = json.load(f) + else: + config = {} + if 'server_url' in data: + config['server_url'] = data['server_url'] + if 'display_name' in data: + config['display_name'] = data['display_name'] + if 'sync_secret' in data: + config['sync_secret'] = data['sync_secret'] + with open(instance_file, 'w') as f: + json.dump(config, f, indent=2) + return jsonify({"status": "ok"}) + +@auth_bp.route('/api/sync/trigger', methods=['POST']) +@login_required +def trigger_sync(): + from core.sync import SyncManager + try: + db_file = current_app.config.get('DB_FILE', '') + instance_id = current_app.config.get('INSTANCE_ID', '') + server_url = current_app.config.get('SERVER_URL', '') + sync_secret = '' + if db_file: + instance_file = os.path.join(os.path.dirname(db_file), 'instance.json') + if os.path.exists(instance_file): + with open(instance_file) as f: + sync_secret = json.load(f).get('sync_secret', '') + if server_url: + sm = SyncManager(db_file, instance_id, server_url, current_app.config.get('DISPLAY_NAME', ''), sync_secret) + sm._push() + sm._pull() + return jsonify({"status": "ok"}) + return jsonify({"status": "error", "message": "No server URL configured"}), 400 + except Exception as e: + return jsonify({"status": "error", "message": str(e)}), 500 + 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 index 3bdefc0..50f0bc1 100644 --- a/blueprints/finance.py +++ b/blueprints/finance.py @@ -1,9 +1,18 @@ -from flask import Blueprint, render_template, request, jsonify +import uuid as _uuid +from datetime import datetime, timezone +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 finance_bp = Blueprint('finance', __name__) +def _log_deletion(conn, entity_type, entity_uuid): + conn.execute("INSERT INTO sync_deletions (entity_type, entity_uuid) VALUES (?, ?)", + (entity_type, entity_uuid)) + +def _instance_id(): + return current_app.config.get('INSTANCE_ID', '') + @finance_bp.route('/dicom') @login_required def dicom(): @@ -51,26 +60,31 @@ def pay_debtor_ticket(debtor_id): data = request.get_json() ticket_id = data.get('ticket_id') amount = float(data.get('amount', 0)) - + inst_id = _instance_id() + now = datetime.now(timezone.utc).isoformat() + 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 + 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 + status = CASE WHEN (total - amount_paid - ?) <= 0 THEN 'paid' ELSE 'partial' END, + updated_at = ?, + updated_by = ? + WHERE id = ?''', (amount, amount, now, inst_id, ticket_id)) + + 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,)) + END, + updated_at = ?, + updated_by = ? + WHERE id = ?''', (now, inst_id, ticket_id)) conn.commit() - + return jsonify({"status": "success"}) @finance_bp.route('/api/dicom/pay', methods=['POST']) @@ -80,34 +94,34 @@ def dicom_pay(): ticket_id = data.get('ticket_id') amount = float(data.get('amount', 0)) payment_method = data.get('payment_method', 'efectivo') - + inst_id = _instance_id() + now = datetime.now(timezone.utc).isoformat() + 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 + conn.execute('UPDATE debtor_tickets SET amount_paid = amount_paid + ? WHERE id = ?', (amount, ticket_id)) + + 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,)) - - # Insert into sales table to track daily revenue - cur.execute('''INSERT INTO sales (date, total, payment_method) - VALUES (CURRENT_TIMESTAMP, ?, ?)''', (amount, payment_method)) - + END, + updated_at = ?, + updated_by = ? + WHERE id = ?''', (now, inst_id, ticket_id)) + + conn.execute('''INSERT INTO sales (date, total, payment_method, uuid) + VALUES (CURRENT_TIMESTAMP, ?, ?, ?)''', + (amount, payment_method, str(_uuid.uuid4()))) + 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 @@ -121,22 +135,27 @@ def update_dicom(): notes = data.get('notes', '') image_url = data.get('image_url', '') action = data.get('action') - + inst_id = _instance_id() + now = datetime.now(timezone.utc).isoformat() + 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)) + existing = conn.execute("SELECT uuid FROM dicom WHERE name = ?", (name,)).fetchone() + ent_uuid = existing[0] if existing else str(_uuid.uuid4()) + conn.execute('''INSERT INTO dicom (uuid, name, amount, notes, image_url, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?) + 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, + updated_at = ?, + updated_by = ?''', + (ent_uuid, name, amount, notes, image_url, now, inst_id, now, inst_id)) conn.commit() return jsonify({"status": "success"}), 200 @@ -145,6 +164,9 @@ def update_dicom(): def delete_dicom(debtor_id): try: with get_db_connection() as conn: + row = conn.execute("SELECT uuid FROM dicom WHERE id = ?", (debtor_id,)).fetchone() + if row: + _log_deletion(conn, 'dicom', row[0]) conn.execute('DELETE FROM dicom WHERE id = ?', (debtor_id,)) conn.commit() return jsonify({"status": "success"}), 200 @@ -189,13 +211,13 @@ 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.execute("INSERT INTO expenses (uuid, description, amount) VALUES (?, ?, ?)", + (str(_uuid.uuid4()), desc, int(amount))) conn.commit() return jsonify({"success": True}) @@ -203,8 +225,10 @@ def add_gasto(): @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,)) + row = conn.execute("SELECT uuid FROM expenses WHERE id = ?", (gasto_id,)).fetchone() + if row: + _log_deletion(conn, 'expense', row[0]) + conn.execute("DELETE FROM expenses WHERE id = ?", (gasto_id,)) conn.commit() return jsonify({"success": True}) @@ -213,22 +237,17 @@ def delete_gasto(gasto_id): 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') + + cur.execute('SELECT id, name, contact_info, uuid 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]) + + return jsonify([{"id": d[0], "name": d[1], "contact_info": d[2], "uuid": d[3]} 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']) @@ -240,51 +259,50 @@ def dicom_checkout(): debtor_name = data.get('debtor_name', '').strip() contact_info = data.get('contact_info', '').strip() initial_payment = data.get('initial_payment', 0) or 0 - + inst_id = _instance_id() + now = datetime.now(timezone.utc).isoformat() + 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 + debtor_uuid = str(_uuid.uuid4()) + conn.execute('''INSERT INTO debtors (uuid, name, contact_info, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + contact_info = excluded.contact_info, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by''', + (debtor_uuid, debtor_name, contact_info, now, inst_id)) + + debtor_id = conn.execute('SELECT id FROM debtors WHERE name = ?', (debtor_name,)).fetchone()[0] + 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 + ticket_uuid = str(_uuid.uuid4()) + c = conn.execute('''INSERT INTO debtor_tickets (uuid, debtor_id, total, amount_paid, status, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?)''', + (ticket_uuid, debtor_id, total, initial_payment, status, now, inst_id)) + ticket_id = c.lastrowid + for item in cart: - cur.execute('''INSERT INTO debtor_ticket_items + conn.execute('''INSERT INTO debtor_ticket_items (ticket_id, barcode, name, price, quantity, subtotal) VALUES (?, ?, ?, ?, ?, ?)''', - (ticket_id, item.get('barcode', ''), item.get('name'), + (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 = ?', + conn.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 @@ -294,13 +312,15 @@ def dicom_checkout(): 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,)) + row = conn.execute("SELECT uuid FROM debtors WHERE id = ?", (debtor_id,)).fetchone() + if row: + _log_deletion(conn, 'debtor', row[0]) + tickets = conn.execute("SELECT uuid FROM debtor_tickets WHERE debtor_id = ?", (debtor_id,)).fetchall() + for t in tickets: + _log_deletion(conn, 'ticket', t[0]) + conn.execute('DELETE FROM debtor_ticket_items WHERE ticket_id IN (SELECT id FROM debtor_tickets WHERE debtor_id = ?)', (debtor_id,)) + conn.execute('DELETE FROM debtor_tickets WHERE debtor_id = ?', (debtor_id,)) + conn.execute('DELETE FROM debtors WHERE id = ?', (debtor_id,)) conn.commit() return jsonify({"status": "success"}), 200 except Exception as e: @@ -312,11 +332,11 @@ def delete_debtor(debtor_id): 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,)) + row = conn.execute("SELECT uuid FROM debtor_tickets WHERE id = ?", (ticket_id,)).fetchone() + if row: + _log_deletion(conn, 'ticket', row[0]) + conn.execute('DELETE FROM debtor_ticket_items WHERE ticket_id = ?', (ticket_id,)) + conn.execute('DELETE FROM debtor_tickets WHERE id = ?', (ticket_id,)) conn.commit() return jsonify({"status": "success"}), 200 except Exception as e: @@ -328,28 +348,23 @@ def delete_ticket(ticket_id): 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() + item = conn.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] - + conn.execute('DELETE FROM debtor_ticket_items WHERE id = ?', (item_id,)) + remaining_items = conn.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,)) + row = conn.execute("SELECT uuid FROM debtor_tickets WHERE id = ?", (ticket_id,)).fetchone() + if row: + _log_deletion(conn, 'ticket', row[0]) + conn.execute('DELETE FROM debtor_tickets WHERE id = ?', (ticket_id,)) + conn.commit() 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.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: @@ -363,36 +378,34 @@ def pay_all_debtor(debtor_id): data = request.get_json() amount = float(data.get('amount', 0)) payment_method = data.get('payment_method', 'efectivo') - + inst_id = _instance_id() + now = datetime.now(timezone.utc).isoformat() + 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 + tickets = conn.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.execute('UPDATE debtor_tickets SET amount_paid = amount_paid + ?, updated_at = ?, updated_by = ? WHERE id = ?', + (remaining, now, inst_id, ticket_id)) + conn.execute('UPDATE debtor_tickets SET status = \'paid\', updated_at = ?, updated_by = ? WHERE id = ?', + (now, inst_id, ticket_id)) + conn.execute('''INSERT INTO sales (date, total, payment_method, uuid) + VALUES (CURRENT_TIMESTAMP, ?, ?, ?)''', + (remaining, payment_method, str(_uuid.uuid4()))) + 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 index 71ae826..1f86491 100644 --- a/blueprints/inventory.py +++ b/blueprints/inventory.py @@ -38,16 +38,22 @@ def upsert(): cache_dir = current_app.config['CACHE_DIR'] final_image_path = download_image(image_url, barcode, cache_dir) + from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() + instance_id = current_app.config.get('INSTANCE_ID', '') + with get_db_connection() as conn: - conn.execute('''INSERT INTO products (barcode, name, price, image_url, stock, unit_type) - VALUES (?,?,?,?,?,?) + conn.execute('''INSERT INTO products (barcode, name, price, image_url, stock, unit_type, updated_at, updated_by) + 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)) + unit_type=excluded.unit_type, + updated_at=excluded.updated_at, + updated_by=excluded.updated_by''', + (barcode, name, price, final_image_path, stock, unit_type, now, instance_id)) conn.commit() return redirect(url_for('inventory.inventory')) @@ -102,9 +108,12 @@ def bulk_price_update(): return jsonify({"error": "Missing data"}), 400 try: + from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() + instance_id = current_app.config.get('INSTANCE_ID', '') with get_db_connection() as conn: - params = [(float(new_price), b) for b in barcodes] - conn.executemany('UPDATE products SET price = ? WHERE barcode = ?', params) + params = [(float(new_price), now, instance_id, b) for b in barcodes] + conn.executemany('UPDATE products SET price = ?, updated_at = ?, updated_by = ? WHERE barcode = ?', params) conn.commit() return jsonify({"status": "success"}), 200 except Exception as e: diff --git a/blueprints/pos.py b/blueprints/pos.py index ac9593f..9aa9002 100644 --- a/blueprints/pos.py +++ b/blueprints/pos.py @@ -1,5 +1,6 @@ import os import time +import uuid 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 @@ -81,7 +82,8 @@ def process_checkout(): 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_uuid = str(uuid.uuid4()) + cur.execute('INSERT INTO sales (date, total, payment_method, uuid) VALUES (CURRENT_TIMESTAMP, ?, ?, ?)', (total, payment_method, sale_uuid)) sale_id = cur.lastrowid for item in cart: diff --git a/blueprints/sales.py b/blueprints/sales.py index 09466cd..9b146cc 100644 --- a/blueprints/sales.py +++ b/blueprints/sales.py @@ -130,6 +130,11 @@ def reverse_sale(sale_id): with get_db_connection() as conn: cur = conn.cursor() + sale_uuid = cur.execute('SELECT uuid FROM sales WHERE id = ?', (sale_id,)).fetchone() + if not sale_uuid: + return jsonify({"error": "Sale not found"}), 404 + sale_uuid = sale_uuid[0] + items = cur.execute('SELECT barcode, quantity FROM sale_items WHERE sale_id = ?', (sale_id,)).fetchall() for barcode, qty in items: @@ -137,6 +142,8 @@ def reverse_sale(sale_id): cur.execute('DELETE FROM sale_items WHERE sale_id = ?', (sale_id,)) cur.execute('DELETE FROM sales WHERE id = ?', (sale_id,)) + cur.execute('INSERT INTO sync_deletions (entity_type, entity_uuid) VALUES (?, ?)', + ('sale', sale_uuid)) conn.commit() diff --git a/blueprints/sync_server.py b/blueprints/sync_server.py new file mode 100644 index 0000000..7ec5a17 --- /dev/null +++ b/blueprints/sync_server.py @@ -0,0 +1,254 @@ +from datetime import datetime, timezone +from flask import Blueprint, request, jsonify, current_app +from functools import wraps + +sync_bp = Blueprint('sync', __name__) + +def require_sync_secret(f): + @wraps(f) + def decorated(*args, **kwargs): + secret = current_app.config.get('SYNC_SECRET', '') + if secret and request.headers.get('X-Sync-Secret') != secret: + return jsonify({"error": "unauthorized"}), 401 + return f(*args, **kwargs) + return decorated + +@sync_bp.route('/api/ping') +def ping(): + return jsonify({"status": "ok"}), 200 + +@sync_bp.route('/api/sync/push', methods=['POST']) +@require_sync_secret +def sync_push(): + data = request.get_json() + if not data: + return jsonify({"error": "empty payload"}), 400 + + instance_id = data.get('instance_id', 'unknown') + synced_at = data.get('synced_at') or datetime.now(timezone.utc).isoformat() + + from core.db import get_db_connection + + with get_db_connection() as conn: + # 1. Deletions + for d in data.get('deletions', []): + et = d['entity_type'] + eu = d['entity_uuid'] + if et == 'debtor': + conn.execute("DELETE FROM debtors WHERE uuid = ?", (eu,)) + elif et == 'ticket': + conn.execute("DELETE FROM debtor_tickets WHERE uuid = ?", (eu,)) + elif et == 'dicom': + conn.execute("DELETE FROM dicom WHERE uuid = ?", (eu,)) + elif et == 'expense': + conn.execute("DELETE FROM expenses WHERE uuid = ?", (eu,)) + elif et == 'sale': + conn.execute("DELETE FROM sales WHERE uuid = ?", (eu,)) + + # 2. Sales + for sale in data.get('sales', []): + existing = conn.execute( + "SELECT id FROM sales WHERE uuid = ?", (sale['uuid'],) + ).fetchone() + if existing: + continue + cur = conn.execute( + "INSERT INTO sales (date, total, payment_method, uuid, synced_at) VALUES (?, ?, ?, ?, ?)", + (sale.get('date', synced_at), sale['total'], sale.get('payment_method', 'efectivo'), + sale['uuid'], synced_at) + ) + sale_id = cur.lastrowid + for item in sale.get('items', []): + conn.execute( + "INSERT INTO sale_items (sale_id, barcode, name, price, quantity, subtotal) VALUES (?, ?, ?, ?, ?, ?)", + (sale_id, item['barcode'], item['name'], item.get('price', 0), + item.get('qty', 1), item.get('subtotal', 0)) + ) + + # 3. Products + now = datetime.now(timezone.utc).isoformat() + for p in data.get('products', []): + existing = conn.execute( + "SELECT updated_at FROM products WHERE barcode = ?", (p['barcode'],) + ).fetchone() + if existing and existing[0] >= p.get('updated_at', now): + continue + conn.execute('''INSERT INTO products (barcode, name, price, image_url, stock, unit_type, updated_at, updated_by) + 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, updated_at=excluded.updated_at, + updated_by=excluded.updated_by''', + (p['barcode'], p.get('name', ''), p.get('price', 0), + p.get('image_url'), p.get('stock', 0), p.get('unit_type', 'unit'), + p.get('updated_at', now), instance_id)) + + # 4. Debtors + for d in data.get('debtors', []): + conn.execute('''INSERT INTO debtors (uuid, name, contact_info, updated_at, updated_by) + VALUES (?,?,?,?,?) + ON CONFLICT(uuid) DO UPDATE SET + name=excluded.name, contact_info=excluded.contact_info, + updated_at=excluded.updated_at, updated_by=excluded.updated_by''', + (d['uuid'], d['name'], d.get('contact_info', ''), + d.get('updated_at', now), d.get('updated_by', instance_id))) + + # 5. Dicom (legacy) + for d in data.get('dicom', []): + conn.execute('''INSERT INTO dicom (uuid, name, amount, notes, image_url, updated_at, updated_by) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(uuid) DO UPDATE SET + name=excluded.name, amount=excluded.amount, + notes=excluded.notes, image_url=excluded.image_url, + updated_at=excluded.updated_at, updated_by=excluded.updated_by''', + (d['uuid'], d['name'], d.get('amount', 0), d.get('notes', ''), + d.get('image_url', ''), d.get('updated_at', now), d.get('updated_by', instance_id))) + + # 6. Debtor tickets with items + for t in data.get('tickets', []): + existing = conn.execute( + "SELECT id FROM debtor_tickets WHERE uuid = ?", (t['uuid'],) + ).fetchone() + if existing: + continue + debtor_row = conn.execute( + "SELECT id FROM debtors WHERE uuid = ?", (t['debtor_uuid'],) + ).fetchone() + if not debtor_row: + continue + cur = conn.execute( + "INSERT INTO debtor_tickets (uuid, debtor_id, date, total, amount_paid, status, updated_at, updated_by) VALUES (?,?,?,?,?,?,?,?)", + (t['uuid'], debtor_row[0], t.get('date', synced_at), t['total'], + t.get('amount_paid', 0), t.get('status', 'unpaid'), + t.get('updated_at', now), t.get('updated_by', instance_id)) + ) + ticket_id = cur.lastrowid + for item in t.get('items', []): + conn.execute( + "INSERT INTO debtor_ticket_items (ticket_id, barcode, name, price, quantity, subtotal) VALUES (?,?,?,?,?,?)", + (ticket_id, item['barcode'], item['name'], item.get('price', 0), + item.get('qty', 1), item.get('subtotal', 0)) + ) + + # 7. Expenses + for e in data.get('expenses', []): + existing = conn.execute( + "SELECT id FROM expenses WHERE uuid = ?", (e['uuid'],) + ).fetchone() + if existing: + continue + conn.execute( + "INSERT INTO expenses (uuid, date, description, amount) VALUES (?,?,?,?)", + (e['uuid'], e.get('date', synced_at), e['description'], e['amount']) + ) + + conn.commit() + + return jsonify({"status": "ok", "instance_id": instance_id}), 200 + +@sync_bp.route('/api/sync/pull') +@require_sync_secret +def sync_pull(): + since = request.args.get('since', '1970-01-01') + since_sales = request.args.get('since_sales', '1970-01-01') + since_debtors = request.args.get('since_debtors', '1970-01-01') + since_tickets = request.args.get('since_tickets', '1970-01-01') + since_dicom = request.args.get('since_dicom', '1970-01-01') + since_expenses = request.args.get('since_expenses', '1970-01-01') + since_deletions = request.args.get('since_deletions', '1970-01-01') + + from core.db import get_db_connection + with get_db_connection() as conn: + # Products + products = conn.execute( + "SELECT barcode, name, price, image_url, stock, unit_type, updated_at, updated_by FROM products WHERE updated_at >= ?", + (since,) + ).fetchall() + + # Sales with items + sales_rows = conn.execute( + "SELECT id, uuid, date, total, payment_method FROM sales WHERE date >= ? ORDER BY date", + (since_sales,) + ).fetchall() + sales_payload = [] + for s in sales_rows: + items = conn.execute( + "SELECT barcode, name, price, quantity, subtotal FROM sale_items WHERE sale_id = ?", + (s[0],) + ).fetchall() + sales_payload.append({ + "uuid": s[1], "date": s[2], "total": s[3], "payment_method": s[4], + "items": [{"barcode": i[0], "name": i[1], "price": i[2], "qty": i[3], "subtotal": i[4]} for i in items] + }) + + # Debtors + debtors = conn.execute( + "SELECT uuid, name, contact_info, updated_at, updated_by FROM debtors WHERE updated_at >= ?", + (since_debtors,) + ).fetchall() + + # Dicom (legacy) + dicom_rows = conn.execute( + "SELECT uuid, name, amount, notes, image_url, updated_at, updated_by FROM dicom WHERE updated_at >= ?", + (since_dicom,) + ).fetchall() + + # Debtor tickets with items + tickets = conn.execute( + "SELECT id, uuid, debtor_id, date, total, amount_paid, status, updated_at, updated_by FROM debtor_tickets WHERE updated_at >= ?", + (since_tickets,) + ).fetchall() + ticket_payload = [] + for t in tickets: + ticket_id, ticket_uuid, debtor_id = t[0], t[1], t[2] + debtor_uuid = conn.execute("SELECT uuid FROM debtors WHERE id = ?", (debtor_id,)).fetchone() + items = conn.execute( + "SELECT barcode, name, price, quantity, subtotal FROM debtor_ticket_items WHERE ticket_id = ?", + (ticket_id,) + ).fetchall() + ticket_payload.append({ + "uuid": ticket_uuid, "debtor_uuid": debtor_uuid[0] if debtor_uuid else '', + "date": t[3], "total": t[4], "amount_paid": t[5], "status": t[6], + "updated_at": t[7], "updated_by": t[8], + "items": [{"barcode": i[0], "name": i[1], "price": i[2], "qty": i[3], "subtotal": i[4]} for i in items] + }) + + # Expenses + expenses = conn.execute( + "SELECT uuid, date, description, amount FROM expenses WHERE date >= ?", + (since_expenses,) + ).fetchall() + + # Deletions + deletions = conn.execute( + "SELECT entity_type, entity_uuid, deleted_at FROM sync_deletions WHERE deleted_at >= ?", + (since_deletions,) + ).fetchall() + + return jsonify({ + "products": [ + {"barcode": p[0], "name": p[1], "price": p[2], "image_url": p[3], + "stock": p[4], "unit_type": p[5], "updated_at": p[6], "updated_by": p[7]} + for p in products + ], + "sales": sales_payload, + "debtors": [ + {"uuid": d[0], "name": d[1], "contact_info": d[2], "updated_at": d[3], "updated_by": d[4]} + for d in debtors + ], + "dicom": [ + {"uuid": d[0], "name": d[1], "amount": d[2], "notes": d[3], "image_url": d[4], + "updated_at": d[5], "updated_by": d[6]} + for d in dicom_rows + ], + "tickets": ticket_payload, + "expenses": [ + {"uuid": e[0], "date": e[1], "description": e[2], "amount": e[3]} + for e in expenses + ], + "deletions": [ + {"entity_type": d[0], "entity_uuid": d[1], "deleted_at": d[2]} + for d in deletions + ] + }), 200 diff --git a/core/db.py b/core/db.py index e1425ad..77aed1f 100644 --- a/core/db.py +++ b/core/db.py @@ -66,6 +66,49 @@ def init_db(db_file): subtotal REAL, FOREIGN KEY(ticket_id) REFERENCES debtor_tickets(id) ON DELETE CASCADE)''') + conn.execute('''CREATE TABLE IF NOT EXISTS sync_deletions + (id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, + entity_uuid TEXT NOT NULL, + deleted_at TEXT DEFAULT (datetime('now')), + synced_at TEXT)''') + + # --- MIGRATIONS --- + for stmt in [ + "ALTER TABLE sales ADD COLUMN uuid TEXT", + "ALTER TABLE sales ADD COLUMN synced_at TEXT", + "ALTER TABLE products ADD COLUMN updated_at TEXT DEFAULT CURRENT_TIMESTAMP", + "ALTER TABLE products ADD COLUMN updated_by TEXT", + "ALTER TABLE expenses ADD COLUMN uuid TEXT", + "ALTER TABLE expenses ADD COLUMN synced_at TEXT", + "ALTER TABLE debtors ADD COLUMN uuid TEXT", + "ALTER TABLE debtors ADD COLUMN updated_at TEXT DEFAULT CURRENT_TIMESTAMP", + "ALTER TABLE debtors ADD COLUMN updated_by TEXT", + "ALTER TABLE debtor_tickets ADD COLUMN uuid TEXT", + "ALTER TABLE debtor_tickets ADD COLUMN updated_at TEXT DEFAULT CURRENT_TIMESTAMP", + "ALTER TABLE debtor_tickets ADD COLUMN updated_by TEXT", + "ALTER TABLE dicom ADD COLUMN uuid TEXT", + "ALTER TABLE dicom ADD COLUMN updated_at TEXT DEFAULT CURRENT_TIMESTAMP", + "ALTER TABLE dicom ADD COLUMN updated_by TEXT", + ]: + try: + conn.execute(stmt) + except Exception: + pass + + for tbl in ['products', 'debtors', 'debtor_tickets', 'dicom']: + conn.execute(f"UPDATE {tbl} SET updated_at = datetime('now') WHERE updated_at IS NULL") + + for idx in [ + "CREATE UNIQUE INDEX IF NOT EXISTS idx_debtors_uuid ON debtors(uuid)", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_dicom_uuid ON dicom(uuid)", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_expenses_uuid ON expenses(uuid)", + ]: + try: + conn.execute(idx) + except Exception: + pass + user = conn.execute('SELECT * FROM users WHERE username = ?', ('admin',)).fetchone() if not user: from werkzeug.security import generate_password_hash diff --git a/core/sync.py b/core/sync.py new file mode 100644 index 0000000..177c1ac --- /dev/null +++ b/core/sync.py @@ -0,0 +1,360 @@ +import threading +import requests +from datetime import datetime, timezone + +POLL_INTERVAL = 30 +BACKOFF_MULTIPLIER = 2 +MAX_BACKOFF = 120 + +class SyncManager: + def __init__(self, db_file, instance_id, server_url, display_name="Desktop", sync_secret=""): + self.db_file = db_file + self.instance_id = instance_id + self.server_url = server_url.rstrip('/') + self.display_name = display_name + self._headers = {'X-Sync-Secret': sync_secret} if sync_secret else {} + self._stop = threading.Event() + self._last_push_at = None + self._last_sale_pull = None + self._last_debtor_pull = None + self._last_ticket_pull = None + self._last_dicom_pull = None + self._last_expense_pull = None + self._last_deletion_pull = None + + def start(self): + thread = threading.Thread(target=self._run, daemon=True, name="sync-manager") + thread.start() + + def stop(self): + self._stop.set() + + def _run(self): + backoff = POLL_INTERVAL + while not self._stop.wait(backoff): + try: + if self._ping(): + self._push() + self._pull() + backoff = POLL_INTERVAL + else: + backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF) + except Exception as e: + print(f"[Sync] Error: {e}") + backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF) + + def _ping(self): + try: + r = requests.get(f"{self.server_url}/api/ping", headers=self._headers, timeout=5) + return r.ok + except requests.RequestException: + return False + + def _push(self): + import sqlite3 + conn = sqlite3.connect(self.db_file) + try: + now = datetime.now(timezone.utc).isoformat() + cutoff = self._last_push_at or '1970-01-01' + + # Sales (immutable, synced_at IS NULL) + sales = conn.execute( + "SELECT id, uuid, date, total, payment_method FROM sales WHERE synced_at IS NULL" + ).fetchall() + + sale_ids = [s[0] for s in sales] + sale_payloads = [] + for s in sales: + items = conn.execute( + "SELECT barcode, name, price, quantity, subtotal FROM sale_items WHERE sale_id = ?", + (s[0],) + ).fetchall() + sale_payloads.append({ + "uuid": s[1], "date": s[2], "total": s[3], "payment_method": s[4], + "items": [{"barcode": i[0], "name": i[1], "price": i[2], "qty": i[3], "subtotal": i[4]} for i in items] + }) + + # Products (mutable, by updated_at) + products = conn.execute( + "SELECT barcode, name, price, image_url, stock, unit_type, updated_at FROM products WHERE updated_by = ? AND updated_at >= ?", + (self.instance_id, cutoff) + ).fetchall() + + # Debtors (mutable) + debtors = conn.execute( + "SELECT uuid, name, contact_info, updated_at FROM debtors WHERE updated_by = ? AND updated_at >= ?", + (self.instance_id, cutoff) + ).fetchall() + + # Dicom legacy (mutable) + dicom_rows = conn.execute( + "SELECT uuid, name, amount, notes, image_url, updated_at FROM dicom WHERE updated_by = ? AND updated_at >= ?", + (self.instance_id, cutoff) + ).fetchall() + + # Debtor tickets with items (mutable) + tickets_raw = conn.execute( + "SELECT id, uuid, debtor_id, date, total, amount_paid, status, updated_at FROM debtor_tickets WHERE updated_by = ? AND updated_at >= ?", + (self.instance_id, cutoff) + ).fetchall() + ticket_payloads = [] + for t in tickets_raw: + debtor_uuid = conn.execute("SELECT uuid FROM debtors WHERE id = ?", (t[2],)).fetchone() + items = conn.execute( + "SELECT barcode, name, price, quantity, subtotal FROM debtor_ticket_items WHERE ticket_id = ?", + (t[0],) + ).fetchall() + ticket_payloads.append({ + "uuid": t[1], "debtor_uuid": debtor_uuid[0] if debtor_uuid else '', + "date": t[3], "total": t[4], "amount_paid": t[5], "status": t[6], + "updated_at": t[7], + "items": [{"barcode": i[0], "name": i[1], "price": i[2], "qty": i[3], "subtotal": i[4]} for i in items] + }) + + # Expenses (immutable, synced_at IS NULL) + expenses = conn.execute( + "SELECT uuid, date, description, amount FROM expenses WHERE synced_at IS NULL" + ).fetchall() + + # Deletions (immutable, synced_at IS NULL) + deletions = conn.execute( + "SELECT entity_type, entity_uuid FROM sync_deletions WHERE synced_at IS NULL" + ).fetchall() + + payload = { + "instance_id": self.instance_id, + "display_name": self.display_name, + "synced_at": now, + "sales": sale_payloads, + "products": [ + {"barcode": p[0], "name": p[1], "price": p[2], "image_url": p[3], "stock": p[4], "unit_type": p[5], "updated_at": p[6]} + for p in products + ], + "debtors": [ + {"uuid": d[0], "name": d[1], "contact_info": d[2], "updated_at": d[3], "updated_by": self.instance_id} + for d in debtors + ], + "dicom": [ + {"uuid": d[0], "name": d[1], "amount": d[2], "notes": d[3], "image_url": d[4], "updated_at": d[5], "updated_by": self.instance_id} + for d in dicom_rows + ], + "tickets": ticket_payloads, + "expenses": [ + {"uuid": e[0], "date": e[1], "description": e[2], "amount": e[3]} + for e in expenses + ], + "deletions": [ + {"entity_type": d[0], "entity_uuid": d[1]} + for d in deletions + ], + } + + has_data = any([ + sale_payloads, products, debtors, dicom_rows, + ticket_payloads, expenses, deletions + ]) + + if has_data: + r = requests.post(f"{self.server_url}/api/sync/push", json=payload, headers=self._headers, timeout=15) + if r.ok: + if sale_ids: + conn.executemany( + "UPDATE sales SET synced_at = ? WHERE id = ?", + [(now, sid) for sid in sale_ids] + ) + if deletions: + conn.executemany( + "UPDATE sync_deletions SET synced_at = ? WHERE entity_type = ? AND entity_uuid = ?", + [(now, d[0], d[1]) for d in deletions] + ) + conn.commit() + self._last_push_at = now + print(f"[Sync] Pushed {len(sale_payloads)} sales, {len(products)} products, {len(debtors)} debtors, {len(dicom_rows)} dicom, {len(ticket_payloads)} tickets, {len(expenses)} expenses, {len(deletions)} deletions") + else: + print(f"[Sync] Push failed: HTTP {r.status_code} {r.text[:200]}") + else: + print(f"[Sync] Push: nothing to push") + finally: + conn.close() + + def _pull(self): + import sqlite3 + conn = sqlite3.connect(self.db_file) + try: + last_pull = conn.execute( + "SELECT COALESCE(MAX(updated_at), '1970-01-01') FROM products WHERE updated_by != ?", + (self.instance_id,) + ).fetchone()[0] + + params = { + "since": last_pull, + "since_sales": self._last_sale_pull or '1970-01-01', + "since_debtors": self._last_debtor_pull or '1970-01-01', + "since_tickets": self._last_ticket_pull or '1970-01-01', + "since_dicom": self._last_dicom_pull or '1970-01-01', + "since_expenses": self._last_expense_pull or '1970-01-01', + "since_deletions": self._last_deletion_pull or '1970-01-01', + "instance_id": self.instance_id, + } + print(f"[Sync] Pull: {params}") + + r = requests.get(f"{self.server_url}/api/sync/pull", params=params, headers=self._headers, timeout=15) + if not r.ok: + print(f"[Sync] Pull request failed: HTTP {r.status_code} {r.text[:200]}") + return + + data = r.json() + now = datetime.now(timezone.utc).isoformat() + + # Apply deletions first + for d in data.get("deletions", []): + et, eu = d["entity_type"], d["entity_uuid"] + if et == 'debtor': + conn.execute("DELETE FROM debtors WHERE uuid = ?", (eu,)) + elif et == 'ticket': + conn.execute("DELETE FROM debtor_tickets WHERE uuid = ?", (eu,)) + elif et == 'dicom': + conn.execute("DELETE FROM dicom WHERE uuid = ?", (eu,)) + elif et == 'expense': + conn.execute("DELETE FROM expenses WHERE uuid = ?", (eu,)) + elif et == 'sale': + conn.execute("DELETE FROM sales WHERE uuid = ?", (eu,)) + + # Debtors (upsert by uuid) + pulled_debtors = 0 + for d in data.get("debtors", []): + conn.execute('''INSERT INTO debtors (uuid, name, contact_info, updated_at, updated_by) + VALUES (?,?,?,?,?) + ON CONFLICT(uuid) DO UPDATE SET + name=excluded.name, contact_info=excluded.contact_info, + updated_at=excluded.updated_at, updated_by=excluded.updated_by''', + (d["uuid"], d["name"], d.get("contact_info", ""), + d.get("updated_at", now), d.get("updated_by", ""))) + pulled_debtors += 1 + + # Dicom (upsert by uuid) + pulled_dicom = 0 + for d in data.get("dicom", []): + conn.execute('''INSERT INTO dicom (uuid, name, amount, notes, image_url, updated_at, updated_by) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(uuid) DO UPDATE SET + name=excluded.name, amount=excluded.amount, + notes=excluded.notes, image_url=excluded.image_url, + updated_at=excluded.updated_at, updated_by=excluded.updated_by''', + (d["uuid"], d["name"], d.get("amount", 0), d.get("notes", ""), + d.get("image_url", ""), d.get("updated_at", now), d.get("updated_by", ""))) + pulled_dicom += 1 + + # Debtor tickets (insert with items, dedup by uuid) + pulled_tickets = 0 + for t in data.get("tickets", []): + existing = conn.execute( + "SELECT id FROM debtor_tickets WHERE uuid = ?", (t["uuid"],) + ).fetchone() + if existing: + continue + debtor_row = conn.execute( + "SELECT id FROM debtors WHERE uuid = ?", (t["debtor_uuid"],) + ).fetchone() + if not debtor_row: + continue + cur = conn.execute( + "INSERT INTO debtor_tickets (uuid, debtor_id, date, total, amount_paid, status, updated_at, updated_by) VALUES (?,?,?,?,?,?,?,?)", + (t["uuid"], debtor_row[0], t.get("date", now), t["total"], + t.get("amount_paid", 0), t.get("status", "unpaid"), + t.get("updated_at", now), t.get("updated_by", "")) + ) + for item in t.get("items", []): + conn.execute( + "INSERT INTO debtor_ticket_items (ticket_id, barcode, name, price, quantity, subtotal) VALUES (?,?,?,?,?,?)", + (cur.lastrowid, item["barcode"], item["name"], item.get("price", 0), + item.get("qty", 1), item.get("subtotal", 0)) + ) + pulled_tickets += 1 + + # Expenses (insert, dedup by uuid) + pulled_expenses = 0 + for e in data.get("expenses", []): + existing = conn.execute( + "SELECT id FROM expenses WHERE uuid = ?", (e["uuid"],) + ).fetchone() + if existing: + continue + conn.execute( + "INSERT INTO expenses (uuid, date, description, amount, synced_at) VALUES (?,?,?,?,?)", + (e["uuid"], e.get("date", now), e["description"], e["amount"], now) + ) + pulled_expenses += 1 + + # Products (upsert by barcode, skip if local is newer) + pulled_prods = 0 + for p in data.get("products", []): + existing = conn.execute( + "SELECT updated_at FROM products WHERE barcode = ?", (p["barcode"],) + ).fetchone() + if existing and existing[0] >= p["updated_at"]: + continue + conn.execute('''INSERT INTO products (barcode, name, price, image_url, stock, unit_type, updated_at, updated_by) + 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, updated_at=excluded.updated_at, + updated_by=excluded.updated_by''', + (p["barcode"], p["name"], p["price"], p.get("image_url"), + p.get("stock", 0), p.get("unit_type", "unit"), + p["updated_at"], p.get("updated_by", ""))) + pulled_prods += 1 + + # Sales (insert with items, dedup by uuid) + pulled_sales = 0 + for sale in data.get("sales", []): + existing = conn.execute( + "SELECT id FROM sales WHERE uuid = ?", (sale["uuid"],) + ).fetchone() + if existing: + continue + cur = conn.execute( + "INSERT INTO sales (date, total, payment_method, uuid, synced_at) VALUES (?, ?, ?, ?, ?)", + (sale.get("date", now), sale["total"], sale.get("payment_method", "efectivo"), + sale["uuid"], now) + ) + for item in sale.get("items", []): + conn.execute( + "INSERT INTO sale_items (sale_id, barcode, name, price, quantity, subtotal) VALUES (?, ?, ?, ?, ?, ?)", + (cur.lastrowid, item["barcode"], item["name"], item.get("price", 0), + item.get("qty", 1), item.get("subtotal", 0)) + ) + pulled_sales += 1 + + conn.commit() + + # Update pull timestamps + all_sales = data.get("sales", []) + all_debtors = data.get("debtors", []) + all_tickets = data.get("tickets", []) + all_dicom = data.get("dicom", []) + all_expenses = data.get("expenses", []) + all_deletions = data.get("deletions", []) + + if all_sales: + self._last_sale_pull = max(s["date"] for s in all_sales) + if all_debtors: + self._last_debtor_pull = max(d["updated_at"] for d in all_debtors) + if all_tickets: + self._last_ticket_pull = max(t["updated_at"] for t in all_tickets) + if all_dicom: + self._last_dicom_pull = max(d["updated_at"] for d in all_dicom) + if all_expenses: + self._last_expense_pull = max(e["date"] for e in all_expenses) + if all_deletions: + self._last_deletion_pull = max(d["deleted_at"] for d in all_deletions) + + if pulled_prods or pulled_sales or pulled_debtors or pulled_dicom or pulled_tickets or pulled_expenses: + print(f"[Sync] Pulled {pulled_prods} products, {pulled_sales} sales, {pulled_debtors} debtors, {pulled_dicom} dicom, {pulled_tickets} tickets, {pulled_expenses} expenses") + else: + print(f"[Sync] Pull: nothing new to apply") + except Exception as e: + print(f"[Sync] Pull error: {e}") + finally: + conn.close() diff --git a/db_client/instance.json b/db_client/instance.json new file mode 100644 index 0000000..5ff323a --- /dev/null +++ b/db_client/instance.json @@ -0,0 +1,7 @@ +{ + "instance_id": "f35f82c4-2cc7-4a29-bce7-9c8b0d451a0e", + "display_name": "Caja Principal", + "mode": "desktop", + "server_url": "http://192.168.1.103:5000", + "last_sync_at": null +} \ No newline at end of file diff --git a/db_client/pos_database.db b/db_client/pos_database.db new file mode 100644 index 0000000000000000000000000000000000000000..73ab03229e60b6b1aec639b53330a6b6d29a49f1 GIT binary patch literal 86016 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 o00e*l5C8%|00;m9AOHk_z^979aExtra Grande + +
+
Sincronización
+
+ + +
+
+ + +
+
+ + +
+
+
+ --- + Estado: No configurado + +
+ +