Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- 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:
@@ -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',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user