Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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
This commit is contained in:
2026-10-12 00:57:31 +00:00
parent f2d4558b80
commit a386ca5b4a
165 changed files with 31313 additions and 2203 deletions

View File

@@ -1,5 +1,201 @@
from flask import Flask, request, redirect, make_response
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():
@@ -10,11 +206,128 @@ def create_app():
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
@@ -25,10 +338,68 @@ def create_app():
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)
@@ -128,11 +499,25 @@ def create_app():
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')
@@ -242,6 +627,10 @@ def create_app():
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)
@@ -297,7 +686,7 @@ def create_app():
cur.execute("""
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate,
i.category, COALESCE(s.stock, 0) AS stock,
i.category_id, COALESCE(s.stock, 0) AS stock,
COALESCE(sv.total_sold, 0) AS total_sold
FROM inventory i
LEFT JOIN (