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 0000000..73ab032 Binary files /dev/null and b/db_client/pos_database.db differ diff --git a/db_server/instance.json b/db_server/instance.json new file mode 100644 index 0000000..f7c4a4d --- /dev/null +++ b/db_server/instance.json @@ -0,0 +1,7 @@ +{ + "instance_id": "fc0f4545-53a4-4a03-a9fb-74d3f9adfe94", + "display_name": "Caja Principal", + "mode": "server", + "server_url": "", + "last_sync_at": null +} \ No newline at end of file diff --git a/db_server/pos_database.db b/db_server/pos_database.db new file mode 100644 index 0000000..1c66712 Binary files /dev/null and b/db_server/pos_database.db differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..74bea42 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + sekipos-server: + build: . + container_name: sekipos-server + ports: + - "5000:5000" + environment: + - SEKIPOS_MODE=server + - SEKIPOS_SYNC_SECRET=${SEKIPOS_SYNC_SECRET:-changeme} + - PYTHONUNBUFFERED=1 + volumes: + - ./db:/app/db + - ./cache:/app/static/cache + restart: unless-stopped diff --git a/templates/macros/modals.html b/templates/macros/modals.html index ad1cea4..8180fb8 100644 --- a/templates/macros/modals.html +++ b/templates/macros/modals.html @@ -324,6 +324,31 @@ + +