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

@@ -2,6 +2,7 @@
"""Config blueprint: tenant configuration, branches, theming."""
import json
import re
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn
@@ -14,6 +15,7 @@ _DEFAULT_ROLE_PERMISSIONS = {
'owner': [], # owner bypasses permission checks
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
'yonke.view', 'yonke.manage',
'catalog.view', 'catalog.edit',
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
'invoicing.view', 'invoicing.create',
@@ -40,6 +42,7 @@ _DEFAULT_ROLE_PERMISSIONS = {
'fleet.view'],
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
'mechanic': ['workshop.view'],
'driver': ['pos.remission'],
'sales': ['pos.sell', 'pos.discount', 'pos.view', 'catalog.view',
'customers.view', 'customers.create'],
}
@@ -108,6 +111,10 @@ _AVAILABLE_PERMISSIONS = [
{'key': 'inventory.import', 'label': 'Importar artículos masivamente'},
{'key': 'inventory.view_cost', 'label': 'Ver costos'},
]},
{'module': 'Yonke', 'permissions': [
{'key': 'yonke.view', 'label': 'Ver Yonke (partes usadas)'},
{'key': 'yonke.manage', 'label': 'Gestionar Yonke (piezas y consignaciones)'},
]},
{'module': 'Catálogo', 'permissions': [
{'key': 'catalog.view', 'label': 'Ver catálogo'},
{'key': 'catalog.edit', 'label': 'Editar catálogo'},
@@ -433,7 +440,7 @@ def create_employee():
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic']
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic', 'driver']
if data['role'] not in valid_roles:
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
@@ -455,6 +462,14 @@ def create_employee():
(emp_id, perm)
)
# Drivers also get a courier record so they can be assigned to deliveries
if data['role'] == 'driver':
cur.execute("""
INSERT INTO couriers (tenant_id, name, code, is_active)
VALUES (%s, %s, %s, true)
ON CONFLICT (tenant_id, code) DO NOTHING
""", (g.tenant_id, data['name'], f'emp_{emp_id}'))
from services.audit import log_action
log_action(conn, 'EMPLOYEE_CREATE', 'employee', emp_id,
new_value={'name': data['name'], 'role': data['role']})
@@ -617,6 +632,15 @@ def update_employee(emp_id):
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(emp_id, perm)
)
# Drivers also get a courier record so they can be assigned to deliveries
if data['role'] == 'driver':
cur.execute("SELECT name FROM employees WHERE id = %s", (emp_id,))
emp_name = cur.fetchone()[0]
cur.execute("""
INSERT INTO couriers (tenant_id, name, code, is_active)
VALUES (%s, %s, %s, true)
ON CONFLICT (tenant_id, code) DO NOTHING
""", (g.tenant_id, emp_name, f'emp_{emp_id}'))
from services.audit import log_action
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
@@ -876,16 +900,15 @@ def get_billing():
@config_bp.route('/billing/upgrade', methods=['POST'])
@require_auth('config.edit')
def upgrade_billing():
"""Upgrade tenant plan."""
from services.billing import upgrade_plan
data = request.get_json() or {}
new_plan = data.get('plan')
if not new_plan:
return jsonify({'error': 'plan required'}), 400
result = upgrade_plan(g.tenant_id, new_plan)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
"""Plan changes are handled by Nexus support, never self-service.
This endpoint previously let any tenant owner upgrade themselves to
`enterprise` (unlimited) for free — a direct revenue leak. It now
refuses every change; plans are managed from the master DB / admin panel.
"""
return jsonify({
'error': 'Los cambios de plan los gestiona Nexus. Contacta a soporte para actualizar tu plan.'
}), 403
# ─── Vehicle Compatibility Source ────────────────────
@@ -1000,6 +1023,49 @@ def update_allowed_brands():
# ─── WhatsApp Configuration ────────────────────────────────────────────────
INVOICE_TOGGLE_KEYS = {
'invoice_auto': 'invoice_auto',
'invoice_email': 'invoice_email',
'invoice_global': 'invoice_global',
}
@config_bp.route('/invoice-settings', methods=['GET'])
@require_auth('config.view')
def get_invoice_settings():
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key = ANY(%s)",
(list(INVOICE_TOGGLE_KEYS.values()),))
rows = {r[0]: r[1] for r in cur.fetchall()}
cur.close()
conn.close()
return jsonify({
'invoice_auto': rows.get('invoice_auto', 'false').lower() == 'true',
'invoice_email': rows.get('invoice_email', 'false').lower() == 'true',
'invoice_global': rows.get('invoice_global', 'false').lower() == 'true',
})
@config_bp.route('/invoice-settings', methods=['PUT'])
@require_auth('config.edit')
def update_invoice_settings():
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
for json_key, cfg_key in INVOICE_TOGGLE_KEYS.items():
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (cfg_key, 'true' if data.get(json_key) else 'false'))
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True})
@config_bp.route('/whatsapp', methods=['GET'])
@require_auth('config.view')
def get_whatsapp_config():
@@ -1016,6 +1082,14 @@ def get_whatsapp_config():
'bridge_key': rows.get('whatsapp_bridge_key', ''),
'enabled': rows.get('whatsapp_enabled', 'false').lower() == 'true',
'phone_number': rows.get('whatsapp_phone_number', ''),
'provider': rows.get('whatsapp_provider', 'baileys'),
'phone_number_id': rows.get('wa_phone_number_id', ''),
'has_access_token': bool(rows.get('wa_access_token')),
'has_app_secret': bool(rows.get('wa_app_secret')),
'verify_token': rows.get('wa_verify_token', ''),
'api_base': rows.get('wa_api_base', ''),
'has_api_key': bool(rows.get('wa_api_key')),
'public_base': rows.get('wa_public_base', ''),
})
@@ -1032,7 +1106,18 @@ def update_whatsapp_config():
'whatsapp_bridge_key': data.get('bridge_key', ''),
'whatsapp_enabled': 'true' if data.get('enabled') else 'false',
'whatsapp_phone_number': data.get('phone_number', ''),
'whatsapp_provider': data.get('provider', 'baileys'),
'wa_phone_number_id': data.get('phone_number_id', ''),
'wa_verify_token': data.get('verify_token', ''),
'wa_api_base': data.get('api_base', ''),
'wa_public_base': data.get('public_base', ''),
}
if data.get('api_key'):
settings['wa_api_key'] = data['api_key']
if data.get('access_token'):
settings['wa_access_token'] = data['access_token']
if data.get('app_secret'):
settings['wa_app_secret'] = data['app_secret']
for key, value in settings.items():
cur.execute("""
@@ -1047,26 +1132,151 @@ def update_whatsapp_config():
return jsonify({'message': 'WhatsApp configuration updated'})
# ─── Contactos de alerta (POR NEGOCIO) ─────────────────────────────────────
# Los contactos que reciben alertas por WhatsApp son POR NEGOCIO: cada tenant
# configura sus propios números en su `tenant_config` (`alert_phones`,
# `alert_whatsapp_enabled`). No hay ningún teléfono global obligatorio; si un
# negocio no configura nada, sus alertas simplemente no se envían (no-op, sin
# error). El webhook de Alertmanager (`pos/app.py`) lee estas mismas claves.
# `+` opcional seguido de 10-15 dígitos (formato internacional).
_ALERT_PHONE_RE = re.compile(r'^\+?\d{10,15}$')
def _normalize_alert_phones(raw):
"""Normaliza contactos separados por comas.
Acepta una cadena ("+5215512345678, 5215587654321") o una lista/tupla.
Recorta espacios, valida cada número (`+` opcional + 10-15 dígitos) y
descarta el resto. Devuelve la tupla (validos, descartados).
"""
if isinstance(raw, (list, tuple)):
chunks = []
for item in raw:
chunks.extend(str(item).split(','))
else:
chunks = str(raw or '').split(',')
valid, dropped = [], []
for chunk in chunks:
phone = chunk.strip()
if not phone:
continue
if _ALERT_PHONE_RE.match(phone):
if phone not in valid:
valid.append(phone)
else:
dropped.append(phone)
return valid, dropped
@config_bp.route('/alert-contacts', methods=['GET'])
@require_auth('config.view')
def get_alert_contacts():
"""Contactos de alerta por WhatsApp de ESTE negocio (tenant).
Son por negocio, nunca globales. `alert_phones` se devuelve como lista
normalizada; `alert_whatsapp_enabled` indica si el reenvío está activado.
Si el negocio no ha configurado nada, devuelve lista vacía y `false`
(las alertas serán no-op). `inherited_global` siempre es `false`: no se
hereda ningún teléfono global.
"""
conn = get_tenant_conn(g.tenant_id)
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, _ = _normalize_alert_phones(rows.get('alert_phones'))
return jsonify({
'alert_phones': phones,
'alert_whatsapp_enabled': str(rows.get('alert_whatsapp_enabled', 'false')).lower() == 'true',
'inherited_global': False,
})
@config_bp.route('/alert-contacts', methods=['PUT'])
@require_auth('config.edit')
def update_alert_contacts():
"""Guarda los contactos de alerta por WhatsApp de ESTE negocio.
Normaliza `alert_phones` (separa por comas, recorta y descarta basura:
solo se aceptan `+` opcional + 10-15 dígitos) y guarda
`alert_whatsapp_enabled` como booleano. Un payload vacío es válido y deja
al negocio sin contactos (alertas no-op). Devuelve lo guardado.
"""
data = request.get_json(silent=True)
if data is None:
data = {}
if not isinstance(data, dict):
return jsonify({'error': 'JSON object required'}), 400
phones, dropped = _normalize_alert_phones(data.get('alert_phones'))
enabled = bool(data.get('alert_whatsapp_enabled'))
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('alert_phones', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (','.join(phones),))
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('alert_whatsapp_enabled', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", ('true' if enabled else 'false',))
conn.commit()
cur.close()
finally:
conn.close()
if dropped:
import logging
logging.getLogger(__name__).warning(
"alert-contacts: %d contacto(s) descartado(s) para tenant %s",
len(dropped), g.tenant_id,
)
return jsonify({
'alert_phones': phones,
'alert_whatsapp_enabled': enabled,
'inherited_global': False,
'dropped': dropped,
})
@config_bp.route('/modules', methods=['GET'])
@require_auth()
def get_modules():
"""Get enabled modules for this tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%'")
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%' OR key LIKE '%enabled'")
rows = {row[0]: row[1] for row in cur.fetchall()}
cur.close()
conn.close()
def _bool(key):
return rows.get(key, 'true').lower() == 'true'
def _bool(name):
# Unificado 2026-10-11: el POS escribe 'module_X' y el dashboard 'X_enabled';
# aceptamos ambos para que el toggle se refleje en las dos capas.
v = rows.get('module_' + name)
if v is None:
v = rows.get(name + '_enabled')
return (v if v is not None else 'true').lower() == 'true'
return jsonify({
'whatsapp': _bool('module_whatsapp'),
'marketplace': _bool('module_marketplace'),
'meli': _bool('module_meli'),
'catalog': _bool('module_catalog'),
'workshop': _bool('module_workshop'),
'whatsapp': _bool('whatsapp'),
'marketplace': _bool('marketplace'),
'meli': _bool('meli'),
'catalog': _bool('catalog'),
'workshop': _bool('workshop'),
'yonke': _bool('yonke'),
})
@@ -1078,13 +1288,14 @@ def update_modules():
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
settings = {
'module_whatsapp': 'true' if data.get('whatsapp') else 'false',
'module_marketplace': 'true' if data.get('marketplace') else 'false',
'module_meli': 'true' if data.get('meli') else 'false',
'module_catalog': 'true' if data.get('catalog') else 'false',
'module_workshop': 'true' if data.get('workshop') else 'false',
}
# Doble escritura: 'module_X' (POS) + 'X_enabled' (dashboard/consumidores).
# Solo se tocan las claves presentes en el payload (no se pisan las demás).
settings = {}
for name in ('whatsapp', 'marketplace', 'meli', 'catalog', 'workshop', 'yonke'):
if name in data:
val = 'true' if data.get(name) else 'false'
settings['module_' + name] = val
settings[name + '_enabled'] = val
for key, value in settings.items():
cur.execute("""
@@ -1222,6 +1433,7 @@ RECEIPT_CONFIG_KEYS = [
'receipt_show_payment_details',
'receipt_show_employee',
'receipt_paper_width',
'receipt_auto_print',
]
@@ -1258,6 +1470,7 @@ def get_receipt_config():
'show_phone': _bool('receipt_show_phone', False),
'show_iva_breakdown': _bool('receipt_show_iva_breakdown', True),
'show_payment_details': _bool('receipt_show_payment_details', True),
'auto_print': _bool('receipt_auto_print', False),
'show_employee': _bool('receipt_show_employee', False),
'paper_width': rows.get('receipt_paper_width', '80') or '80',
})