SekiPOS server sync
This commit is contained in:
61
app.py
61
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)
|
||||
socketio.run(app, host='0.0.0.0', port=PORT, debug=True, allow_unsafe_werkzeug=True)
|
||||
@@ -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)
|
||||
@@ -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,6 +60,8 @@ 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
|
||||
@@ -58,17 +69,20 @@ def pay_debtor_ticket(debtor_id):
|
||||
with get_db_connection() as conn:
|
||||
conn.execute('''UPDATE debtor_tickets
|
||||
SET amount_paid = amount_paid + ?,
|
||||
status = CASE WHEN (total - amount_paid - ?) <= 0 THEN 'paid' ELSE 'partial' END
|
||||
WHERE id = ?''', (amount, amount, ticket_id))
|
||||
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))
|
||||
|
||||
# Update status based on final values
|
||||
conn.execute('''UPDATE debtor_tickets
|
||||
SET status = CASE
|
||||
WHEN total - amount_paid <= 0 THEN 'paid'
|
||||
WHEN amount_paid > 0 THEN 'partial'
|
||||
ELSE 'unpaid'
|
||||
END
|
||||
WHERE id = ?''', (ticket_id,))
|
||||
END,
|
||||
updated_at = ?,
|
||||
updated_by = ?
|
||||
WHERE id = ?''', (now, inst_id, ticket_id))
|
||||
conn.commit()
|
||||
|
||||
return jsonify({"status": "success"})
|
||||
@@ -80,29 +94,29 @@ 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()
|
||||
conn.execute('UPDATE debtor_tickets SET amount_paid = amount_paid + ? WHERE id = ?', (amount, ticket_id))
|
||||
|
||||
# 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
|
||||
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))
|
||||
|
||||
# Insert into sales table to track daily revenue
|
||||
cur.execute('''INSERT INTO sales (date, total, payment_method)
|
||||
VALUES (CURRENT_TIMESTAMP, ?, ?)''', (amount, payment_method))
|
||||
conn.execute('''INSERT INTO sales (date, total, payment_method, uuid)
|
||||
VALUES (CURRENT_TIMESTAMP, ?, ?, ?)''',
|
||||
(amount, payment_method, str(_uuid.uuid4())))
|
||||
|
||||
conn.commit()
|
||||
|
||||
@@ -121,6 +135,8 @@ 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
|
||||
@@ -129,14 +145,17 @@ def update_dicom():
|
||||
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)
|
||||
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,
|
||||
last_updated = CURRENT_TIMESTAMP''', (name, amount, notes, image_url))
|
||||
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
|
||||
@@ -194,8 +216,8 @@ def add_gasto():
|
||||
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,6 +259,8 @@ 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
|
||||
@@ -249,36 +270,33 @@ def dicom_checkout():
|
||||
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 (?, ?)
|
||||
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''',
|
||||
(debtor_name, contact_info))
|
||||
contact_info = excluded.contact_info,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by''',
|
||||
(debtor_uuid, debtor_name, contact_info, now, inst_id))
|
||||
|
||||
# Get debtor ID
|
||||
debtor_id = cur.execute('SELECT id FROM debtors WHERE name = ?', (debtor_name,)).fetchone()[0]
|
||||
debtor_id = conn.execute('SELECT id FROM debtors WHERE name = ?', (debtor_name,)).fetchone()[0]
|
||||
|
||||
# Insert debtor ticket
|
||||
status = 'partial' if initial_payment > 0 else 'unpaid'
|
||||
cur.execute('''INSERT INTO debtor_tickets (debtor_id, total, amount_paid, status)
|
||||
VALUES (?, ?, ?, ?)''',
|
||||
(debtor_id, total, initial_payment, status))
|
||||
ticket_id = cur.lastrowid
|
||||
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
|
||||
|
||||
# Insert ticket items and deduct stock
|
||||
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'),
|
||||
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()
|
||||
@@ -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,15 +378,14 @@ 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
|
||||
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()
|
||||
|
||||
@@ -380,14 +394,13 @@ def pay_all_debtor(debtor_id):
|
||||
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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
254
blueprints/sync_server.py
Normal file
254
blueprints/sync_server.py
Normal file
@@ -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
|
||||
43
core/db.py
43
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
|
||||
|
||||
360
core/sync.py
Normal file
360
core/sync.py
Normal file
@@ -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()
|
||||
7
db_client/instance.json
Normal file
7
db_client/instance.json
Normal file
@@ -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
|
||||
}
|
||||
BIN
db_client/pos_database.db
Normal file
BIN
db_client/pos_database.db
Normal file
Binary file not shown.
7
db_server/instance.json
Normal file
7
db_server/instance.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"instance_id": "fc0f4545-53a4-4a03-a9fb-74d3f9adfe94",
|
||||
"display_name": "Caja Principal",
|
||||
"mode": "server",
|
||||
"server_url": "",
|
||||
"last_sync_at": null
|
||||
}
|
||||
BIN
db_server/pos_database.db
Normal file
BIN
db_server/pos_database.db
Normal file
Binary file not shown.
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -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
|
||||
@@ -324,6 +324,31 @@
|
||||
<option value="xlarge">Extra Grande</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h6 class="text-muted text-uppercase small mb-2"><i class="bi bi-cloud-arrow-up me-1"></i>Sincronización</h6>
|
||||
<div class="mb-2">
|
||||
<label class="form-label text-muted small mb-1">Servidor (URL)</label>
|
||||
<input type="text" id="setting-server-url" class="form-control" placeholder="Ej: http://192.168.1.100:5000" autocomplete="off">
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label text-muted small mb-1">Nombre de esta caja</label>
|
||||
<input type="text" id="setting-display-name" class="form-control" placeholder="Ej: Caja Principal" autocomplete="off">
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label text-muted small mb-1">Clave de sincronización</label>
|
||||
<input type="password" id="setting-sync-secret" class="form-control" placeholder="Ingrese la clave del servidor" autocomplete="off">
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2 p-2 rounded" style="background: var(--input-bg);">
|
||||
<div>
|
||||
<span id="sync-mode-badge" class="badge bg-secondary me-1">---</span>
|
||||
<small class="text-muted d-block mt-1" id="sync-status-text">Estado: <span id="sync-status-label">No configurado</span></small>
|
||||
<small class="text-muted d-block" id="sync-instance-id"></small>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="syncNow()" id="btn-sync-now" title="Sincronizar ahora">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer d-flex">
|
||||
<button class="btn btn-secondary flex-grow-1" data-bs-dismiss="modal">Cancelar</button>
|
||||
@@ -338,6 +363,48 @@
|
||||
document.getElementById('setting-sii-fields').classList.toggle('d-none', !isChecked);
|
||||
}
|
||||
|
||||
async function loadSyncConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/instance/config');
|
||||
const data = await res.json();
|
||||
const isServer = data.mode === 'server';
|
||||
const badge = document.getElementById('sync-mode-badge');
|
||||
badge.textContent = isServer ? 'Servidor' : 'Cliente';
|
||||
badge.className = 'badge ' + (isServer ? 'bg-success' : 'bg-primary') + ' me-1';
|
||||
|
||||
const urlInput = document.getElementById('setting-server-url');
|
||||
const nameInput = document.getElementById('setting-display-name');
|
||||
const syncBtn = document.getElementById('btn-sync-now');
|
||||
|
||||
urlInput.value = data.server_url || '';
|
||||
nameInput.value = data.display_name || '';
|
||||
document.getElementById('setting-sync-secret').value = data.sync_secret || '';
|
||||
urlInput.disabled = isServer;
|
||||
syncBtn.style.display = isServer ? 'none' : '';
|
||||
|
||||
document.getElementById('sync-instance-id').textContent = 'ID: ' + (data.instance_id || '').slice(0, 8) + '...';
|
||||
document.getElementById('sync-status-label').textContent = isServer ? 'Activo' : (data.server_url ? 'Conectado' : 'No configurado');
|
||||
} catch (e) {
|
||||
document.getElementById('sync-status-label').textContent = 'Error al cargar';
|
||||
}
|
||||
}
|
||||
|
||||
async function syncNow() {
|
||||
const btn = document.getElementById('btn-sync-now');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||
document.getElementById('sync-status-label').textContent = 'Sincronizando...';
|
||||
try {
|
||||
const res = await fetch('/api/sync/trigger', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
document.getElementById('sync-status-label').textContent = data.status === 'ok' ? 'Sincronizado' : 'Error';
|
||||
} catch (e) {
|
||||
document.getElementById('sync-status-label').textContent = 'Error de conexión';
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const modalEl = document.getElementById('settingsModal');
|
||||
if (modalEl) {
|
||||
@@ -357,12 +424,13 @@
|
||||
document.getElementById('setting-address').value = localStorage.getItem('seki_address') || '';
|
||||
|
||||
toggleSiiFields();
|
||||
loadSyncConfig();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function savePosSettings() {
|
||||
async function savePosSettings() {
|
||||
const bizName = document.getElementById('setting-biz-name').value.trim() || 'SekiPOS';
|
||||
const autoPrint = document.getElementById('setting-auto-print').checked;
|
||||
const askDetails = document.getElementById('setting-ask-order-details').checked;
|
||||
@@ -370,6 +438,9 @@
|
||||
const foodMode = document.getElementById('setting-food-mode').checked;
|
||||
const lastScanned = document.getElementById('setting-last-scanned').checked;
|
||||
const comandaSize = document.getElementById('setting-comanda-size').value;
|
||||
const serverUrl = document.getElementById('setting-server-url').value.trim();
|
||||
const displayName = document.getElementById('setting-display-name').value.trim() || 'Caja Principal';
|
||||
const syncSecret = document.getElementById('setting-sync-secret').value.trim();
|
||||
localStorage.setItem('seki_biz_name', bizName);
|
||||
localStorage.setItem('seki_auto_print', autoPrint);
|
||||
localStorage.setItem('seki_ask_order_details', askDetails);
|
||||
@@ -379,6 +450,16 @@
|
||||
localStorage.setItem('seki_last_scanned', lastScanned);
|
||||
localStorage.setItem('seki_comanda_size', comandaSize);
|
||||
|
||||
try {
|
||||
await fetch('/api/instance/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ server_url: serverUrl, display_name: displayName, sync_secret: syncSecret })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to save sync config', e);
|
||||
}
|
||||
|
||||
if (showSii) {
|
||||
localStorage.setItem('seki_rut', document.getElementById('setting-rut').value.trim());
|
||||
localStorage.setItem('seki_giro', document.getElementById('setting-giro').value.trim());
|
||||
|
||||
Reference in New Issue
Block a user