feat: module toggles in POS config and Instance Manager

- Add GET/PUT /pos/api/config/modules endpoints in POS config_bp.py
- Update sidebar.js to filter nav items based on enabled modules
- Add Modules section to POS config.html with toggles for WhatsApp, Marketplace, MercadoLibre
- Add module load/save logic to POS config.js
- Preload modules in app-init.js for sidebar caching

- Add tenant module management to Instance Manager
  - get_tenant_modules / update_tenant_modules in tenant_service.py
  - GET/PUT /api/tenants/<id>/modules endpoints in tenants_bp.py
  - Add modules modal to manager index.html
  - Add module editing UI and logic to manager.js
  - Add toggle-switch CSS to manager.css
This commit is contained in:
2026-05-28 00:21:52 +00:00
parent 999591e248
commit 718fa06888
26 changed files with 2614 additions and 429 deletions

View File

@@ -579,6 +579,58 @@ def update_whatsapp_config():
return jsonify({'message': 'WhatsApp configuration updated'})
@config_bp.route('/modules', methods=['GET'])
@require_auth('config.view')
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_%'")
rows = {row[0]: row[1] for row in cur.fetchall()}
cur.close()
conn.close()
def _bool(key):
return rows.get(key, 'true').lower() == 'true'
return jsonify({
'whatsapp': _bool('module_whatsapp'),
'marketplace': _bool('module_marketplace'),
'meli': _bool('module_meli'),
})
@config_bp.route('/modules', methods=['PUT'])
@require_auth('config.edit')
def update_modules():
"""Update enabled modules for this tenant."""
data = request.get_json() or {}
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',
}
for key, value in settings.items():
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (key, value))
conn.commit()
cur.close()
conn.close()
return jsonify({'message': 'Modules updated', 'modules': {
'whatsapp': data.get('whatsapp'),
'marketplace': data.get('marketplace'),
'meli': data.get('meli'),
}})
@config_bp.route('/onboarding-status', methods=['GET'])
@require_auth('pos.view')
def get_onboarding_status():

View File

@@ -1864,6 +1864,14 @@ def complete_layaway(layaway_id):
new_value={'sale_id': sale['id'], 'total': total})
conn.commit()
# WhatsApp learning hook (non-blocking)
try:
from services.wa_learning import check_learning_resolution
check_learning_resolution(sale['id'], cust_id, conn)
except Exception:
pass
cur.close(); conn.close()
return jsonify(sale), 201

View File

@@ -16,6 +16,7 @@ from middleware import require_auth
from tenant_db import get_tenant_conn, get_master_conn
from services import whatsapp_service
from config import WHATSAPP_BRIDGE_URL, WHATSAPP_BRIDGE_KEY
from datetime import datetime
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
@@ -50,6 +51,27 @@ def _get_whatsapp_config(conn):
}
def _get_branch_phone(tenant_conn, branch_id=None):
"""Obtener teléfono de la sucursal."""
if not tenant_conn:
return '(pendiente)'
try:
cur = tenant_conn.cursor()
if branch_id:
cur.execute("SELECT phone FROM branches WHERE id = %s", (branch_id,))
row = cur.fetchone()
if row and row[0]:
cur.close()
return row[0]
cur.execute("SELECT value FROM tenant_config WHERE key = 'tenant_phone'")
row = cur.fetchone()
cur.close()
return row[0] if row and row[0] else '(pendiente)'
except Exception as e:
print(f"[WA-SM] get_branch_phone error: {e}")
return '(pendiente)'
def _resolve_mye_ids(vehicle, master_conn):
"""Return list of MYE ids matching vehicle brand/model/year text."""
if not master_conn or not vehicle:
@@ -329,11 +351,7 @@ def logout():
def webhook():
"""Receive messages from Baileys bridge (public, no auth).
Flow:
1. Persist the incoming message to the tenant's whatsapp_messages log.
2. Build inventory context for the AI (what this tenant has in stock).
3. Ask the chatbot for a reply, enriched with that context.
4. Send the reply back via the Baileys bridge.
Nuevo flujo: máquina de estados estructurada.
"""
data = request.get_json(force=True, silent=True) or {}
@@ -344,421 +362,227 @@ def webhook():
if not msg.get('phone') or msg.get('from_me'):
return jsonify({'ok': True})
# Resolve tenant: try query param first, then fallback to first enabled tenant
phone = msg['phone']
reply_to = msg.get('sender_pn') or msg.get('jid') or phone
text = msg.get('text', '')
media_kind = msg.get('media_kind', 'text')
# Audio transcription (voice notes)
if media_kind == 'audio' and msg.get('media_base64'):
try:
from services.whisper_local import transcribe_audio_base64
transcript = transcribe_audio_base64(
msg['media_base64'],
mimetype=msg.get('media_mimetype') or 'audio/ogg',
)
if transcript:
text = transcript
print(f"[WA-SM] Voice note transcribed: {transcript[:100]}")
except ImportError:
pass
except Exception as e:
print(f"[WA-SM] Whisper transcription failed: {e}")
# Location message: if current state expects it, store coordinates
if media_kind == 'location' and msg.get('latitude') is not None:
text = f"Ubicación: {msg['latitude']},{msg['longitude']}"
# Image without caption: provide a default text so the state machine can handle it
if media_kind == 'image' and not text:
text = "(imagen)"
# Resolve tenant
tenant_id = request.args.get('tenant_id', type=int)
if not tenant_id:
# Fallback: find first tenant with whatsapp enabled
try:
mconn = get_master_conn()
mcur = mconn.cursor()
mcur.execute("""
SELECT t.id FROM tenants t
JOIN tenant_config c ON c.key = 'whatsapp_enabled' AND c.value = 'true'
WHERE t.is_active = true
ORDER BY t.id LIMIT 1
SELECT id, db_name FROM tenants
WHERE is_active = true
ORDER BY id
""")
row = mcur.fetchone()
tenants = mcur.fetchall()
mcur.close()
mconn.close()
tenant_id = row[0] if row else None
# Find first tenant with whatsapp_enabled in their config
for tid, db_name in tenants:
try:
from tenant_db import get_tenant_conn_by_dbname
tconn = get_tenant_conn_by_dbname(db_name)
tcur = tconn.cursor()
tcur.execute(
"SELECT value FROM tenant_config WHERE key = 'whatsapp_enabled'"
)
row = tcur.fetchone()
tcur.close()
tconn.close()
if row and row[0].lower() == 'true':
tenant_id = tid
break
except Exception:
continue
except Exception:
tenant_id = None
# Prepare phone and reply target early
reply_to = msg.get('jid') or msg['phone']
media_kind = msg.get('media_kind', 'text')
clean_phone = msg.get('phone', '')
tenant_conn = None
master_conn = None
inventory_context = None
wa_config = {}
try:
tenant_conn = get_tenant_conn(tenant_id)
master_conn = get_master_conn()
wa_config = _get_whatsapp_config(tenant_conn)
# 1. Log the incoming message (with contact display name)
# Deduplicate by wa_message_id
wa_message_id = msg.get('message_id')
if wa_message_id:
cur = tenant_conn.cursor()
cur.execute("SELECT 1 FROM whatsapp_messages WHERE wa_message_id = %s LIMIT 1", (wa_message_id,))
if cur.fetchone():
cur.close()
return jsonify({'ok': True})
cur.close()
# 1. Log incoming message
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
VALUES (%s, 'incoming', %s, %s, %s)
ON CONFLICT DO NOTHING
""", (msg['phone'], msg['text'], msg['message_id'], msg.get('push_name') or None))
""", (phone, text, wa_message_id, msg.get('push_name')))
tenant_conn.commit()
cur.close()
# 2. Build inventory context once per webhook call so the chatbot
# can say things like "tengo 5 Bosch BP-123 por $450".
try:
from services.ai_chat import get_inventory_context
inventory_context = get_inventory_context(tenant_conn)
except Exception as e:
print(f"[WA-AI] inventory_context failed: {e}")
inventory_context = None
# 2. Load session state
from services.wa_state_machine import get_session, save_session, process_message, StateContext
session = get_session(tenant_conn, phone)
# 2c. Urgency detection — if customer signals urgency, add a note
try:
from services.part_kits import is_urgent, urgency_note
if msg.get('text') and is_urgent(msg['text']):
if inventory_context:
inventory_context += urgency_note()
else:
inventory_context = urgency_note().strip()
except Exception as e:
print(f"[WA-AI] urgency detection failed: {e}")
# 3. Check session expiry (30 minutes)
current_state = session.get('state', 'idle')
state_data = session.get('state_data', {})
last_updated = session.get('updated_at')
# 2d. Purchase history — append recent confirmed orders for this customer
try:
from services.part_kits import get_purchase_history
history = get_purchase_history(clean_phone, tenant_conn)
if history:
if inventory_context:
inventory_context += "\n\n" + history
else:
inventory_context = history
except Exception as e:
print(f"[WA-AI] Purchase history failed: {e}")
# 2b. Append previously-detected vehicle so the AI keeps context
# even when we don't send full conversation history (Hermes is slow with it)
try:
from services.wa_quotation import get_vehicle
saved_vehicle = get_vehicle(tenant_conn, clean_phone)
if saved_vehicle and inventory_context:
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
if v_str:
inventory_context += f"\n\nVEHICULO DEL CLIENTE: {v_str}"
elif saved_vehicle:
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
if v_str:
inventory_context = f"VEHICULO DEL CLIENTE: {v_str}"
except Exception as e:
print(f"[WA-AI] vehicle_context failed: {e}")
except Exception as e:
print(f"[WA-AI] tenant connection failed: {e}")
if tenant_conn:
if last_updated and hasattr(last_updated, 'strftime'):
# PostgreSQL returns datetime objects (often timezone-aware)
from datetime import timezone
now = datetime.now(timezone.utc)
if last_updated.tzinfo is None:
now = now.replace(tzinfo=None)
elapsed = (now - last_updated).total_seconds()
if elapsed > 1800:
current_state = 'idle'
state_data = {'customer_id': state_data.get('customer_id')}
elif last_updated and isinstance(last_updated, str):
from datetime import datetime as dt
try:
tenant_conn.rollback()
parsed = dt.fromisoformat(last_updated.replace('Z', '+00:00'))
elapsed = (dt.now(dt.now().astimezone().tzinfo) - parsed).total_seconds()
if elapsed > 1800:
current_state = 'idle'
state_data = {'customer_id': state_data.get('customer_id')}
except Exception:
pass
# 3. Dispatch by media kind + quotation commands
reply = None
# Global reset commands work from any state
if text and text.strip().lower() in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar', 'menu', 'menú'):
current_state = 'idle'
state_data = {'customer_id': state_data.get('customer_id')}
# ── Abandoned quotation follow-up ──
# If customer has an active quote and hasn't interacted in 15+ min,
# send a gentle nudge before processing their current message.
try:
from services.part_kits import should_send_followup
followup = should_send_followup(clean_phone, tenant_conn)
if followup:
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
if tenant_conn:
# Abandoned quotation follow-up
try:
from services.part_kits import should_send_followup
followup = should_send_followup(phone, tenant_conn)
if followup:
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
cur_fu = tenant_conn.cursor()
cur_fu.execute(
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
(clean_phone, followup)
(phone, followup)
)
tenant_conn.commit()
cur_fu.close()
except Exception as fu_err:
print(f"[WA-AI] Follow-up send failed: {fu_err}")
except Exception as fu_err:
print(f"[WA-SM] Follow-up send failed: {fu_err}")
# ── Location message → nearest branch ──
if media_kind == 'location' and msg.get('latitude') is not None and msg.get('longitude') is not None:
from services.geo_branches import find_nearest_branch
nearest = find_nearest_branch(tenant_conn, msg['latitude'], msg['longitude'])
if nearest:
reply = (
f"📍 *Sucursal más cercana:*\n\n"
f"*{nearest['name']}*\n"
f"📌 {nearest['address']}\n"
f"📞 {nearest['phone']}\n"
f"🚗 Aprox. *{nearest['distance_km']} km* de tu ubicación\n\n"
f"¿Te gustaría recoger tu pedido ahí o prefieres envío a domicilio?"
)
else:
reply = (
"📍 Gracias por tu ubicación.\n\n"
"Actualmente no tenemos sucursales registradas con coordenadas. "
"¿En qué ciudad te encuentras? Te puedo indicar nuestras opciones de envío."
)
# ── Check for quotation commands FIRST (before AI) ──
if not reply and media_kind == 'text' and msg.get('text'):
from services.wa_quotation import (
detect_quote_intent, get_open_quotation, create_quotation,
add_item_to_quotation, get_quotation_detail, format_quotation_wa,
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part,
# 4. Build context
context = StateContext(
tenant_conn=tenant_conn,
master_conn=master_conn,
wa_config=wa_config,
tenant_id=tenant_id,
phone=phone,
media_kind=media_kind,
media_base64=msg.get('media_base64'),
push_name=msg.get('push_name'),
)
from services.quote_image import generate_quote_image
from services.whatsapp_service import send_image
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone))
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open)
if intent == 'add':
last_part = get_last_shown_part(tenant_conn, clean_phone)
if not last_part:
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
elif tenant_conn:
qid = get_open_quotation(tenant_conn, clean_phone)
if not qid:
qid = create_quotation(tenant_conn, clean_phone)
add_item_to_quotation(tenant_conn, qid, last_part, quantity=qty or 1)
detail = get_quotation_detail(tenant_conn, qid)
item_count = len(detail['items']) if detail else 0
reply = (
f'✅ *{last_part.get("name", "")}* × {qty or 1} agregado a tu cotización.\n'
f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n'
f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._'
)
# Smart kit suggestion — cross-sell related parts
try:
from services.part_kits import build_kit_text
kit_text = build_kit_text(last_part.get('name', ''))
if kit_text:
reply += kit_text
except Exception as kit_err:
print(f"[WA-AI] Kit suggestion failed: {kit_err}")
# 5. Process through state machine
reply, next_state, next_state_data = process_message(
phone=phone,
text=text,
current_state=current_state,
state_data=state_data,
context=context,
)
elif intent == 'send':
if tenant_conn:
qid = get_open_quotation(tenant_conn, clean_phone)
if qid:
detail = get_quotation_detail(tenant_conn, qid)
reply = format_quotation_wa(detail)
if not reply:
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
else:
# Generate rich visual quote image and send it
try:
quote_items = []
for it in detail.get('items', []):
quote_items.append({
'name': it.get('name', ''),
'sku': it.get('sku', ''),
'qty': it.get('quantity', 1),
'price': float(it.get('unit_price', 0)),
'total': float(it.get('total', 0)),
})
totals = {
'subtotal': float(detail.get('subtotal', 0)),
'tax': float(detail.get('tax', 0)),
'total': float(detail.get('total', 0)),
}
tenant_name = tenant_config.get('business_name', 'Autopartes')
b64_img = generate_quote_image(quote_items, totals, tenant_name=tenant_name)
img_result = send_image(clean_phone, caption="Aquí está tu cotización 👇", base64_image=b64_img, bridge_url=bridge_url)
if img_result.get('success'):
reply = "📎 *Te envié tu cotización en imagen.*\n\n" + reply
else:
print(f"[WA-AI] Image send failed: {img_result}")
except Exception as img_err:
print(f"[WA-AI] Quote image generation failed: {img_err}")
else:
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
elif intent == 'clear':
if tenant_conn:
clear_quotation(tenant_conn, clean_phone)
reply = '🗑️ Cotización limpiada. Pregunta por partes para empezar una nueva.'
elif intent == 'confirm':
if tenant_conn:
qid = confirm_quotation(tenant_conn, clean_phone)
if qid:
reply = (
f'✅ *Pedido confirmado!*\n\n'
f'Tu cotización #{qid} fue registrada.\n'
f'Nos pondremos en contacto contigo para coordinar la entrega/recolección.\n\n'
f'¡Gracias por tu compra! 🙏'
)
else:
reply = '⚠️ No tienes una cotización abierta para confirmar.'
# ── Check for conversation reset commands ──
if media_kind == 'text' and msg.get('text'):
txt_lower = msg['text'].lower().strip()
if txt_lower in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar'):
if tenant_conn:
try:
cur_del = tenant_conn.cursor()
cur_del.execute("DELETE FROM whatsapp_messages WHERE phone = %s", (clean_phone,))
tenant_conn.commit()
cur_del.close()
except Exception as del_err:
print(f"[WA-AI] Failed to clear conversation history: {del_err}")
reply = '🗑️ *Conversación reiniciada.*\n\n¡Hola de nuevo! ¿En qué puedo ayudarte?'
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
if tenant_conn:
try:
cur_save = tenant_conn.cursor()
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
tenant_conn.commit()
cur_save.close()
except Exception:
pass
if tenant_conn:
try: tenant_conn.close()
except Exception: pass
return jsonify({'ok': True})
if intent is not None:
# It was a quote command — send reply and skip the AI
if reply:
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
if tenant_conn:
try:
cur_save = tenant_conn.cursor()
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
tenant_conn.commit()
cur_save.close()
except Exception:
pass
# Clean up and return early
if tenant_conn:
try: tenant_conn.close()
except Exception: pass
return jsonify({'ok': True})
# Load conversation history so the AI remembers context (vehicle, parts, etc.)
conversation_history = []
if tenant_conn:
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=4)
if conversation_history:
print(f"[WA-AI] Loaded {len(conversation_history)} history messages for {clean_phone}")
try:
if media_kind == 'image' and msg.get('media_base64'):
from services.ai_chat import chat_with_image
# Prompt: use the caption if provided, else default to
# "identify this part" which chat_with_image handles gracefully.
prompt = msg.get('text') or 'Identifica esta parte automotriz y sugiere terminos de busqueda.'
ai_resp = chat_with_image(
user_message=prompt,
image_base64=msg['media_base64'],
conversation_history=conversation_history,
inventory_context=inventory_context,
# 5b. Si el estado transicionó sin mensaje, procesar el siguiente inmediatamente
# (algunos estados solo hacen transiciones y delegan el mensaje al siguiente estado)
loop_guard = 0
while reply is None and loop_guard < 5:
loop_guard += 1
reply, next_state, next_state_data = process_message(
phone=phone,
text=text,
current_state=next_state,
state_data=next_state_data,
context=context,
)
reply = ai_resp.get('message', '') or ''
print(f"[WA-AI] Image from {reply_to}: {reply[:80]}...")
elif media_kind == 'audio' and msg.get('media_base64'):
# Voice note handling — transcribe first, then chat().
# See services.whisper_local for the transcriber.
try:
from services.whisper_local import transcribe_audio_base64
transcript = transcribe_audio_base64(
msg['media_base64'],
mimetype=msg.get('media_mimetype') or 'audio/ogg',
)
except ImportError:
transcript = None
print("[WA-AI] whisper_local not installed — voice notes skipped")
except Exception as e:
transcript = None
print(f"[WA-AI] Whisper transcription failed: {e}")
# 6. Save new state
save_session(tenant_conn, phone, next_state, next_state_data)
if transcript:
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
from services.ai_chat import chat
ai_resp = chat(transcript, conversation_history=conversation_history, inventory_context=inventory_context)
reply = ai_resp.get('message', '') or ''
# Prefix the reply so the sender knows we understood the voice note
if reply:
reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}'
else:
reply = ('Recibi tu nota de voz pero no pude transcribirla. '
'Puedes escribirme el mensaje?')
elif msg.get('text'):
txt = msg['text'].strip().lower()
# Quick welcome menu for new customers with no vehicle
is_greeting = txt in ('hola', 'buenos dias', 'buenas tardes', 'buenas noches', 'hey', 'que onda', 'saludos')
if is_greeting:
try:
from services.wa_quotation import get_vehicle
veh = get_vehicle(tenant_conn, clean_phone)
if not veh:
reply = (
"¡Qué onda! Bienvenido a *Autopartes Estrada*.\n\n"
"Soy Juan, tu vendedor. Para ayudarte rápido, dime:\n\n"
"1⃣ *Marca, modelo y año* de tu vehículo\n"
"2⃣ La *parte* que necesitas\n"
"3⃣ O escribe *menú* para ver opciones\n\n"
'_Ejemplo: "Necesito balatas para Tsuru 2015"_'
)
except Exception:
pass
if not reply:
# Plain text message — standard chatbot flow
from services.ai_chat import chat
ai_resp = chat(msg['text'], conversation_history=conversation_history, inventory_context=inventory_context)
reply = ai_resp.get('message', '') or ''
# Enrich: if the AI returned a search_query, look up real parts
# from the catalog and append them to the WhatsApp reply.
search_q = ai_resp.get('search_query')
vehicle = ai_resp.get('vehicle')
# Persist detected vehicle so we don't lose context between messages
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'):
try:
from services.wa_quotation import set_vehicle
set_vehicle(tenant_conn, clean_phone, vehicle)
except Exception as veh_err:
print(f"[WA-AI] Failed to save vehicle: {veh_err}")
if search_q and reply:
try:
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn, master_conn)
if enrichment:
reply = reply + '\n\n' + enrichment
elif not found_part and vehicle and vehicle.get('brand'):
# Only say "not in stock" when we have a specific vehicle
# and still found nothing. Otherwise let the AI ask for vehicle info.
reply = reply + '\n\n' + "_No tengo esa pieza exacta en stock para tu modelo ahora, pero puedo pedirla por encargo o buscar alternativas. ¿Te interesa?_"
# Track the found part so "cotizar" can add it
if found_part:
from services.wa_quotation import set_last_shown_part
set_last_shown_part(tenant_conn, clean_phone, found_part)
except Exception as enrich_err:
print(f"[WA-AI] Enrichment failed: {enrich_err}")
# Send reply if we produced one
# 7. Send reply
if reply:
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}")
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
# Save the bot's reply to DB so it shows in the WhatsApp UI
if tenant_conn:
try:
cur2 = tenant_conn.cursor()
cur2.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text)
VALUES (%s, 'outgoing', %s)
""", (msg['phone'], reply))
tenant_conn.commit()
cur2.close()
except Exception as db_err:
print(f"[WA-AI] Failed to save bot reply to DB: {db_err}")
# Log outgoing
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text)
VALUES (%s, 'outgoing', %s)
""", (phone, reply))
tenant_conn.commit()
cur.close()
except Exception as e:
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}")
print(f"[WA-SM] Webhook error: {e}")
import traceback
traceback.print_exc()
# Fallback: enviar mensaje de error genérico
try:
if tenant_conn:
phone_branch = _get_branch_phone(tenant_conn, None)
fallback = (
"Estoy teniendo problemas técnicos en este momento. 😕\n\n"
f"Por favor llámanos directamente al {phone_branch}."
)
whatsapp_service.send_message(reply_to, fallback, bridge_url=wa_config.get('bridge_url'))
except Exception:
pass
# 4. Clean up connections
if tenant_conn is not None:
try:
tenant_conn.close()
except Exception:
pass
if master_conn is not None:
try:
master_conn.close()
except Exception:
pass
finally:
if tenant_conn:
try:
tenant_conn.close()
except Exception:
pass
if master_conn:
try:
master_conn.close()
except Exception:
pass
return jsonify({'ok': True})

View File

@@ -0,0 +1,100 @@
-- ============================================================
-- v3.5 WhatsApp State Machine
-- Reorganización del chatbot de AI libre a flujo estructurado
-- ============================================================
-- 1. Extender whatsapp_sessions con estado y contexto
-- ---------------------------------------------------
ALTER TABLE whatsapp_sessions
ADD COLUMN IF NOT EXISTS state VARCHAR(50) DEFAULT 'idle',
ADD COLUMN IF NOT EXISTS state_data JSONB DEFAULT '{}',
ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id),
ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id),
ADD COLUMN IF NOT EXISTS learning_cycle INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
-- Índices para lookups rápidos de sesión
CREATE INDEX IF NOT EXISTS idx_wa_sessions_state ON whatsapp_sessions(state);
CREATE INDEX IF NOT EXISTS idx_wa_sessions_customer ON whatsapp_sessions(customer_id);
CREATE INDEX IF NOT EXISTS idx_wa_sessions_updated ON whatsapp_sessions(updated_at);
-- 2. Tabla de vínculo persistente WA ID ↔ Cliente
-- ------------------------------------------------
CREATE TABLE IF NOT EXISTS wa_customer_links (
phone VARCHAR(50) PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id);
-- Trigger para updated_at
CREATE OR REPLACE FUNCTION update_wa_link_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links;
CREATE TRIGGER trg_wa_link_updated
BEFORE UPDATE ON wa_customer_links
FOR EACH ROW EXECUTE FUNCTION update_wa_link_timestamp();
-- 3. Tabla de sesiones de aprendizaje (piezas no resueltas)
-- ---------------------------------------------------------
CREATE TABLE IF NOT EXISTS wa_learning_sessions (
id SERIAL PRIMARY KEY,
phone VARCHAR(50) NOT NULL,
customer_id INTEGER REFERENCES customers(id),
description TEXT NOT NULL,
offered_parts JSONB DEFAULT '[]',
status VARCHAR(20) DEFAULT 'pending',
resolved_part_id INTEGER REFERENCES inventory(id),
resolution_sale_id INTEGER REFERENCES sales(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_wa_learn_phone ON wa_learning_sessions(phone);
CREATE INDEX IF NOT EXISTS idx_wa_learn_status ON wa_learning_sessions(status);
CREATE INDEX IF NOT EXISTS idx_wa_learn_customer ON wa_learning_sessions(customer_id);
CREATE INDEX IF NOT EXISTS idx_wa_learn_created ON wa_learning_sessions(created_at);
-- 4. Tabla de configuración de envío por sucursal
-- ------------------------------------------------
CREATE TABLE IF NOT EXISTS branch_delivery_config (
id SERIAL PRIMARY KEY,
branch_id INTEGER NOT NULL UNIQUE REFERENCES branches(id),
is_enabled BOOLEAN DEFAULT FALSE,
delivery_fee NUMERIC(12,2) DEFAULT 0,
free_delivery_threshold NUMERIC(12,2) DEFAULT NULL,
coverage_radius_km INTEGER DEFAULT NULL,
delivery_hours VARCHAR(100) DEFAULT 'Lun-Vie 9:00-18:00',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 5. Agregar push_name a whatsapp_messages (schema drift existente)
-- ------------------------------------------------------------------
ALTER TABLE whatsapp_messages
ADD COLUMN IF NOT EXISTS push_name VARCHAR(200);
-- 6. Migrar datos existentes: vincular por teléfono
-- --------------------------------------------------
-- Intentar vincular sesiones WA existentes con customers por teléfono
INSERT INTO wa_customer_links (phone, customer_id)
SELECT ws.phone, c.id
FROM whatsapp_sessions ws
JOIN customers c ON c.phone = ws.phone
WHERE ws.phone IS NOT NULL AND c.phone IS NOT NULL
ON CONFLICT (phone) DO NOTHING;
-- Actualizar customer_id en whatsapp_sessions desde el vínculo
UPDATE whatsapp_sessions ws
SET customer_id = wcl.customer_id
FROM wa_customer_links wcl
WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL;

View File

@@ -440,6 +440,13 @@ def process_sale(conn, sale_data):
except Exception:
pass # Savings errors never block sales
# WhatsApp learning hook (non-blocking)
try:
from services.wa_learning import check_learning_resolution
check_learning_resolution(sale_id, customer_id, conn)
except Exception:
pass # Learning errors never block sales
return {
'id': sale_id,
'branch_id': branch_id,

140
pos/services/wa_customer.py Normal file
View File

@@ -0,0 +1,140 @@
"""
WhatsApp Customer Service — identificación y vinculación de clientes.
Funciones para buscar, crear y vincular clientes desde el flujo de WhatsApp.
"""
import re
def find_customer_by_phone(phone, tenant_conn):
"""Buscar cliente por número de teléfono exacto o parcial."""
if not tenant_conn or not phone:
return []
cur = tenant_conn.cursor()
# Limpiar phone de prefijos internacionales para búsqueda flexible
clean = phone.replace('+52', '').replace('52', '').lstrip('1')
cur.execute("""
SELECT id, name, phone, address, rfc
FROM customers
WHERE phone = %s OR phone LIKE %s OR phone LIKE %s
LIMIT 5
""", (phone, f'%{clean}', f'%{clean[-10:]}' if len(clean) >= 10 else f'%{clean}'))
rows = cur.fetchall()
cur.close()
return [{'id': r[0], 'name': r[1], 'phone': r[2], 'address': r[3], 'rfc': r[4]} for r in rows]
def find_customer_by_name(name, tenant_conn):
"""Buscar cliente por nombre (ILIKE)."""
if not tenant_conn or not name:
return []
cur = tenant_conn.cursor()
# Buscar por nombre completo o primer palabra
first_word = name.split()[0] if name else name
cur.execute("""
SELECT id, name, phone, address, rfc
FROM customers
WHERE name ILIKE %s OR name ILIKE %s
LIMIT 5
""", (f'%{name}%', f'%{first_word}%'))
rows = cur.fetchall()
cur.close()
return [{'id': r[0], 'name': r[1], 'phone': r[2], 'address': r[3], 'rfc': r[4]} for r in rows]
def search_customers(query, tenant_conn):
"""Buscar por teléfono o nombre."""
if not tenant_conn or not query:
return []
# Detectar si es número de teléfono
digits = re.sub(r'\D', '', query)
if len(digits) >= 7:
by_phone = find_customer_by_phone(digits, tenant_conn)
if by_phone:
return by_phone
return find_customer_by_name(query, tenant_conn)
def get_customer_by_id(tenant_conn, customer_id):
"""Obtener cliente por ID."""
if not tenant_conn or not customer_id:
return None
cur = tenant_conn.cursor()
cur.execute("""
SELECT id, name, phone, address, rfc, vehicle_info
FROM customers WHERE id = %s
""", (customer_id,))
row = cur.fetchone()
cur.close()
if row:
return {
'id': row[0], 'name': row[1], 'phone': row[2],
'address': row[3], 'rfc': row[4], 'vehicle_info': row[5]
}
return None
def create_customer(tenant_conn, phone, name, email=None, address=None, rfc=None):
"""Crear cliente nuevo desde WhatsApp."""
if not tenant_conn:
return None
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO customers (name, phone, email, address, rfc, is_active, created_at)
VALUES (%s, %s, %s, %s, %s, TRUE, NOW())
RETURNING id
""", (name, phone, email, address, rfc))
cid = cur.fetchone()[0]
tenant_conn.commit()
cur.close()
return cid
def link_wa_customer(phone, customer_id, tenant_conn):
"""Vincular número WA a cliente permanentemente."""
if not tenant_conn or not phone or not customer_id:
return
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO wa_customer_links (phone, customer_id, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (phone) DO UPDATE SET customer_id = EXCLUDED.customer_id, updated_at = NOW()
""", (phone, customer_id))
tenant_conn.commit()
cur.close()
def get_linked_customer(phone, tenant_conn):
"""Obtener customer_id vinculado a un número WA."""
if not tenant_conn or not phone:
return None
cur = tenant_conn.cursor()
cur.execute("SELECT customer_id FROM wa_customer_links WHERE phone = %s", (phone,))
row = cur.fetchone()
cur.close()
return row[0] if row else None
def get_customer_address(tenant_conn, customer_id):
"""Obtener dirección del cliente."""
if not tenant_conn or not customer_id:
return None
cur = tenant_conn.cursor()
cur.execute("SELECT address FROM customers WHERE id = %s", (customer_id,))
row = cur.fetchone()
cur.close()
return row[0] if row and row[0] else None
def update_customer_address(tenant_conn, customer_id, address):
"""Actualizar dirección del cliente."""
if not tenant_conn or not customer_id or not address:
return
cur = tenant_conn.cursor()
cur.execute(
"UPDATE customers SET address = %s WHERE id = %s",
(address, customer_id)
)
tenant_conn.commit()
cur.close()

127
pos/services/wa_learning.py Normal file
View File

@@ -0,0 +1,127 @@
"""
WhatsApp Learning Service — ruta de aprendizaje para piezas no resueltas.
Registra sesiones donde el bot no pudo identificar una pieza, y las resuelve
asíncronamente cuando el cliente realiza una compra futura.
"""
import json
def register_unresolved_search(phone, customer_id, description, offered_parts, tenant_conn):
"""Registrar una sesión no resuelta para aprendizaje futuro."""
if not tenant_conn or not phone or not description:
return None
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO wa_learning_sessions (phone, customer_id, description, offered_parts, status, created_at)
VALUES (%s, %s, %s, %s, 'pending', NOW())
RETURNING id
""", (phone, customer_id, description, json.dumps(offered_parts or [])))
sid = cur.fetchone()[0]
tenant_conn.commit()
cur.close()
return sid
def find_pending_sessions(phone, tenant_conn):
"""Buscar sesiones pendientes de aprendizaje para un número WA."""
if not tenant_conn or not phone:
return []
cur = tenant_conn.cursor()
cur.execute("""
SELECT id, description, offered_parts, created_at
FROM wa_learning_sessions
WHERE phone = %s AND status = 'pending'
ORDER BY created_at DESC
""", (phone,))
rows = cur.fetchall()
cur.close()
return [{'id': r[0], 'description': r[1], 'offered_parts': r[2], 'created_at': str(r[3])} for r in rows]
def find_pending_sessions_by_customer(customer_id, tenant_conn):
"""Buscar sesiones pendientes por customer_id."""
if not tenant_conn or not customer_id:
return []
cur = tenant_conn.cursor()
cur.execute("""
SELECT id, phone, description, offered_parts, created_at
FROM wa_learning_sessions
WHERE customer_id = %s AND status = 'pending'
ORDER BY created_at DESC
""", (customer_id,))
rows = cur.fetchall()
cur.close()
return [{'id': r[0], 'phone': r[1], 'description': r[2], 'offered_parts': r[3], 'created_at': str(r[4])} for r in rows]
def resolve_session(session_id, resolved_part_id, sale_id, tenant_conn):
"""Marcar sesión como resuelta con la pieza comprada."""
if not tenant_conn or not session_id:
return
cur = tenant_conn.cursor()
cur.execute("""
UPDATE wa_learning_sessions
SET status = 'learned', resolved_part_id = %s, resolution_sale_id = %s, resolved_at = NOW()
WHERE id = %s
""", (resolved_part_id, sale_id, session_id))
tenant_conn.commit()
cur.close()
def get_learning_pairs_for_training(tenant_conn, limit=100):
"""Obtener pares (descripción del cliente → pieza real) para entrenamiento."""
if not tenant_conn:
return []
cur = tenant_conn.cursor()
cur.execute("""
SELECT l.description, i.name, i.part_number, i.brand
FROM wa_learning_sessions l
JOIN inventory i ON i.id = l.resolved_part_id
WHERE l.status = 'learned' AND l.resolved_at > NOW() - INTERVAL '90 days'
ORDER BY l.resolved_at DESC
LIMIT %s
""", (limit,))
rows = cur.fetchall()
cur.close()
return [{'description': r[0], 'part_name': r[1], 'part_number': r[2], 'brand': r[3]} for r in rows]
def check_learning_resolution(sale_id, customer_id, tenant_conn):
"""
Hook para llamar después de completar una venta.
Verifica si esta venta resuelve una sesión de aprendizaje pendiente.
"""
if not tenant_conn or not customer_id:
return
sessions = find_pending_sessions_by_customer(customer_id, tenant_conn)
if not sessions:
return
# Obtener items de esta venta
cur = tenant_conn.cursor()
cur.execute("""
SELECT si.inventory_id, i.name, i.part_number
FROM sale_items si
JOIN inventory i ON i.id = si.inventory_id
WHERE si.sale_id = %s
""", (sale_id,))
sale_items = cur.fetchall()
cur.close()
if not sale_items:
return
# Matching heurístico
for sess in sessions:
desc_words = set(sess['description'].lower().split())
for inv_id, item_name, part_number in sale_items:
item_words = set(item_name.lower().split())
# Intersección de palabras significativas
common = desc_words & item_words - {'de', 'la', 'el', 'para', 'un', 'una', 'con', 'y', 'o', 'en', 'al', 'del', 'los', 'las'}
if len(common) >= 2:
resolve_session(sess['id'], inv_id, sale_id, tenant_conn)
print(f"[WA-LEARN] Resolved session {sess['id']} with sale {sale_id}, item {inv_id}")
break

View File

@@ -105,7 +105,7 @@ def confirm_quotation(tenant_conn, phone):
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,))
tenant_conn.commit()
cur.close()
clear_last_shown(phone)
clear_last_shown(tenant_conn, phone)
return qid
@@ -342,7 +342,7 @@ def clear_quotation(tenant_conn, phone):
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (qid,))
tenant_conn.commit()
cur.close()
clear_last_shown(phone)
clear_last_shown(tenant_conn, phone)
return qid

File diff suppressed because it is too large Load Diff

View File

@@ -88,9 +88,19 @@ def process_incoming(webhook_data):
key = data.get('key', {})
message = data.get('message', {})
# remoteJid can be phone@s.whatsapp.net or LID@lid
# remoteJid can be phone@s.whatsapp.net or LID:instance@lid
remote_jid = key.get('remoteJid', '')
phone = remote_jid.replace('@s.whatsapp.net', '').replace('@lid', '')
# Strip JID suffixes and LID instance suffix (:12)
phone = remote_jid.split('@')[0].split(':')[0] if remote_jid else ''
# DEBUG
import json
print(f"[WA-DEBUG] key fields: {json.dumps({k: v for k, v in key.items() if k in ('remoteJid', 'senderPn', 'fromMe', 'id')})}")
# senderPn contains the real phone number when remoteJid is a privacy LID
sender_pn = key.get('senderPn', '')
if sender_pn:
sender_pn = sender_pn.replace('@s.whatsapp.net', '')
# The bridge now classifies and passes these extra fields. Fall back to
# the old parsing if they're missing (older bridge version).
@@ -122,6 +132,7 @@ def process_incoming(webhook_data):
return {
'phone': phone,
'jid': remote_jid,
'sender_pn': sender_pn,
'text': text,
'from_me': key.get('fromMe', False),
'message_id': key.get('id', ''),

View File

@@ -180,4 +180,18 @@
permissions: payload.permissions || []
};
// ─── Preload enabled modules for sidebar filtering ───
try {
fetch('/pos/api/config/modules', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) {
if (r.ok) return r.json();
}).then(function(data) {
if (data) {
localStorage.setItem('pos_modules', JSON.stringify(data));
window.POS_USER.modules = data;
}
}).catch(function() {});
} catch(e) {}
})();

View File

@@ -689,6 +689,53 @@ const Config = (() => {
}
}
// -------------------------------------------------------------------------
// Modules / Integrations
// -------------------------------------------------------------------------
async function loadModules() {
try {
var res = await fetch(API + '/modules', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
var cbWa = document.getElementById('cfg-module-whatsapp');
var cbMp = document.getElementById('cfg-module-marketplace');
var cbMeli = document.getElementById('cfg-module-meli');
if (cbWa) cbWa.checked = data.whatsapp !== false;
if (cbMp) cbMp.checked = data.marketplace !== false;
if (cbMeli) cbMeli.checked = data.meli !== false;
localStorage.setItem('pos_modules', JSON.stringify(data));
} catch (e) {
console.error('Config.loadModules:', e);
}
}
async function saveModules() {
var btn = event.target;
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
try {
var data = {
whatsapp: document.getElementById('cfg-module-whatsapp').checked,
marketplace: document.getElementById('cfg-module-marketplace').checked,
meli: document.getElementById('cfg-module-meli').checked,
};
var res = await fetch(API + '/modules', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(data)
});
if (!res.ok) {
var err = await res.json().catch(function() { return { error: res.statusText }; });
throw new Error(err.error || 'Save failed');
}
localStorage.setItem('pos_modules', JSON.stringify(data));
toast('Módulos actualizados');
} catch (e) {
toast(e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Guardar módulos'; }
}
}
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
@@ -744,6 +791,7 @@ const Config = (() => {
loadCurrency();
loadVehicleCompatSource();
loadAllowedBrands();
loadModules();
}
document.addEventListener('DOMContentLoaded', init);
@@ -753,6 +801,7 @@ const Config = (() => {
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
loadBusiness, saveBusiness, saveTaxParams,
loadCurrency, saveCurrency,
loadModules, saveModules,
openModal, closeModal
};
// Register Cmd+K items

View File

@@ -17,6 +17,16 @@
var currentTheme = localStorage.getItem('pos_theme') || 'industrial';
var currentLang = localStorage.getItem('pos_lang') || 'es';
var modules = {};
try {
modules = JSON.parse(localStorage.getItem('pos_modules') || '{}');
} catch(e) { modules = {}; }
function moduleEnabled(key) {
// Default to true if not configured yet
return modules[key] !== false;
}
var navSections = [
{ label: _t('nav_main'), items: [
{ name: _t('dashboard'), href: '/pos/dashboard', icon: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>' },
@@ -28,14 +38,14 @@
{ label: _t('nav_management'), items: [
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
{ name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' },
{ name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
moduleEnabled('marketplace') ? { name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,
{ name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
{ name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
{ name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
{ name: _t('fleet'), href: '/pos/fleet', icon: '<path d="M1 13h22M1 13l2-6h6l2 6M9 7h6l2 6M15 13l2-6M5 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>' },
{ name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' },
]},
moduleEnabled('whatsapp') ? { name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null,
].filter(Boolean)},
{ label: _t('nav_system'), items: [
{ name: _t('config'), href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' },
]},

View File

@@ -179,7 +179,7 @@ function cacheFirst(request) {
return caches.match(request).then(function (cached) {
if (cached) {
fetch(request).then(function (response) {
if (response && response.status === 200) {
if (response && response.status === 200 && request.method === 'GET') {
caches.open(CACHE_NAME).then(function (cache) {
cache.put(request, response);
});
@@ -188,7 +188,7 @@ function cacheFirst(request) {
return cached;
}
return fetch(request).then(function (response) {
if (response && response.status === 200) {
if (response && response.status === 200 && request.method === 'GET') {
var clone = response.clone();
caches.open(CACHE_NAME).then(function (cache) {
cache.put(request, clone);
@@ -201,7 +201,7 @@ function cacheFirst(request) {
function networkFirst(request) {
return fetch(request).then(function (response) {
if (response && response.status === 200) {
if (response && response.status === 200 && request.method === 'GET') {
var clone = response.clone();
caches.open(CACHE_NAME).then(function (cache) {
cache.put(request, clone);

View File

@@ -251,7 +251,58 @@
</div>
<!-- ===============================================================
SECTION 3: USUARIOS Y PERMISOS
SECTION 3: MÓDULOS E INTEGRACIONES
=============================================================== -->
<div class="settings-section">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</div>
<div>
<div class="settings-section__title">Módulos e Integraciones</div>
<div class="settings-section__desc">Activa o desactiva funcionalidades del menú para este tenant</div>
</div>
</div>
<div class="settings-card">
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">WhatsApp</span>
<span class="toggle-row__desc">Mostrar el menú de WhatsApp Bridge y chat</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-module-whatsapp" checked />
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Marketplace</span>
<span class="toggle-row__desc">Mostrar el menú de Marketplace interno</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-module-marketplace" checked />
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">MercadoLibre</span>
<span class="toggle-row__desc">Mostrar el menú de integración con MercadoLibre</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-module-meli" checked />
<span class="toggle__slider"></span>
</label>
</div>
<div style="margin-top:var(--space-4);text-align:right;">
<button class="btn btn--primary" onclick="Config.saveModules()">Guardar módulos</button>
</div>
</div>
</div>
<!-- ===============================================================
SECTION 4: USUARIOS Y PERMISOS
=============================================================== -->
<div class="settings-section">
<div class="settings-section__header">

View File

@@ -3,7 +3,7 @@
"version": "1.0.0",
"type": "commonjs",
"dependencies": {
"@whiskeysockets/baileys": "^6.7.16",
"@whiskeysockets/baileys": "^6.7.23",
"express": "^4.18.2",
"qrcode": "^1.5.3",
"pino": "^8.16.2"

View File

@@ -28,8 +28,9 @@ const sendQueue = [];
let queueTimer = null;
let connectWatchdog = null;
let staleWatchdog = null;
const WATCHDOG_MS = 90000;
const STALE_MS = 90000;
let queueFlushInterval = null;
const WATCHDOG_MS = 300000; // 5 minutos para dar tiempo al QR scanning
const STALE_MS = 1800000; // 30 minutos (keepalive ya maneja pings, no forzamos reconexión por inactividad)
let lastActivity = Date.now();
function updateActivity() {
@@ -67,19 +68,14 @@ function flushSendQueue() {
function clearWatchdog() {
if (connectWatchdog) { clearTimeout(connectWatchdog); connectWatchdog = null; }
if (staleWatchdog) { clearInterval(staleWatchdog); staleWatchdog = null; }
if (queueFlushInterval) { clearInterval(queueFlushInterval); queueFlushInterval = null; }
}
function scheduleStaleWatchdog() {
if (staleWatchdog) clearInterval(staleWatchdog);
staleWatchdog = setInterval(() => {
if (connectionState === 'open' && (Date.now() - lastActivity > STALE_MS)) {
console.log(`[Tenant ${TENANT_ID}] Stale watchdog: no activity for ${STALE_MS/1000}s while open, forcing reconnect`);
try { sock?.ws?.close(); } catch (e) {}
sock = null;
connectionState = 'disconnected';
setTimeout(connectWhatsApp, 30000);
}
}, 30000);
// ELIMINADO: el stale watchdog forzaba reconexión cada 90s sin mensajes,
// lo cual destruía la sesión de Baileys y provocaba 440 + limpieza de auth.
// Baileys ya envía keepalive cada 15s (keepAliveIntervalMs). No es necesario.
if (staleWatchdog) { clearInterval(staleWatchdog); staleWatchdog = null; }
}
function scheduleWatchdog() {
@@ -118,7 +114,7 @@ async function connectWhatsApp() {
auth: state,
logger,
// Use a generic Ubuntu + Chrome fingerprint — less suspicious than raw Linux
browser: ['Ubuntu', 'Chrome', '120.0.0.0'],
browser: ['Chrome', 'Windows', '124.0.0.0'],
defaultQueryTimeoutMs: 60000,
keepAliveIntervalMs: 15000,
markOnlineOnConnect: false,
@@ -157,23 +153,24 @@ async function connectWhatsApp() {
}
if (reason === 440) {
// 440 = conflict/replaced. The session data is permanently invalid.
// Clean auth immediately and wait 5 min so WhatsApp forgets the old session.
console.log(`[Tenant ${TENANT_ID}] 440 Session replaced — clearing auth, waiting 5 min`);
clearAuthState();
sock = null;
// 440 = conflict/replaced. WhatsApp reemplazó esta sesión (puede ser
// por reconexión muy rápida, o porque el teléfono abrió otra sesión).
// NUNCA limpiamos auth automáticamente — solo esperamos más tiempo.
retry440Count++;
const delay = retry440Count >= 3 ? 600000 : 300000; // 10 min after 3 failures, else 5 min
const delay = retry440Count >= 3 ? 600000 : 120000; // 2min → 10min
console.log(`[Tenant ${TENANT_ID}] 440 Session replaced — reconnecting in ${delay/1000}s with existing creds (attempt ${retry440Count})`);
sock = null;
setTimeout(connectWhatsApp, delay);
return;
}
if (reason === 515) {
// 515 = stream error, often precedes 440. Treat same as 440.
console.log(`[Tenant ${TENANT_ID}] 515 Stream error — clearing auth, waiting 5 min`);
clearAuthState();
// 515 = stream error / restart required. WhatsApp sends this after
// successful pairing to force a reconnect with the new credentials.
// DO NOT clear auth — the credentials were just saved by creds.update.
console.log(`[Tenant ${TENANT_ID}] 515 Restart required — reconnecting in 5s with saved creds`);
sock = null;
setTimeout(connectWhatsApp, 300000);
setTimeout(connectWhatsApp, 5000);
return;
}
@@ -185,19 +182,11 @@ async function connectWhatsApp() {
}
if (reason === 408) {
// 408 during init queries usually means the server is overloaded
// or our auth is partially invalid. Clear auth if this happens repeatedly.
console.log(`[Tenant ${TENANT_ID}] 408 Timeout — waiting 60s`);
// 408 during init queries = rate-limit o auth parcialmente inválido.
// No limpiamos auth automáticamente; esperamos más tiempo.
console.log(`[Tenant ${TENANT_ID}] 408 Timeout — waiting 120s`);
sock = null;
retry440Count++;
if (retry440Count >= 5) {
console.log(`[Tenant ${TENANT_ID}] Too many timeouts — clearing auth for fresh QR`);
clearAuthState();
retry440Count = 0;
setTimeout(connectWhatsApp, 300000);
return;
}
setTimeout(connectWhatsApp, 60000);
setTimeout(connectWhatsApp, 120000);
return;
}
@@ -208,20 +197,33 @@ async function connectWhatsApp() {
}
if (connection === 'open') {
// Race-condition guard: if sock was nulled by a concurrent disconnect,
// ignore this stale 'open' event.
if (!sock) {
console.log(`[Tenant ${TENANT_ID}] Ignoring stale 'open' event (sock is null)`);
return;
}
clearWatchdog();
connectionState = 'open';
qrCode = null;
retry440Count = 0;
updateActivity();
scheduleStaleWatchdog();
// Stale watchdog eliminado — Baileys ya mantiene keepalive.
console.log(`[Tenant ${TENANT_ID}] Connected!`);
flushSendQueue();
if (!queueFlushInterval) {
queueFlushInterval = setInterval(() => {
if (connectionState === 'open') flushSendQueue();
}, 5000);
}
}
});
sock.ev.on('messages.upsert', async ({ messages }) => {
for (const msg of messages) {
if (msg.key.fromMe) continue;
// Skip system/receipt messages with no meaningful content
if (!msg.message || Object.keys(msg.message).length === 0) continue;
const phone = msg.key.remoteJid.replace('@s.whatsapp.net', '');
const message = msg.message || {};
@@ -289,7 +291,8 @@ async function connectWhatsApp() {
media_ptt,
latitude,
longitude,
push_name: msg.pushName || ''
push_name: msg.pushName || '',
sender_pn: msg.key?.senderPn || ''
}
}),
signal: controller.signal
@@ -332,21 +335,22 @@ app.post('/send', async (req, res) => {
return res.status(400).json({ error: 'phone and message required' });
}
const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net';
console.log(`[Tenant ${TENANT_ID}] /send called for ${jid}. state=${connectionState}, sock=${!!sock}`);
if (connectionState !== 'open' || !sock) {
sendQueue.push({ jid, message });
console.log(`[Tenant ${TENANT_ID}] Message queued for ${jid} (state: ${connectionState}, queue size: ${sendQueue.length})`);
return res.status(202).json({ queued: true, state: connectionState });
if (connectionState === 'open' && sock) {
try {
const r = await sock.sendMessage(jid, { text: message });
res.json({ success: true, id: r.key.id });
return;
} catch (e) {
console.log(`[Tenant ${TENANT_ID}] Send failed, will queue:`, e.message);
// fall through to queue
}
}
try {
const r = await sock.sendMessage(jid, { text: message });
res.json({ success: true, id: r.key.id });
} catch (e) {
sendQueue.push({ jid, message });
console.log(`[Tenant ${TENANT_ID}] Send failed, queued for retry:`, e.message);
res.status(202).json({ queued: true, error: e.message });
}
sendQueue.push({ jid, message });
console.log(`[Tenant ${TENANT_ID}] Message queued for ${jid} (state: ${connectionState}, queue size: ${sendQueue.length})`);
res.status(202).json({ queued: true, state: connectionState });
});
app.post('/send-image', async (req, res) => {
const { phone, caption, base64 } = req.body;