- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
728 lines
27 KiB
Python
728 lines
27 KiB
Python
import logging
|
|
import re
|
|
import time
|
|
|
|
from flask import Flask, request, redirect, make_response, g
|
|
from json_provider import OrjsonProvider
|
|
from services.prom_metrics import CONTENT_TYPE as PROM_CONTENT_TYPE, metrics as prom_metrics
|
|
from services.prom_metrics import normalize_path as prom_normalize_path
|
|
|
|
|
|
_alert_log = logging.getLogger('nexus.pos.alerts')
|
|
|
|
# `+` opcional seguido de 10-15 dígitos (formato internacional).
|
|
_ALERT_PHONE_RE = re.compile(r'^\+?\d{10,15}$')
|
|
|
|
|
|
def _extract_payload_tenant_ref(data, alerts):
|
|
"""Extrae el tenant referenciado por un payload de Alertmanager.
|
|
|
|
Busca `labels.tenant_id` (o `labels.tenant`) en el payload, en sus
|
|
`commonLabels` y en las etiquetas de cada alerta. Devuelve el primer valor
|
|
no vacío o None.
|
|
"""
|
|
label_dicts = []
|
|
for candidate in (data.get('labels'), data.get('commonLabels')):
|
|
if isinstance(candidate, dict):
|
|
label_dicts.append(candidate)
|
|
for alert in alerts or []:
|
|
if isinstance(alert, dict) and isinstance(alert.get('labels'), dict):
|
|
label_dicts.append(alert['labels'])
|
|
|
|
for labels in label_dicts:
|
|
for key in ('tenant_id', 'tenant'):
|
|
value = labels.get(key)
|
|
if value not in (None, '', []):
|
|
return value
|
|
return None
|
|
|
|
|
|
def _resolve_tenant_dbname(tenant_ref):
|
|
"""Resuelve una referencia de tenant a su `db_name`.
|
|
|
|
Acepta id numérico, db_name, subdominio o nombre exacto. Devuelve None si
|
|
no existe o si falla la consulta.
|
|
"""
|
|
from tenant_db import get_master_conn
|
|
|
|
ref = str(tenant_ref).strip()
|
|
if not ref:
|
|
return None
|
|
try:
|
|
conn = get_master_conn()
|
|
try:
|
|
cur = conn.cursor()
|
|
if ref.isdigit():
|
|
cur.execute(
|
|
"SELECT db_name FROM tenants WHERE id = %s AND is_active = true",
|
|
(int(ref),),
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"""SELECT db_name FROM tenants
|
|
WHERE is_active = true
|
|
AND (db_name = %s OR LOWER(subdomain) = %s OR LOWER(name) = %s)
|
|
LIMIT 1""",
|
|
(ref, ref.lower(), ref.lower()),
|
|
)
|
|
row = cur.fetchone()
|
|
cur.close()
|
|
finally:
|
|
conn.close()
|
|
return row[0] if row and row[0] else None
|
|
except Exception as exc:
|
|
_alert_log.warning("alert routing: no se pudo resolver tenant %r: %s", tenant_ref, exc)
|
|
return None
|
|
|
|
|
|
def _get_tenant_alert_config(db_name):
|
|
"""Lee los contactos de alerta del tenant `db_name`.
|
|
|
|
Devuelve (phones, enabled). Ante cualquier fallo devuelve ([], False) para
|
|
degradar sin error.
|
|
"""
|
|
from tenant_db import get_tenant_conn_by_dbname
|
|
|
|
phones, enabled = [], False
|
|
try:
|
|
conn = get_tenant_conn_by_dbname(db_name)
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT key, value FROM tenant_config "
|
|
"WHERE key IN ('alert_phones', 'alert_whatsapp_enabled')"
|
|
)
|
|
rows = {r[0]: r[1] for r in cur.fetchall()}
|
|
cur.close()
|
|
finally:
|
|
conn.close()
|
|
phones = [p.strip() for p in str(rows.get('alert_phones') or '').replace('"', '').split(',') if p.strip()]
|
|
enabled = str(rows.get('alert_whatsapp_enabled', 'false')).lower() == 'true'
|
|
except Exception as exc:
|
|
_alert_log.warning("alert routing: no se pudo leer config de %s: %s", db_name, exc)
|
|
return phones, enabled
|
|
|
|
|
|
def _global_alert_phones():
|
|
"""Fallback de compatibilidad: `ALERT_PHONES` de /etc/nexus/alert.env."""
|
|
try:
|
|
with open('/etc/nexus/alert.env') as fh:
|
|
for line in fh:
|
|
if line.startswith('ALERT_PHONES='):
|
|
raw = line.split('=', 1)[1].strip().strip('"')
|
|
return [p.strip() for p in raw.split(',') if p.strip()]
|
|
except Exception:
|
|
pass
|
|
return []
|
|
|
|
|
|
def _send_via_bridge(url, phones, msg):
|
|
"""Envía `msg` a `phones` por el bridge de WhatsApp de `url`.
|
|
|
|
Devuelve True si al menos un envío se completó. Best effort: no propaga
|
|
excepciones.
|
|
"""
|
|
import requests
|
|
|
|
if not url:
|
|
return False
|
|
try:
|
|
status = requests.get(url + '/status', timeout=4).json()
|
|
if status.get('state') != 'connected':
|
|
return False
|
|
sent = False
|
|
for phone in phones:
|
|
try:
|
|
requests.post(url + '/send', json={'phone': phone, 'message': msg}, timeout=10)
|
|
sent = True
|
|
except Exception as exc:
|
|
_alert_log.warning("alert routing: fallo enviando a %s: %s", phone, exc)
|
|
return sent
|
|
except Exception as exc:
|
|
_alert_log.warning("alert routing: bridge %s no disponible: %s", url, exc)
|
|
return False
|
|
|
|
|
|
def _send_tenant_alert(db_name, phones, msg):
|
|
"""Envía la alerta por el bridge configurado en ESE tenant."""
|
|
from tenant_db import get_tenant_conn_by_dbname
|
|
|
|
try:
|
|
conn = get_tenant_conn_by_dbname(db_name)
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT value FROM tenant_config WHERE key = 'whatsapp_bridge_url'")
|
|
row = cur.fetchone()
|
|
cur.close()
|
|
finally:
|
|
conn.close()
|
|
return _send_via_bridge(row[0] if row and row[0] else '', phones, msg)
|
|
except Exception as exc:
|
|
_alert_log.warning("alert routing: bridge del tenant %s no disponible: %s", db_name, exc)
|
|
return False
|
|
|
|
|
|
def _send_global_alert(phones, msg):
|
|
"""Fallback global: envía por el primer bridge de tenant conectado."""
|
|
from tenant_db import get_master_conn, get_tenant_conn_by_dbname
|
|
|
|
try:
|
|
conn = get_master_conn()
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT db_name FROM tenants WHERE is_active = true")
|
|
dbs = [r[0] for r in cur.fetchall()]
|
|
cur.close()
|
|
finally:
|
|
conn.close()
|
|
|
|
for db in dbs:
|
|
try:
|
|
tconn = get_tenant_conn_by_dbname(db)
|
|
try:
|
|
tcur = tconn.cursor()
|
|
tcur.execute("SELECT value FROM tenant_config WHERE key = 'whatsapp_bridge_url'")
|
|
row = tcur.fetchone()
|
|
tcur.close()
|
|
finally:
|
|
tconn.close()
|
|
if not row or not row[0]:
|
|
continue
|
|
if _send_via_bridge(row[0], phones, msg):
|
|
return True
|
|
except Exception:
|
|
continue
|
|
return False
|
|
except Exception as exc:
|
|
_alert_log.warning("alert routing: fallback global sin bridge: %s", exc)
|
|
return False
|
|
|
|
|
|
def create_app():
|
|
app = Flask(__name__)
|
|
app.json = OrjsonProvider(app)
|
|
|
|
# Tenant subdomain resolver (before every request)
|
|
from middleware_tenant import resolve_tenant
|
|
app.before_request(resolve_tenant)
|
|
|
|
# ─── Prometheus metrics ────────────────────────────────────────
|
|
# Se registran DESPUÉS de resolve_tenant, así que corren después de él.
|
|
prom_metrics.configure('pos')
|
|
|
|
@app.before_request
|
|
def _metrics_request_start():
|
|
if request.path != '/metrics':
|
|
g._metrics_t0 = time.perf_counter()
|
|
prom_metrics.request_started()
|
|
|
|
@app.after_request
|
|
def _metrics_request_end(response):
|
|
t0 = getattr(g, '_metrics_t0', None)
|
|
if t0 is not None:
|
|
g._metrics_done = True
|
|
rule = getattr(request.url_rule, 'rule', None)
|
|
prom_metrics.request_finished(
|
|
request.method,
|
|
prom_normalize_path(rule, request.path),
|
|
response.status_code,
|
|
time.perf_counter() - t0,
|
|
)
|
|
return response
|
|
|
|
@app.teardown_request
|
|
def _metrics_request_teardown(exc):
|
|
if exc is not None:
|
|
prom_metrics.record_exception()
|
|
if getattr(g, '_metrics_t0', None) is not None and not getattr(g, '_metrics_done', False):
|
|
prom_metrics.request_aborted()
|
|
|
|
# NOTE: Page-level routing guards are handled client-side by app-init.js
|
|
# using the employee's current permissions; API endpoints enforce their own
|
|
# permission checks via @require_auth.
|
|
|
|
# ─── PWA: Service Worker must be served from /pos/ scope ──────
|
|
@app.route('/pos/api/notifications/webhook', methods=['POST'])
|
|
def notifications_webhook():
|
|
"""Recibe alertas de Alertmanager y las reenvía por WhatsApp (best effort).
|
|
|
|
El destino es POR NEGOCIO: cada tenant configura sus propios números en
|
|
su `tenant_config` (`alert_phones` + `alert_whatsapp_enabled`),
|
|
editables desde `PUT /pos/api/config/alert-contacts`. Resolución:
|
|
|
|
(a) si el payload trae `labels.tenant_id` (o `labels.tenant`) → se usan
|
|
los contactos de ESE tenant y se envía por su bridge de WhatsApp;
|
|
(b) si no, fallback de compatibilidad al `ALERT_PHONES` global de
|
|
/etc/nexus/alert.env (best effort por el primer bridge conectado);
|
|
(c) si no hay contactos → no-op con `sent:false` (nunca error).
|
|
|
|
Si un negocio no configura contactos, sus alertas no se envían: no hay
|
|
ningún teléfono global obligatorio. Autenticación por `INTERNAL_API_KEY`
|
|
(Bearer o X-Internal-Key). Tolera fallos: nunca responde 500.
|
|
"""
|
|
# Endpoint abierto solo con el secreto interno (Alertmanager lo envía como Bearer).
|
|
from config import INTERNAL_API_KEY as _internal_key
|
|
if _internal_key:
|
|
_tok = request.headers.get('X-Internal-Key', '')
|
|
_auth = request.headers.get('Authorization', '')
|
|
if _tok != _internal_key and _auth != f'Bearer {_internal_key}':
|
|
return jsonify({'error': 'unauthorized'}), 401
|
|
data = request.get_json(silent=True) or {}
|
|
alerts = data.get('alerts') or []
|
|
status = data.get('status', 'firing')
|
|
lines = []
|
|
for a in alerts[:5]:
|
|
ann = a.get('annotations', {})
|
|
summary = ann.get('summary') or a.get('labels', {}).get('alertname', 'alerta')
|
|
desc = ann.get('description', '')
|
|
lines.append(f"• {summary}" + (f" — {desc}" if desc else ''))
|
|
if not lines:
|
|
return jsonify({'ok': True, 'sent': False, 'reason': 'sin alertas'})
|
|
|
|
try:
|
|
# (a) Destino por negocio si el payload identifica un tenant.
|
|
tenant_ref = _extract_payload_tenant_ref(data, alerts)
|
|
tenant_db, phones = None, []
|
|
if tenant_ref is not None:
|
|
tenant_db = _resolve_tenant_dbname(tenant_ref)
|
|
if tenant_db:
|
|
phones, enabled = _get_tenant_alert_config(tenant_db)
|
|
if phones and not enabled:
|
|
return jsonify({'ok': True, 'sent': False, 'scope': 'tenant',
|
|
'reason': 'alertas WhatsApp deshabilitadas para el negocio'})
|
|
else:
|
|
# (b) Fallback de compatibilidad al teléfono global.
|
|
phones = _global_alert_phones()
|
|
|
|
if not phones:
|
|
return jsonify({'ok': True, 'sent': False, 'reason': 'sin contactos configurados'})
|
|
|
|
msg = ('🚨 *Nexus Alert* [' + status + ']\n' + '\n'.join(lines))
|
|
if tenant_db:
|
|
sent = _send_tenant_alert(tenant_db, phones, msg)
|
|
scope, reason = 'tenant', 'bridge del negocio no conectado'
|
|
else:
|
|
sent = _send_global_alert(phones, msg)
|
|
scope, reason = 'global', 'sin bridge conectado'
|
|
if sent:
|
|
return jsonify({'ok': True, 'sent': True, 'scope': scope})
|
|
return jsonify({'ok': True, 'sent': False, 'scope': scope, 'reason': reason})
|
|
except Exception as exc:
|
|
_alert_log.exception("alert routing: fallo inesperado: %s", exc)
|
|
return jsonify({'ok': True, 'sent': False, 'reason': 'error interno'})
|
|
|
|
@app.route('/pos/api/public/media/<path:filename>')
|
|
def public_media(filename):
|
|
"""Sirve media temporal (imágenes/PDF) para enviar vía WA CRM."""
|
|
from flask import send_from_directory, abort
|
|
import re as _re
|
|
if not _re.fullmatch(r'[0-9a-f]{32}(_[A-Za-z0-9._-]{1,80})?\.(png|jpg|jpeg|pdf)', filename):
|
|
abort(404)
|
|
return send_from_directory('/tmp/wa_media', filename, max_age=3600)
|
|
|
|
@app.route('/pos/sw-push.js')
|
|
def pos_sw_push():
|
|
from flask import send_from_directory, make_response
|
|
response = make_response(send_from_directory('static', 'sw-push.js',
|
|
mimetype='application/javascript'))
|
|
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
return response
|
|
|
|
@app.route('/pos/sw.js')
|
|
def pos_sw():
|
|
from flask import send_from_directory, make_response
|
|
response = make_response(send_from_directory('static/pwa', 'sw.js',
|
|
mimetype='application/javascript'))
|
|
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
response.headers['Pragma'] = 'no-cache'
|
|
response.headers['Expires'] = '0'
|
|
return response
|
|
|
|
# ─── Manejo global de tablas retiradas (esquema TecDoc, junio 2026) ───
|
|
# Los endpoints que aún consultan el catálogo OEM antiguo devolvían 500 con
|
|
# SQL crudo. Aquí se degradan a una respuesta limpia y vacía.
|
|
from werkzeug.exceptions import HTTPException
|
|
|
|
@app.errorhandler(Exception)
|
|
def _handle_retired_schema(e):
|
|
if isinstance(e, HTTPException):
|
|
return e
|
|
try:
|
|
from psycopg2.errors import UndefinedTable, UndefinedColumn
|
|
if isinstance(e, (UndefinedTable, UndefinedColumn)):
|
|
name = getattr(e, 'diag', None)
|
|
table = getattr(name, 'table_name', '') if name else ''
|
|
return jsonify({
|
|
'data': [],
|
|
'oem_disabled': True,
|
|
'error': 'Función no disponible en este despliegue',
|
|
'detail': f'La tabla "{table}" fue retirada del catálogo maestro.'
|
|
if table else 'Consulta sobre esquema retirado.',
|
|
}), 200
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
# Register blueprints
|
|
from blueprints.auth_bp import auth_bp
|
|
app.register_blueprint(auth_bp)
|
|
|
|
# Storefront público por tenant (vitrina web + pedidos)
|
|
from blueprints.storefront_bp import storefront_bp
|
|
app.register_blueprint(storefront_bp)
|
|
|
|
@app.route('/tienda')
|
|
def public_storefront():
|
|
from flask import g, render_template
|
|
business_name = 'Refaccionaria'
|
|
tid = getattr(g, 'tenant_id', None)
|
|
if tid:
|
|
try:
|
|
from tenant_db import get_master_conn
|
|
mconn = get_master_conn()
|
|
mcur = mconn.cursor()
|
|
mcur.execute("SELECT name FROM tenants WHERE id = %s", (tid,))
|
|
row = mcur.fetchone()
|
|
mcur.close()
|
|
mconn.close()
|
|
if row:
|
|
business_name = row[0]
|
|
except Exception:
|
|
pass
|
|
return render_template('storefront.html', business_name=business_name)
|
|
|
|
@app.route('/')
|
|
def root_redirect():
|
|
# En subdominios de tenants la raíz es la tienda pública;
|
|
# en el dominio principal manda al POS.
|
|
from flask import g
|
|
if getattr(g, 'tenant_id', None):
|
|
return redirect('/tienda')
|
|
return redirect('/pos/login')
|
|
|
|
from blueprints.config_bp import config_bp
|
|
app.register_blueprint(config_bp)
|
|
|
|
from blueprints.inventory_bp import inventory_bp
|
|
app.register_blueprint(inventory_bp)
|
|
|
|
from blueprints.catalog_bp import catalog_bp
|
|
app.register_blueprint(catalog_bp)
|
|
|
|
from blueprints.pos_bp import pos_bp
|
|
app.register_blueprint(pos_bp)
|
|
|
|
from blueprints.public_bp import public_bp
|
|
app.register_blueprint(public_bp)
|
|
|
|
from blueprints.customers_bp import customers_bp
|
|
app.register_blueprint(customers_bp)
|
|
|
|
from blueprints.cashregister_bp import cashregister_bp
|
|
app.register_blueprint(cashregister_bp)
|
|
|
|
from blueprints.invoicing_bp import invoicing_bp
|
|
app.register_blueprint(invoicing_bp)
|
|
|
|
from blueprints.accounting_bp import accounting_bp
|
|
app.register_blueprint(accounting_bp)
|
|
|
|
from blueprints.chat_bp import chat_bp
|
|
app.register_blueprint(chat_bp)
|
|
|
|
from blueprints.fleet_bp import fleet_bp
|
|
app.register_blueprint(fleet_bp)
|
|
|
|
from blueprints.whatsapp_bp import whatsapp_bp
|
|
app.register_blueprint(whatsapp_bp)
|
|
|
|
from blueprints.marketplace_bp import marketplace_bp
|
|
app.register_blueprint(marketplace_bp)
|
|
|
|
from blueprints.marketplace_external_bp import marketplace_ext_bp
|
|
app.register_blueprint(marketplace_ext_bp)
|
|
|
|
from blueprints.dropshipping_bp import dropship_bp
|
|
app.register_blueprint(dropship_bp)
|
|
|
|
from blueprints.peer_bp import peer_bp
|
|
app.register_blueprint(peer_bp)
|
|
|
|
from blueprints.supplier_bp import supplier_bp
|
|
app.register_blueprint(supplier_bp)
|
|
|
|
from blueprints.warranty_bp import warranty_bp
|
|
app.register_blueprint(warranty_bp)
|
|
|
|
from blueprints.crm_bp import crm_bp
|
|
app.register_blueprint(crm_bp)
|
|
|
|
from blueprints.service_order_bp import service_order_bp
|
|
app.register_blueprint(service_order_bp)
|
|
|
|
from blueprints.image_bp import image_bp
|
|
app.register_blueprint(image_bp)
|
|
|
|
from blueprints.notification_bp import notification_bp
|
|
app.register_blueprint(notification_bp)
|
|
|
|
from blueprints.savings_bp import savings_bp
|
|
app.register_blueprint(savings_bp)
|
|
|
|
from blueprints.logistics_bp import logistics_bp
|
|
app.register_blueprint(logistics_bp)
|
|
|
|
from blueprints.public_api_bp import public_api_bp
|
|
app.register_blueprint(public_api_bp)
|
|
|
|
from blueprints.tasks_bp import tasks_bp
|
|
app.register_blueprint(tasks_bp)
|
|
|
|
from blueprints.bnpl_bp import bnpl_bp
|
|
app.register_blueprint(bnpl_bp)
|
|
|
|
from blueprints.erp_bp import erp_bp
|
|
app.register_blueprint(erp_bp)
|
|
|
|
from blueprints.whatsapp_cloud_bp import whatsapp_cloud_bp
|
|
app.register_blueprint(whatsapp_cloud_bp)
|
|
|
|
from blueprints.dashboard_stats_bp import dashboard_stats_bp
|
|
app.register_blueprint(dashboard_stats_bp)
|
|
|
|
from blueprints.supplier_portal_bp import supplier_portal_bp
|
|
app.register_blueprint(supplier_portal_bp)
|
|
|
|
from blueprints.supplier_catalog_bp import supplier_catalog_bp
|
|
app.register_blueprint(supplier_catalog_bp)
|
|
|
|
from blueprints.internal_bp import internal_bp
|
|
app.register_blueprint(internal_bp)
|
|
|
|
from blueprints.yonke_bp import yonke_bp
|
|
app.register_blueprint(yonke_bp)
|
|
|
|
from blueprints.b2b_bp import b2b_bp
|
|
app.register_blueprint(b2b_bp)
|
|
|
|
# Health check
|
|
@app.route('/pos/health')
|
|
def health():
|
|
return {'status': 'ok'}
|
|
|
|
# Prometheus scrape endpoint (no pasa por el contador: se excluye por path)
|
|
@app.route('/metrics')
|
|
def prometheus_metrics():
|
|
response = make_response(prom_metrics.render())
|
|
response.headers['Content-Type'] = PROM_CONTENT_TYPE
|
|
response.headers['Cache-Control'] = 'no-store'
|
|
return response
|
|
|
|
from flask import g, jsonify, render_template, send_from_directory
|
|
|
|
@app.route('/favicon.ico')
|
|
def favicon():
|
|
return send_from_directory('static/pwa', 'icon-192.png', mimetype='image/png')
|
|
|
|
@app.route('/pos/login')
|
|
def pos_login_legacy():
|
|
# Redirect to the new login path to bypass any stale browser/SW cache
|
|
# of the old /pos/login page that did not send employee_id.
|
|
response = make_response(redirect('/pos/login2'))
|
|
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
return response
|
|
|
|
@app.route('/pos/login2')
|
|
def pos_login():
|
|
response = make_response(render_template('login.html',
|
|
tenant_id=getattr(g, 'tenant_id', None),
|
|
tenant_name=getattr(g, 'tenant_name', None),
|
|
tenant_subdomain=getattr(g, 'tenant_subdomain', None)))
|
|
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
response.headers['Pragma'] = 'no-cache'
|
|
response.headers['Expires'] = '0'
|
|
return response
|
|
|
|
@app.route('/pos/supplier-catalog')
|
|
def supplier_catalog_page():
|
|
return render_template('supplier_catalog.html')
|
|
|
|
@app.route('/pos/catalog')
|
|
def pos_catalog():
|
|
return render_template('catalog.html')
|
|
|
|
@app.route('/pos/inventory')
|
|
def pos_inventory():
|
|
return render_template('inventory.html')
|
|
|
|
@app.route('/pos/sale')
|
|
def pos_sale():
|
|
return render_template('pos.html')
|
|
|
|
@app.route('/pos/customers')
|
|
def pos_customers():
|
|
return render_template('customers.html')
|
|
|
|
@app.route('/pos/invoicing')
|
|
def pos_invoicing():
|
|
return render_template('invoicing.html')
|
|
|
|
@app.route('/pos/accounting')
|
|
def pos_accounting():
|
|
return render_template('accounting.html')
|
|
|
|
@app.route('/pos/dashboard')
|
|
def pos_dashboard():
|
|
response = make_response(render_template('dashboard.html'))
|
|
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
response.headers['Pragma'] = 'no-cache'
|
|
response.headers['Expires'] = '0'
|
|
return response
|
|
|
|
@app.route('/pos/config')
|
|
def pos_config():
|
|
return render_template('config.html')
|
|
|
|
@app.route('/pos/reports')
|
|
def pos_reports():
|
|
return render_template('reports.html')
|
|
|
|
@app.route('/pos/fleet')
|
|
def pos_fleet():
|
|
return render_template('fleet.html')
|
|
|
|
@app.route('/pos/workshop')
|
|
def pos_workshop():
|
|
return render_template('workshop.html')
|
|
|
|
@app.route('/pos/quotations')
|
|
def pos_quotations():
|
|
return render_template('quotations.html')
|
|
|
|
@app.route('/pos/whatsapp')
|
|
def pos_whatsapp():
|
|
return render_template('whatsapp.html')
|
|
|
|
@app.route('/pos/marketplace')
|
|
def pos_marketplace():
|
|
return render_template('marketplace.html')
|
|
|
|
@app.route('/pos/marketplace-external')
|
|
def pos_marketplace_external():
|
|
return render_template('marketplace_external.html')
|
|
|
|
@app.route('/pos/marketplace-external/callback')
|
|
def pos_marketplace_external_callback():
|
|
return render_template('marketplace_external.html')
|
|
|
|
@app.route('/pos/historical-sales')
|
|
def pos_historical_sales():
|
|
return render_template('historical_sales.html')
|
|
|
|
@app.route('/pos/remission-notes')
|
|
def pos_remission_notes():
|
|
response = make_response(render_template('remission_notes.html'))
|
|
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
response.headers['Pragma'] = 'no-cache'
|
|
response.headers['Expires'] = '0'
|
|
return response
|
|
|
|
@app.route('/pos/yonke')
|
|
def pos_yonke():
|
|
return render_template('yonke.html')
|
|
|
|
@app.route('/pos/static/<path:filename>')
|
|
def pos_static(filename):
|
|
return send_from_directory('static', filename)
|
|
|
|
# ─── Sync: full inventory for offline cache ───────────────────
|
|
from middleware import require_auth as _require_auth
|
|
|
|
@app.route('/pos/api/sync/inventory', methods=['GET'])
|
|
@_require_auth()
|
|
def sync_full_inventory():
|
|
"""Download full inventory for offline cache."""
|
|
from tenant_db import get_tenant_conn
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
branch_id = g.branch_id
|
|
|
|
cur.execute("""
|
|
SELECT i.id, i.part_number, i.barcode, i.name, i.brand,
|
|
i.unit, i.price_1, i.price_2, i.price_3, i.tax_rate,
|
|
COALESCE(s.stock, 0) AS stock
|
|
FROM inventory i
|
|
LEFT JOIN (
|
|
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
|
|
FROM inventory_operations GROUP BY inventory_id
|
|
) s ON s.inventory_id = i.id
|
|
WHERE i.is_active = true AND i.branch_id = %s
|
|
ORDER BY i.name
|
|
""", [branch_id])
|
|
|
|
items = []
|
|
for r in cur.fetchall():
|
|
items.append({
|
|
'item_id': r[0], 'sku': r[1], 'barcode': r[2],
|
|
'name': r[3], 'brand': r[4], 'unit': r[5],
|
|
'price_1': float(r[6]) if r[6] else 0,
|
|
'price_2': float(r[7]) if r[7] else 0,
|
|
'price_3': float(r[8]) if r[8] else 0,
|
|
'tax_rate': float(r[9]) if r[9] else 0.16,
|
|
'stock': r[10]
|
|
})
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({'items': items, 'count': len(items)})
|
|
|
|
@app.route('/pos/api/sync/top-parts', methods=['GET'])
|
|
@_require_auth()
|
|
def sync_top_parts():
|
|
"""Get top 500 most-sold parts for offline catalog cache."""
|
|
from tenant_db import get_tenant_conn
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
branch_id = g.branch_id
|
|
|
|
cur.execute("""
|
|
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate,
|
|
i.category_id, COALESCE(s.stock, 0) AS stock,
|
|
COALESCE(sv.total_sold, 0) AS total_sold
|
|
FROM inventory i
|
|
LEFT JOIN (
|
|
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
|
|
FROM inventory_operations GROUP BY inventory_id
|
|
) s ON s.inventory_id = i.id
|
|
LEFT JOIN (
|
|
SELECT si.inventory_id, SUM(si.quantity) AS total_sold
|
|
FROM sale_items si
|
|
JOIN sales sa ON si.sale_id = sa.id
|
|
WHERE sa.status IN ('completed', 'partially_returned')
|
|
GROUP BY si.inventory_id
|
|
) sv ON sv.inventory_id = i.id
|
|
WHERE i.is_active = true AND i.branch_id = %s
|
|
ORDER BY COALESCE(sv.total_sold, 0) DESC
|
|
LIMIT 500
|
|
""", [branch_id])
|
|
|
|
parts = []
|
|
for r in cur.fetchall():
|
|
parts.append({
|
|
'part_number': r[0], 'name': r[1], 'brand': r[2],
|
|
'price': float(r[3]) if r[3] else 0,
|
|
'tax_rate': float(r[4]) if r[4] else 0.16,
|
|
'category': r[5] or '',
|
|
'stock': r[6], 'total_sold': r[7]
|
|
})
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({'parts': parts, 'count': len(parts)})
|
|
|
|
return app
|
|
|
|
# Expose at module level for Gunicorn: gunicorn -w 2 -b 0.0.0.0:5001 app:app
|
|
app = create_app()
|
|
|
|
if __name__ == '__main__':
|
|
app.run(host='0.0.0.0', port=5001, debug=True)
|