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/') 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/') 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)