diff --git a/config.py b/config.py index d7573bf..4fcf7d8 100644 --- a/config.py +++ b/config.py @@ -11,6 +11,9 @@ if not DB_URL: "Example: postgresql://user:pass@localhost/nexus_autoparts" ) +MASTER_DB_URL = os.environ.get("MASTER_DB_URL") or DB_URL +TENANT_DB_URL_TEMPLATE = os.environ.get("TENANT_DB_URL_TEMPLATE") or DB_URL.replace("nexus_autoparts", "{db_name}") + # Legacy SQLite path (used only by migration script) SQLITE_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), diff --git a/dashboard/admin.html b/dashboard/admin.html index eeab4e1..8c6b907 100644 --- a/dashboard/admin.html +++ b/dashboard/admin.html @@ -92,6 +92,14 @@ + + @@ -660,6 +668,35 @@ + +
+ +
+
+

Tenants Activos

+
+
+ + + + + + + + + + + + + + +
IDNombreWhatsAppMarketplaceMercadoLibreAcciones
+
+
+
+ diff --git a/dashboard/admin.js b/dashboard/admin.js index 62fe78d..fcd51f5 100644 --- a/dashboard/admin.js +++ b/dashboard/admin.js @@ -121,6 +121,9 @@ function showSection(sectionId) { case 'users': loadUsers(); break; + case 'tenants': + loadTenants(); + break; } } @@ -2074,3 +2077,99 @@ async function toggleUserActive(userId, currentActive) { showAlert(e.message, 'error'); } } + +// ─── Tenants / Modules ───────────────────────────────────────────────────── + +async function loadTenants() { + var token = localStorage.getItem('access_token'); + var tbody = document.getElementById('tenantsTable'); + tbody.innerHTML = '
'; + + try { + var res = await fetch('/api/admin/tenants', { + headers: { 'Authorization': 'Bearer ' + token } + }); + if (!res.ok) throw new Error('Error al cargar tenants (' + res.status + ')'); + var data = await res.json(); + var tenants = data.tenants || []; + + if (tenants.length === 0) { + tbody.innerHTML = 'No hay tenants activos'; + return; + } + + // Load modules for each tenant + var modulesMap = {}; + await Promise.all(tenants.map(async function(t) { + try { + var mres = await fetch('/api/admin/tenants/' + t.id + '/modules', { + headers: { 'Authorization': 'Bearer ' + token } + }); + if (mres.ok) { + modulesMap[t.id] = await mres.json(); + } else { + modulesMap[t.id] = {}; + } + } catch (e) { + modulesMap[t.id] = {}; + } + })); + + renderTenantsTable(tenants, modulesMap); + } catch (e) { + tbody.innerHTML = '' + e.message + ''; + } +} + +function renderTenantsTable(tenants, modulesMap) { + var tbody = document.getElementById('tenantsTable'); + tbody.innerHTML = tenants.map(function(t) { + var mods = modulesMap[t.id] || {}; + function toggleBtn(tenantId, key, enabled) { + var label = enabled ? 'Activado' : 'Desactivado'; + var cls = enabled ? 'btn-primary' : 'btn-secondary'; + return ''; + } + return '' + + '' + t.id + '' + + '' + (t.name || '-') + '' + + '' + toggleBtn(t.id, 'whatsapp_enabled', !!mods.whatsapp_enabled) + '' + + '' + toggleBtn(t.id, 'marketplace_enabled', !!mods.marketplace_enabled) + '' + + '' + toggleBtn(t.id, 'meli_enabled', !!mods.meli_enabled) + '' + + '' + + ''; + }).join(''); +} + +async function toggleTenantModule(tenantId, key, currentValue) { + var token = localStorage.getItem('access_token'); + var moduleNames = { + 'whatsapp_enabled': 'WhatsApp', + 'marketplace_enabled': 'Marketplace', + 'meli_enabled': 'MercadoLibre' + }; + var action = currentValue ? 'desactivar' : 'activar'; + if (!confirm('¿Seguro que deseas ' + action + ' ' + moduleNames[key] + ' para el tenant #' + tenantId + '?')) return; + + try { + var payload = {}; + payload[key] = !currentValue; + var res = await fetch('/api/admin/tenants/' + tenantId + '/modules', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + token + }, + body: JSON.stringify(payload) + }); + if (!res.ok) { + var err = await res.json(); + throw new Error(err.error || 'Error al actualizar módulo'); + } + showAlert(moduleNames[key] + ' ' + (currentValue ? 'desactivado' : 'activado') + ' para tenant #' + tenantId); + loadTenants(); + } catch (e) { + showAlert(e.message, 'error'); + } +} diff --git a/dashboard/server.py b/dashboard/server.py index 2413c57..4621b06 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -16,6 +16,7 @@ sys.path.insert(0, os.path.join(_base, '..', 'pos')) # pos/ for auth, services sys.path.insert(0, os.path.join(_base, '..')) # root config.py (has DB_URL) from config import DB_URL from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth +from tenant_db import get_tenant_conn from services.translations import translate_part_name, translate_category sys.path.insert(0, os.path.join(_base, '..', 'pos')) @@ -4628,6 +4629,76 @@ def part_aftermarket(part_id): session.close() +# ============================================================================ +# Tenant Module Config Endpoints +# ============================================================================ + +MODULE_CONFIG_KEYS = [ + 'whatsapp_enabled', + 'marketplace_enabled', + 'meli_enabled', +] + + +@app.route('/api/admin/tenants') +def api_admin_tenants(): + session = Session() + try: + rows = session.execute(text( + "SELECT id, name, db_name, is_active, is_seller FROM tenants WHERE is_active = true ORDER BY id" + )).mappings().all() + return jsonify({'tenants': [dict(r) for r in rows]}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + finally: + session.close() + + +@app.route('/api/admin/tenants//modules') +def api_admin_tenant_modules(tenant_id): + try: + conn = get_tenant_conn(tenant_id) + cur = conn.cursor() + result = {} + for key in MODULE_CONFIG_KEYS: + cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,)) + row = cur.fetchone() + result[key] = (row[0] or '').lower() == 'true' if row else False + cur.close() + conn.close() + return jsonify(result) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/admin/tenants//modules', methods=['PUT']) +def api_admin_tenant_modules_update(tenant_id): + data = request.get_json() or {} + if not data: + return jsonify({'error': 'No data provided'}), 400 + + try: + conn = get_tenant_conn(tenant_id) + cur = conn.cursor() + for key, value in data.items(): + if key not in MODULE_CONFIG_KEYS: + continue + cur.execute( + """ + INSERT INTO tenant_config (key, value, updated_at) + VALUES (%s, %s, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + """, + (key, 'true' if value else 'false'), + ) + conn.commit() + cur.close() + conn.close() + return jsonify({'ok': True}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + # ============================================================================ # Static files from dashboard root (CSS/JS/HTML) # ============================================================================ diff --git a/manager/blueprints/tenants_bp.py b/manager/blueprints/tenants_bp.py index 13ea21b..8a98c9a 100644 --- a/manager/blueprints/tenants_bp.py +++ b/manager/blueprints/tenants_bp.py @@ -58,3 +58,24 @@ def delete_tenant(tenant_id): return jsonify(result) except Exception as e: return jsonify({"error": str(e)}), 500 + + +@tenants_bp.route("//modules", methods=["GET"]) +@require_manager_auth +def get_tenant_modules(tenant_id): + try: + result = tenant_service.get_tenant_modules(tenant_id) + return jsonify({"data": result}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@tenants_bp.route("//modules", methods=["PUT"]) +@require_manager_auth +def update_tenant_modules(tenant_id): + data = request.get_json() or {} + try: + result = tenant_service.update_tenant_modules(tenant_id, data) + return jsonify(result) + except Exception as e: + return jsonify({"error": str(e)}), 500 diff --git a/manager/services/tenant_service.py b/manager/services/tenant_service.py index 0df77ac..11c4c00 100644 --- a/manager/services/tenant_service.py +++ b/manager/services/tenant_service.py @@ -311,6 +311,55 @@ def get_tenant_login_url(subdomain): return f"https://{subdomain}.{domain}/pos/login" +def get_tenant_modules(tenant_id): + """Get enabled modules for a tenant from tenant_config.""" + tenant = get_tenant(tenant_id) + if not tenant: + raise ValueError("Tenant not found") + db_name = tenant["db_name"] + dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name) + conn = psycopg2.connect(dsn) + cur = conn.cursor() + try: + modules = {} + for key in ["module_whatsapp", "module_marketplace", "module_meli"]: + cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,)) + row = cur.fetchone() + modules[key.replace("module_", "")] = (row[0] or "").lower() == "true" if row else True + return modules + finally: + cur.close() + conn.close() + + +def update_tenant_modules(tenant_id, modules): + """Update enabled modules for a tenant in tenant_config.""" + tenant = get_tenant(tenant_id) + if not tenant: + raise ValueError("Tenant not found") + db_name = tenant["db_name"] + dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name) + conn = psycopg2.connect(dsn) + cur = conn.cursor() + try: + key_map = { + "whatsapp": "module_whatsapp", + "marketplace": "module_marketplace", + "meli": "module_meli", + } + for field, key in key_map.items(): + value = "true" if modules.get(field) else "false" + cur.execute(""" + INSERT INTO tenant_config (key, value) VALUES (%s, %s) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, (key, value)) + conn.commit() + return {"success": True, "tenant_id": tenant_id, "modules": modules} + finally: + cur.close() + conn.close() + + def get_dashboard_stats(): """Global stats for the manager dashboard.""" conn = get_master_conn() diff --git a/manager/static/css/manager.css b/manager/static/css/manager.css index 132ae17..5d177ac 100644 --- a/manager/static/css/manager.css +++ b/manager/static/css/manager.css @@ -661,3 +661,42 @@ body { .sidebar-brand span, .nav-item span, .user-info span { display: none; } .stats-grid { grid-template-columns: 1fr; } } + +/* Toggle switch for modules modal */ +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + cursor: pointer; +} +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} +.toggle-slider { + position: absolute; + inset: 0; + background: var(--border); + border-radius: 24px; + transition: background 0.2s; +} +.toggle-slider::before { + content: ""; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background: white; + border-radius: 50%; + transition: transform 0.2s; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} +.toggle-switch input:checked + .toggle-slider { + background: var(--success); +} +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(20px); +} diff --git a/manager/static/js/manager.js b/manager/static/js/manager.js index 327c33b..8519430 100644 --- a/manager/static/js/manager.js +++ b/manager/static/js/manager.js @@ -188,6 +188,7 @@ async function loadDemos() { ${escapeHtml(d.subdomain)} ${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"} + @@ -254,6 +255,7 @@ async function loadTenants(withStats = false) { ${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")} ${formatDate(t.created_at)} + @@ -475,5 +477,59 @@ function copyText(text) { navigator.clipboard.writeText(text).then(() => toast("Copiado al portapapeles", "success")); } +// ─── Modules ─────────────────────────────────────────────────────────────── +let currentModulesTenantId = null; + +async function openModulesModal(tenantId, name) { + currentModulesTenantId = tenantId; + document.getElementById("modules-modal-title").textContent = `Módulos — ${escapeHtml(name)}`; + document.getElementById("modules-modal").style.display = "flex"; + + // Load current state + const res = await api(`/api/tenants/${tenantId}/modules`); + if (res && res.status === 200) { + const m = res.data.data; + document.getElementById("mod-whatsapp").checked = m.whatsapp !== false; + document.getElementById("mod-marketplace").checked = m.marketplace !== false; + document.getElementById("mod-meli").checked = m.meli !== false; + } else { + toast("Error al cargar módulos", "error"); + } +} + +function closeModulesModal() { + document.getElementById("modules-modal").style.display = "none"; + currentModulesTenantId = null; +} + +async function saveModules() { + if (!currentModulesTenantId) return; + const btn = document.getElementById("modules-save-btn"); + const originalText = btn.innerHTML; + btn.innerHTML = ` Guardando...`; + btn.disabled = true; + + const payload = { + whatsapp: document.getElementById("mod-whatsapp").checked, + marketplace: document.getElementById("mod-marketplace").checked, + meli: document.getElementById("mod-meli").checked, + }; + + const res = await api(`/api/tenants/${currentModulesTenantId}/modules`, { + method: "PUT", + body: payload + }); + + if (res && res.status === 200) { + toast("Módulos actualizados", "success"); + closeModulesModal(); + } else { + toast(res?.data?.error || "Error al guardar", "error"); + } + + btn.innerHTML = originalText; + btn.disabled = false; +} + // ─── Init ────────────────────────────────────────────────────────────────── document.addEventListener("DOMContentLoaded", initAuth); diff --git a/manager/templates/index.html b/manager/templates/index.html index 104e0eb..b822c22 100644 --- a/manager/templates/index.html +++ b/manager/templates/index.html @@ -316,6 +316,53 @@ + + +
diff --git a/pos/blueprints/config_bp.py b/pos/blueprints/config_bp.py index 1adfb6d..fe2c48f 100644 --- a/pos/blueprints/config_bp.py +++ b/pos/blueprints/config_bp.py @@ -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(): diff --git a/pos/blueprints/pos_bp.py b/pos/blueprints/pos_bp.py index 7e0ee85..462d7f0 100644 --- a/pos/blueprints/pos_bp.py +++ b/pos/blueprints/pos_bp.py @@ -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 diff --git a/pos/blueprints/whatsapp_bp.py b/pos/blueprints/whatsapp_bp.py index 579277c..ccb0d10 100644 --- a/pos/blueprints/whatsapp_bp.py +++ b/pos/blueprints/whatsapp_bp.py @@ -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}) diff --git a/pos/migrations/v3.5_whatsapp_state_machine.sql b/pos/migrations/v3.5_whatsapp_state_machine.sql new file mode 100644 index 0000000..f9a6ea3 --- /dev/null +++ b/pos/migrations/v3.5_whatsapp_state_machine.sql @@ -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; diff --git a/pos/services/pos_engine.py b/pos/services/pos_engine.py index 94b593e..21bccfb 100644 --- a/pos/services/pos_engine.py +++ b/pos/services/pos_engine.py @@ -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, diff --git a/pos/services/wa_customer.py b/pos/services/wa_customer.py new file mode 100644 index 0000000..4cc224c --- /dev/null +++ b/pos/services/wa_customer.py @@ -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() diff --git a/pos/services/wa_learning.py b/pos/services/wa_learning.py new file mode 100644 index 0000000..2f587ed --- /dev/null +++ b/pos/services/wa_learning.py @@ -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 diff --git a/pos/services/wa_quotation.py b/pos/services/wa_quotation.py index e4f3f34..72ea20e 100644 --- a/pos/services/wa_quotation.py +++ b/pos/services/wa_quotation.py @@ -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 diff --git a/pos/services/wa_state_machine.py b/pos/services/wa_state_machine.py new file mode 100644 index 0000000..97076f6 --- /dev/null +++ b/pos/services/wa_state_machine.py @@ -0,0 +1,1366 @@ +""" +WhatsApp State Machine — motor de estados para el flujo conversacional estructurado. + +Reemplaza el flujo de AI libre por una máquina de estados determinista: + idle → greeting/menu → [cotización | soporte | facturación] + +Cada estado es una función registrada con el decorador @state(name). +""" + +import re +import json +from datetime import datetime + +# ─── Registro de estados ───────────────────────────────────────────── +_STATES = {} + + +def state(name): + """Decorador para registrar un estado en la máquina de estados.""" + def decorator(func): + _STATES[name] = func + return func + return decorator + + +class StateContext: + """Contenedor de todo el contexto necesario para una transición.""" + def __init__(self, tenant_conn, master_conn, wa_config, tenant_id, + phone, customer_id=None, branch_id=None, media_kind='text', + media_base64=None, push_name=None): + self.tenant_conn = tenant_conn + self.master_conn = master_conn + self.wa_config = wa_config + self.tenant_id = tenant_id + self.phone = phone + self.customer_id = customer_id + self.branch_id = branch_id + self.media_kind = media_kind + self.media_base64 = media_base64 + self.push_name = push_name + + +# ─── Punto de entrada principal ────────────────────────────────────── + +def process_message(phone, text, current_state, state_data, context): + """ + Procesar un mensaje entrante a través de la máquina de estados. + + Returns: + (reply: str|None, next_state: str, next_state_data: dict) + """ + handler = _STATES.get(current_state, _handle_unknown_state) + return handler(text, state_data, context) + + +def _handle_unknown_state(text, state_data, ctx): + """Fallback para estados corruptos o no registrados.""" + return _MSG_UNKNOWN_STATE, 'menu', state_data + + +# ─── Mensajes globales ─────────────────────────────────────────────── + +_MSG_UNKNOWN_STATE = ( + "Parece que hubo un problema. Volvamos al inicio. 🤔\n\n" + "¿En qué puedo ayudarte?\n\n" + "1️⃣ *Cotizar* refacciones\n" + "2️⃣ *Soporte* — Hablar con la sucursal\n" + "3️⃣ *Facturación* — Consultar o generar facturas\n\n" + "Responde con el número de la opción." +) + +_MSG_MENU = ( + "¿En qué puedo ayudarte hoy?\n\n" + "1️⃣ *Cotizar* refacciones\n" + "2️⃣ *Soporte* — Hablar con la sucursal\n" + "3️⃣ *Facturación* — Consultar o generar facturas\n\n" + "Responde con el número de la opción que necesites." +) + +_MSG_MENU_INVALID = ( + "No entendí tu selección. 🤔\n\n" + "Por favor responde con:\n" + "• *1* para Cotizar\n" + "• *2* para Soporte\n" + "• *3* para Facturación" +) + +_MSG_GREETING_KNOWN = "¡Hola {name}! 👋\nBienvenido a *{branch}*." +_MSG_GREETING_UNKNOWN = "¡Hola! 👋\nBienvenido a *{branch}*. Soy tu asistente virtual." + +_MSG_IDENTIFY_PROMPT = "Para darte una mejor atención, ¿podrías indicarme tu *nombre* o *número de cliente*?" +_MSG_IDENTIFY_FOUND = "¡Perfecto! Te encontré en el sistema, {name}. ✅\n\nContinuamos con tu cotización." +_MSG_IDENTIFY_MULTIPLE = "Encontré varios clientes con datos similares:\n\n{options}\n\nResponde con el número del cliente correcto." +_MSG_IDENTIFY_NOT_FOUND = ( + "No te encontré en nuestro sistema. 😕\n\n" + "¿Te gustaría registrarte para agilizar futuras compras?\n" + "Responde *sí* para registrarte o *no* para continuar como invitado." +) + +_MSG_QUOTE_SEARCH_PROMPT = ( + "¿Qué refacción estás buscando? 🔧\n\n" + "Puedes decirme algo como:\n" + "• \"Balatas para Tsuru 2015\"\n" + "• \"Filtro de aceite Bosch\"\n" + "• O envíame una *foto* de la pieza" +) + +_MSG_EXPLORE_OFFER = ( + "🔍 Estoy buscando en nuestros catálogos técnicos...\n\n" + "¿Te refieres a *{suggestion}*?\n\n" + "Responde *sí* para confirmar o *no* para que busque otra opción." +) +_MSG_EXPLORE_RETRY = "Entendido. Déjame buscar otra alternativa... 🔎" +_MSG_EXPLORE_ERROR = ( + "Estoy teniendo problemas para identificar la pieza. 😕\n\n" + "¿Podrías darme más detalles? Por ejemplo: marca del carro, modelo, año, " + "o el síntoma que presenta." +) +_MSG_EXPLORE_CONFIRM_PROMPT = "¿Confirmas que es esta pieza? Responde *sí* o *no*." + +_MSG_LEARNING_TRANSFER = ( + "No logré identificar la pieza con certeza después de explorar varias opciones. 😔\n\n" + "He registrado tu búsqueda para mejorar nuestro servicio en el futuro.\n\n" + "Te sugiero llamar directamente a la sucursal para que un asesor especializado te ayude:\n\n" + "📞 *{phone}*\n\n" + "Ellos podrán orientarte mejor con tu descripción." +) + +_MSG_DELIVERY_PROMPT = "¿Deseas que te enviemos el pedido a domicilio? 🚚\n\nResponde *sí* o *no*." +_MSG_DELIVERY_ADDRESS_CONFIRM = ( + "¿Tu dirección de envío es la siguiente?\n\n" + "📍 {address}\n\n" + "Responde *sí* para confirmar o escribe la nueva dirección." +) +_MSG_DELIVERY_ADDRESS_REQUEST = ( + "¿A qué dirección te gustaría recibir el envío? 📍\n\n" + "Por favor escribe la dirección completa incluyendo calle, número, colonia y código postal." +) +_MSG_DELIVERY_ADDRESS_SAVED = ( + "✅ Dirección registrada:\n\n" + "📍 {address}\n\n" + "Continuamos con tu cotización." +) + +_MSG_SUPPORT = ( + "📞 *Soporte directo*\n\n" + "Para una atención más personalizada, llámanos directamente:\n\n" + "*{phone}*\n\n" + "Horario: Lunes a Viernes 8:00 - 18:00, Sábados 8:00 - 14:00\n\n" + "Un asesor especializado te atenderá con gusto." +) + +_MSG_INVOICE_FISCAL_PROMPT = ( + "Para generar tu factura necesito tus datos fiscales:\n\n" + "1️⃣ RFC\n" + "2️⃣ Razón social\n" + "3️⃣ Uso CFDI (ej. G03)\n" + "4️⃣ Código postal\n\n" + "Puedes enviarme los datos en un solo mensaje. Ejemplo:\n\n" + "RFC: ABCD010101A12\n" + "Razón social: Mi Empresa S.A. de C.V.\n" + "Uso CFDI: G03\n" + "CP: 45130" +) + +_MSG_ERROR_RETRY = ( + "Algo salió mal. Volvamos a intentarlo.\n\n" + "¿Podrías indicarme tu *nombre* o *número de cliente*?" +) + + +# ─── Estados ───────────────────────────────────────────────────────── + +@state('idle') +def handle_idle(text, state_data, ctx): + """Estado inicial: saludo + menú.""" + from services.wa_customer import get_linked_customer, get_customer_by_id + + customer_id = get_linked_customer(ctx.phone, ctx.tenant_conn) + branch_name = _get_branch_name(ctx.tenant_conn, ctx.branch_id) + + if customer_id: + customer = get_customer_by_id(ctx.tenant_conn, customer_id) + first_name = customer['name'].split()[0] if customer and customer.get('name') else 'Cliente' + greeting = _MSG_GREETING_KNOWN.format(name=first_name, branch=branch_name) + else: + greeting = _MSG_GREETING_UNKNOWN.format(branch=branch_name) + + reply = greeting + "\n\n" + _MSG_MENU + next_data = {'customer_id': customer_id, 'branch_name': branch_name} + return reply, 'menu', next_data + + +@state('menu') +def handle_menu(text, state_data, ctx): + """Esperar selección de menú (1, 2, 3).""" + if not text: + return _MSG_MENU_INVALID, 'menu', state_data + + t = text.strip().lower() + + if t in ('1', 'uno', 'cotizar', 'cotización', 'cotizacion', 'refacciones', 'partes'): + return None, 'quote_identify', state_data + + if t in ('2', 'dos', 'soporte', 'ayuda', 'teléfono', 'telefono', 'llamar'): + return None, 'support_phone', state_data + + if t in ('3', 'tres', 'facturación', 'facturacion', 'factura', 'cfdi'): + return None, 'invoice_identify', state_data + + # Fallback: si parece descripción de parte (no saludo), redirigir a cotización + if len(t) > 10 and not _is_greeting(t): + state_data['preloaded_search'] = text.strip() + return ( + "Entendido, parece que buscas una refacción. Te ayudo con eso. 🔧\n\n", + 'quote_identify', + state_data + ) + + return _MSG_MENU_INVALID, 'menu', state_data + + +@state('support_phone') +def handle_support_phone(text, state_data, ctx): + """Proporcionar teléfono de sucursal.""" + phone = _get_branch_phone(ctx.tenant_conn, ctx.branch_id) + reply = _MSG_SUPPORT.format(phone=phone) + return reply, 'support_done', state_data + + +@state('support_done') +def handle_support_done(text, state_data, ctx): + """Estado de cierre, permite volver al menú.""" + if not text: + return ( + "¿Hay algo más en lo que pueda ayudarte?\n\n" + "Escribe *menú* para volver al inicio o indícame qué necesitas.", + 'support_done', state_data + ) + + t = text.strip().lower() + + if t in ('menu', 'menú', 'inicio', 'principal', 'opciones', 'volver', 'regresar'): + return _MSG_MENU, 'menu', state_data + + if t in ('1', 'uno', 'cotizar'): + return None, 'quote_identify', state_data + + if t in ('2', 'dos', 'soporte'): + return None, 'support_phone', state_data + + if t in ('3', 'tres', 'facturación', 'facturacion'): + return None, 'invoice_identify', state_data + + return ( + "¿Hay algo más en lo que pueda ayudarte?\n\n" + "Escribe *menú* para volver al inicio o indícame qué necesitas.", + 'support_done', + state_data + ) + + +# ─── Estados de Cotización ─────────────────────────────────────────── + +@state('quote_identify') +def handle_quote_identify(text, state_data, ctx): + """Identificar cliente antes de cotizar.""" + from services.wa_customer import search_customers, link_wa_customer + + if state_data.get('customer_id'): + if state_data.get('preloaded_search'): + customer = _get_customer_by_id(ctx.tenant_conn, state_data['customer_id']) + first_name = customer['name'].split()[0] if customer and customer.get('name') else 'Cliente' + reply = f"Perfecto {first_name}, busco eso para ti... 🔍" + return reply, 'quote_search', state_data + return None, 'quote_search', state_data + + if text and not state_data.get('identify_prompted'): + customers = search_customers(text.strip(), ctx.tenant_conn) + + if len(customers) == 1: + state_data['customer_id'] = customers[0]['id'] + link_wa_customer(ctx.phone, customers[0]['id'], ctx.tenant_conn) + return _MSG_IDENTIFY_FOUND.format(name=customers[0]['name']), 'quote_search', state_data + + if len(customers) > 1: + state_data['customer_candidates'] = customers + state_data['identify_prompted'] = True + options = '\n'.join([f"{i+1}. {c['name']} (#{c['id']})" for i, c in enumerate(customers[:5])]) + return _MSG_IDENTIFY_MULTIPLE.format(options=options), 'quote_select_customer', state_data + + state_data['identify_prompted'] = True + # Si el texto es solo dígitos (ej: selección de menú), no usarlo como nombre propuesto + if text.strip().isdigit(): + state_data.pop('proposed_name', None) + else: + state_data['proposed_name'] = text.strip() + return _MSG_IDENTIFY_NOT_FOUND, 'quote_register_new', state_data + + state_data['identify_prompted'] = True + return _MSG_IDENTIFY_PROMPT, 'quote_identify', state_data + + +@state('quote_select_customer') +def handle_quote_select_customer(text, state_data, ctx): + """Seleccionar entre múltiples clientes candidatos.""" + from services.wa_customer import link_wa_customer + + candidates = state_data.get('customer_candidates', []) + if not candidates: + return _MSG_ERROR_RETRY, 'quote_identify', {'identify_prompted': True} + + try: + selection = int(text.strip()) + if 1 <= selection <= len(candidates): + chosen = candidates[selection - 1] + state_data['customer_id'] = chosen['id'] + del state_data['customer_candidates'] + link_wa_customer(ctx.phone, chosen['id'], ctx.tenant_conn) + return _MSG_IDENTIFY_FOUND.format(name=chosen['name']), 'quote_search', state_data + except (ValueError, IndexError): + pass + + return f"Por favor responde con un número del 1 al {len(candidates)}.", 'quote_select_customer', state_data + + +@state('quote_register_new') +def handle_quote_register_new(text, state_data, ctx): + """Registrar cliente nuevo o continuar como invitado.""" + from services.wa_customer import create_customer, link_wa_customer + + t = text.strip().lower() + + if t in ('si', 'sí', 'yes', 'ok', 'dale', 'va'): + # Si estamos esperando el nombre real del usuario + if state_data.get('awaiting_name'): + name = text.strip() + if len(name) < 2: + return "Por favor proporciona un nombre válido (más de 2 caracteres).", 'quote_register_new', state_data + state_data.pop('awaiting_name', None) + customer_id = create_customer(ctx.tenant_conn, phone=ctx.phone, name=name) + if customer_id: + state_data['customer_id'] = customer_id + link_wa_customer(ctx.phone, customer_id, ctx.tenant_conn) + return f"¡Listo! Te registré como *{name}*. ✅\n\nContinuamos con tu cotización.", 'quote_search', state_data + + name = state_data.get('proposed_name', '').strip() + # Si no hay nombre propuesto o parece ser un número de menú, pedir el nombre + if not name or name.isdigit(): + state_data['awaiting_name'] = True + return "¡Genial! ¿Cuál es tu *nombre completo*?", 'quote_register_new', state_data + + customer_id = create_customer(ctx.tenant_conn, phone=ctx.phone, name=name) + if customer_id: + state_data['customer_id'] = customer_id + link_wa_customer(ctx.phone, customer_id, ctx.tenant_conn) + return f"¡Listo! Te registré como *{name}*. ✅\n\nContinuamos con tu cotización.", 'quote_search', state_data + + if t in ('no', 'nope', 'nah', 'paso'): + state_data['customer_id'] = None + return ( + "De acuerdo, continuamos como invitado. 🎭\n\n" + "Puedes registrarte en cualquier momento si lo deseas.", + 'quote_search', state_data + ) + + return "¿Te gustaría registrarte? Responde *sí* o *no*.", 'quote_register_new', state_data + + +@state('quote_search') +def handle_quote_search(text, state_data, ctx): + """Recibir descripción de la pieza.""" + search_text = state_data.pop('preloaded_search', None) or text + + if not search_text or len(search_text.strip()) < 2: + return _MSG_QUOTE_SEARCH_PROMPT, 'quote_search', state_data + + search_text = search_text.strip() + state_data['search_description'] = search_text + + try: + from services.ai_chat import chat, chat_with_image + if ctx.media_kind == 'image' and ctx.media_base64: + ai_resp = chat_with_image( + user_message=search_text if search_text != '(imagen)' else 'Identifica esta parte automotriz y sugiere terminos de busqueda.', + image_base64=ctx.media_base64, + conversation_history=[], + inventory_context=None, + ) + else: + ai_resp = chat(search_text, conversation_history=[], inventory_context=None) + search_query = ai_resp.get('search_query') + vehicle = ai_resp.get('vehicle') + + if vehicle and vehicle.get('brand'): + state_data['vehicle'] = vehicle + from services.wa_quotation import set_vehicle + set_vehicle(ctx.tenant_conn, ctx.phone, vehicle) + + confidence = _assess_confidence(ai_resp, search_text) + state_data['ai_confidence'] = confidence + state_data['ai_search_query'] = search_query + + if confidence >= 0.7: + return None, 'quote_inventory_check', state_data + else: + state_data['learning_cycle'] = 1 + return None, 'quote_explore', state_data + + except Exception as e: + print(f"[WA-SM] AI error in quote_search: {e}") + state_data['ai_search_query'] = search_text + return None, 'quote_inventory_check', state_data + + +@state('quote_explore') +def handle_quote_explore(text, state_data, ctx): + """Ruta de exploración cuando la AI no está segura.""" + cycle = state_data.get('learning_cycle', 1) + + if not state_data.get('explore_offered'): + description = state_data.get('search_description', '') + explore_prompt = _build_explore_prompt(description, state_data.get('vehicle')) + + try: + from services.ai_chat import chat + ai_resp = chat(explore_prompt, conversation_history=[], inventory_context=None) + suggested_part = ai_resp.get('message', '') + search_query = ai_resp.get('search_query', '') + + state_data['explore_offered'] = True + state_data['explore_suggestion'] = suggested_part + state_data['explore_search_query'] = search_query + + offered = state_data.get('offered_parts', []) + offered.append({ + 'cycle': cycle, + 'suggestion': suggested_part, + 'search_query': search_query, + 'timestamp': datetime.now().isoformat() + }) + state_data['offered_parts'] = offered + + return _MSG_EXPLORE_OFFER.format(suggestion=suggested_part), 'quote_explore', state_data + + except Exception as e: + print(f"[WA-SM] Explore AI error: {e}") + return _MSG_EXPLORE_ERROR, 'quote_search', state_data + + t = text.strip().lower() if text else '' + + if t in ('si', 'sí', 'yes', 'ese', 'ese mero', 'correcto', 'va', 'dale'): + state_data['ai_search_query'] = state_data.get('explore_search_query', '') + state_data.pop('explore_offered', None) + state_data.pop('explore_suggestion', None) + return None, 'quote_inventory_check', state_data + + if t in ('no', 'nope', 'nah', 'otro', 'diferente'): + if cycle < 2: + state_data['learning_cycle'] = cycle + 1 + state_data.pop('explore_offered', None) + state_data.pop('explore_suggestion', None) + return _MSG_EXPLORE_RETRY, 'quote_explore', state_data + else: + return _trigger_learning_route(state_data, ctx) + + return _MSG_EXPLORE_CONFIRM_PROMPT, 'quote_explore', state_data + + +@state('quote_inventory_check') +def handle_quote_inventory_check(text, state_data, ctx): + """Buscar en inventario y mostrar resultados.""" + if not state_data.get('inventory_shown'): + search_query = state_data.get('ai_search_query', '') + vehicle = state_data.get('vehicle') + + enrichment, found_part = _enrich_wa_reply_with_part( + search_query, vehicle, ctx.tenant_conn, ctx.master_conn + ) + + state_data['inventory_shown'] = True + state_data['last_found_part'] = found_part + + if enrichment: + if found_part: + from services.wa_quotation import set_last_shown_part + set_last_shown_part(ctx.tenant_conn, ctx.phone, found_part) + reply = ( + "Aquí está lo que encontré en nuestro inventario:\n\n" + f"{enrichment}\n\n" + "¿Son las refacciones que necesitas?\n" + "Responde *sí* para continuar o *no* para buscar otra." + ) + return reply, 'quote_inventory_check', state_data + else: + reply = ( + "No tengo esa pieza exacta en stock actualmente. 😕\n\n" + "¿Te gustaría que busque una alternativa similar o prefieres que la ordene por encargo?\n\n" + "Responde *sí* para ver alternativas o *no* para regresar." + ) + state_data['inventory_empty'] = True + return reply, 'quote_inventory_check', state_data + + t = text.strip().lower() if text else '' + + if t in ('si', 'sí', 'yes', 'va', 'dale', 'correcto'): + if state_data.get('inventory_empty'): + # Si ya exploramos alternativas (quote_explore) y aún no hay stock, + # no entrar en loop infinito — transferir a soporte. + if state_data.get('offered_parts') and len(state_data['offered_parts']) > 0: + return _trigger_learning_route(state_data, ctx) + + state_data.pop('inventory_shown', None) + state_data.pop('inventory_empty', None) + # Resetear flags de explore para que quote_explore genere una nueva sugerencia + state_data.pop('explore_offered', None) + state_data.pop('explore_suggestion', None) + # Silent transition: el loop en whatsapp_bp.py ejecutará quote_explore + # inmediatamente y generará la sugerencia sin esperar otro mensaje del usuario + return None, 'quote_explore', state_data + + found_part = state_data.get('last_found_part') + if found_part: + state_data['confirmed_parts'] = state_data.get('confirmed_parts', []) + state_data['confirmed_parts'].append(found_part) + return None, 'quote_delivery_check', state_data + + if t in ('no', 'nope', 'nah', 'otro', 'diferente'): + state_data.pop('inventory_shown', None) + state_data.pop('last_found_part', None) + state_data.pop('inventory_empty', None) + return "De acuerdo. ¿Qué otra refacción estás buscando?", 'quote_search', state_data + + return "¿Confirmas que son estas refacciones? Responde *sí* o *no*.", 'quote_inventory_check', state_data + + +@state('quote_delivery_check') +def handle_quote_delivery_check(text, state_data, ctx): + """Determinar si el cliente quiere envío a domicilio.""" + if not state_data.get('delivery_checked'): + has_delivery = _check_branch_delivery(ctx.tenant_conn, ctx.branch_id) + state_data['delivery_checked'] = True + state_data['has_delivery'] = has_delivery + + if not has_delivery: + return None, 'quote_emit', state_data + + return _MSG_DELIVERY_PROMPT, 'quote_delivery_check', state_data + + t = text.strip().lower() if text else '' + + if t in ('si', 'sí', 'yes', 'va', 'dale'): + state_data['wants_delivery'] = True + return None, 'quote_delivery_address', state_data + + if t in ('no', 'nope', 'nah', 'paso', 'recojo'): + state_data['wants_delivery'] = False + return None, 'quote_emit', state_data + + return "¿Deseas envío a domicilio? Responde *sí* o *no*.", 'quote_delivery_check', state_data + + +@state('quote_delivery_address') +def handle_quote_delivery_address(text, state_data, ctx): + """Obtener o confirmar dirección de envío.""" + from services.wa_customer import get_customer_address, update_customer_address + + customer_id = state_data.get('customer_id') + + if not state_data.get('address_prompted'): + state_data['address_prompted'] = True + + if customer_id: + address = get_customer_address(ctx.tenant_conn, customer_id) + if address: + state_data['saved_address'] = address + return _MSG_DELIVERY_ADDRESS_CONFIRM.format(address=address), 'quote_delivery_address', state_data + + return _MSG_DELIVERY_ADDRESS_REQUEST, 'quote_delivery_address', state_data + + t = text.strip().lower() if text else '' + + if state_data.get('saved_address') and t in ('si', 'sí', 'yes', 'correcto', 'va'): + state_data['delivery_address'] = state_data['saved_address'] + return None, 'quote_emit', state_data + + if text and len(text.strip()) > 5: + state_data['delivery_address'] = text.strip() + if customer_id: + update_customer_address(ctx.tenant_conn, customer_id, text.strip()) + return _MSG_DELIVERY_ADDRESS_SAVED.format(address=text.strip()), 'quote_emit', state_data + + if state_data.get('saved_address'): + return "Responde *sí* para confirmar la dirección o escribe la nueva dirección.", 'quote_delivery_address', state_data + + return "Por favor proporciona una dirección válida para el envío.", 'quote_delivery_address', state_data + + +@state('quote_emit') +def handle_quote_emit(text, state_data, ctx): + """Crear/actualizar cotización y presentarla.""" + if not state_data.get('quote_emitted'): + state_data['quote_emitted'] = True + + confirmed_parts = state_data.get('confirmed_parts', []) + if not confirmed_parts: + return "No tienes refacciones seleccionadas. 😕\n\n¿Qué estás buscando?", 'quote_search', state_data + + from services.wa_quotation import ( + get_open_quotation, create_quotation, add_item_to_quotation, + get_quotation_detail, format_quotation_wa + ) + + qid = get_open_quotation(ctx.tenant_conn, ctx.phone) + if not qid: + qid = create_quotation(ctx.tenant_conn, ctx.phone) + + customer_id = state_data.get('customer_id') + if customer_id: + cur = ctx.tenant_conn.cursor() + cur.execute( + "UPDATE quotations SET customer_id = %s WHERE id = %s", + (customer_id, qid) + ) + ctx.tenant_conn.commit() + cur.close() + + for part in confirmed_parts: + add_item_to_quotation(ctx.tenant_conn, qid, part, quantity=1) + + if state_data.get('wants_delivery') and state_data.get('delivery_address'): + cur = ctx.tenant_conn.cursor() + cur.execute( + "UPDATE quotations SET notes = CONCAT(COALESCE(notes,''), ' | DELIVERY: ', %s) WHERE id = %s", + (state_data['delivery_address'], qid) + ) + ctx.tenant_conn.commit() + cur.close() + + detail = get_quotation_detail(ctx.tenant_conn, qid) + quote_text = format_quotation_wa(detail) + + reply = ( + f"{quote_text}\n\n" + "¿Qué deseas hacer?\n" + "• Escribe *agregar* para más productos\n" + "• Escribe *enviar* para recibir tu cotización por imagen\n" + "• Escribe *confirmar* para hacer el pedido\n" + "• Escribe *limpiar* para empezar de nuevo" + ) + return reply, 'quote_command_created', state_data + + return None, 'quote_command_created', state_data + + +@state('quote_command_created') +def handle_quote_command_created(text, state_data, ctx): + """Escuchar comandos sobre la cotización existente.""" + t = text.strip().lower() if text else '' + + if t in ('agregar', 'más', 'mas', 'otro', 'otra', 'seguir'): + state_data.pop('inventory_shown', None) + state_data.pop('last_found_part', None) + state_data.pop('inventory_empty', None) + state_data.pop('quote_emitted', None) + return "¿Qué otra refacción necesitas?", 'quote_search', state_data + + if t in ('enviar', 'mandar', 'pdf', 'imagen', 'foto'): + try: + from services.wa_quotation import get_open_quotation, get_quotation_detail + from services.quote_image import generate_quote_image + from services.whatsapp_service import send_image + + qid = get_open_quotation(ctx.tenant_conn, ctx.phone) + if qid: + detail = get_quotation_detail(ctx.tenant_conn, qid) + quote_items = [{ + 'name': it.get('name', ''), + 'sku': it.get('part_number', ''), + 'qty': it.get('quantity', 1), + 'price': float(it.get('unit_price', 0)), + 'total': float(it.get('total', 0)), + } for it in detail.get('items', [])] + totals = { + 'subtotal': float(detail.get('subtotal', 0)), + 'tax': float(detail.get('tax_total', 0)), + 'total': float(detail.get('total', 0)), + } + tenant_name = _get_tenant_business_name(ctx.tenant_conn) + b64_img = generate_quote_image(quote_items, totals, tenant_name=tenant_name) + img_result = send_image( + ctx.phone, + caption="Aquí está tu cotización 👇", + base64_image=b64_img, + bridge_url=ctx.wa_config.get('bridge_url') + ) + if img_result.get('success'): + return ( + "📎 *Te envié tu cotización en imagen.*\n\n" + "¿Qué deseas hacer?\n" + "• *agregar* — más productos\n" + "• *confirmar* — hacer el pedido\n" + "• *limpiar* — empezar de nuevo", + 'quote_command_created', + state_data + ) + except Exception as e: + print(f"[WA-SM] Quote image failed: {e}") + + from services.wa_quotation import get_open_quotation, get_quotation_detail, format_quotation_wa + qid = get_open_quotation(ctx.tenant_conn, ctx.phone) + if qid: + detail = get_quotation_detail(ctx.tenant_conn, qid) + return format_quotation_wa(detail), 'quote_command_created', state_data + + if t in ('confirmar', 'confirmo', 'acepto', 'si', 'sí', 'va', 'ordenar', 'pedir'): + from services.wa_quotation import confirm_quotation + qid = confirm_quotation(ctx.tenant_conn, ctx.phone) + if qid: + reply = ( + "✅ *¡Pedido confirmado!*\n\n" + "Tu cotización fue registrada.\n" + "Nos pondremos en contacto contigo para coordinar la entrega.\n\n" + "¡Gracias por tu compra! 🙏" + ) + clean_data = { + 'customer_id': state_data.get('customer_id'), + 'branch_name': state_data.get('branch_name'), + } + return reply, 'support_done', clean_data + + if t in ('limpiar', 'borrar', 'nueva', 'cancelar', 'reset'): + from services.wa_quotation import clear_quotation + clear_quotation(ctx.tenant_conn, ctx.phone) + clean_data = { + 'customer_id': state_data.get('customer_id'), + 'branch_name': state_data.get('branch_name'), + } + return ( + "🗑️ Cotización limpiada.\n\n" + "¿Qué refacción estás buscando?", + 'quote_search', + clean_data + ) + + return ( + "No entendí. Puedes escribir:\n" + "• *agregar* — para más productos\n" + "• *enviar* — para recibir imagen\n" + "• *confirmar* — para hacer el pedido\n" + "• *limpiar* — para empezar de nuevo", + 'quote_command_created', + state_data + ) + + +# ─── Estados de Facturación ────────────────────────────────────────── + +@state('invoice_identify') +def handle_invoice_identify(text, state_data, ctx): + """Identificar cliente antes de buscar facturas.""" + from services.wa_customer import search_customers, link_wa_customer + + if state_data.get('customer_id'): + return None, 'invoice_search', state_data + + if not state_data.get('invoice_identify_prompted'): + state_data['invoice_identify_prompted'] = True + return "Para buscar tus facturas, ¿podrías indicarme tu *nombre* o *número de cliente*?", 'invoice_identify', state_data + + if text: + customers = search_customers(text.strip(), ctx.tenant_conn) + + if len(customers) == 1: + state_data['customer_id'] = customers[0]['id'] + link_wa_customer(ctx.phone, customers[0]['id'], ctx.tenant_conn) + return f"¡Perfecto, {customers[0]['name']}! Buscando tus facturas... 📋", 'invoice_search', state_data + + if len(customers) > 1: + state_data['invoice_customer_candidates'] = customers + options = '\n'.join([f"{i+1}. {c['name']} (#{c['id']})" for i, c in enumerate(customers[:5])]) + return f"Encontré varios clientes:\n\n{options}\n\nResponde con el número correcto.", 'invoice_select_customer', state_data + + return ( + "No te encontré en el sistema. Sin un cliente registrado no puedo buscar facturas existentes.\n\n" + "Si necesitas generar una factura nueva, puedo ayudarte a capturar tus datos fiscales.\n" + "Responde *nueva* para generar una factura nueva o *menu* para volver.", + 'invoice_no_customer', + state_data + ) + + return "Por favor indícame tu nombre o número de cliente.", 'invoice_identify', state_data + + +@state('invoice_select_customer') +def handle_invoice_select_customer(text, state_data, ctx): + """Seleccionar cliente entre múltiples candidatos en facturación.""" + from services.wa_customer import link_wa_customer + + candidates = state_data.get('invoice_customer_candidates', []) + try: + selection = int(text.strip()) + if 1 <= selection <= len(candidates): + chosen = candidates[selection - 1] + state_data['customer_id'] = chosen['id'] + del state_data['invoice_customer_candidates'] + link_wa_customer(ctx.phone, chosen['id'], ctx.tenant_conn) + return f"¡Perfecto, {chosen['name']}! Buscando tus facturas...", 'invoice_search', state_data + except (ValueError, IndexError): + pass + + return f"Responde con un número del 1 al {len(candidates)}.", 'invoice_select_customer', state_data + + +@state('invoice_no_customer') +def handle_invoice_no_customer(text, state_data, ctx): + """Cliente no encontrado en facturación.""" + t = text.strip().lower() if text else '' + + if t in ('nueva', 'nueva factura', 'generar', 'sí', 'si', 'ok'): + return None, 'invoice_capture_fiscal', state_data + + if t in ('menu', 'menú', 'inicio', 'volver'): + return _MSG_MENU, 'menu', state_data + + return ( + "¿Qué deseas hacer?\n" + "• *nueva* — Generar factura nueva\n" + "• *menú* — Volver al inicio", + 'invoice_no_customer', + state_data + ) + + +@state('invoice_search') +def handle_invoice_search(text, state_data, ctx): + """Buscar facturas del cliente.""" + customer_id = state_data.get('customer_id') + if not customer_id: + return "Error: cliente no identificado.", 'invoice_identify', state_data + + cur = ctx.tenant_conn.cursor() + cur.execute(""" + SELECT s.id, s.created_at, s.total, + (SELECT COUNT(*) FROM sale_items WHERE sale_id = s.id) as item_count + FROM sales s + WHERE s.customer_id = %s AND s.status = 'completed' + ORDER BY s.created_at DESC + LIMIT 5 + """, (customer_id,)) + rows = cur.fetchall() + cur.close() + + if not rows: + return ( + "No encontré facturas de compras a tu nombre. 😕\n\n" + "Si necesitas generar una factura para una compra reciente, " + "puedo ayudarte a capturar tus datos fiscales y el ticket.\n\n" + "Responde *sí* para continuar o *menú* para volver.", + 'invoice_capture_fiscal', + state_data + ) + + lines = ["📋 *Tus facturas recientes:*\n"] + for i, row in enumerate(rows, 1): + sale_id, created_at, total, item_count = row + if hasattr(created_at, 'strftime'): + date_str = created_at.strftime('%d/%m/%Y') + else: + date_str = str(created_at)[:10] + lines.append(f"{i}. Folio #{sale_id} — ${float(total):,.2f} — {date_str} ({item_count} artículos)") + + lines.append("\n6. *Otra* — Indicar cuál necesito") + lines.append("\nResponde con el número de la factura que necesitas.") + + state_data['invoice_sales'] = [{ + 'id': r[0], 'created_at': str(r[1]), 'total': float(r[2]), 'item_count': r[3] + } for r in rows] + + return '\n'.join(lines), 'invoice_select', state_data + + +@state('invoice_select') +def handle_invoice_select(text, state_data, ctx): + """Cliente selecciona una factura de la lista.""" + sales = state_data.get('invoice_sales', []) + t = text.strip().lower() if text else '' + + if t in ('6', 'otra', 'otro', 'otra factura', 'buscar'): + return "Indícame el folio, fecha aproximada o monto de la factura que necesitas.", 'invoice_custom_search', state_data + + try: + selection = int(t) + if 1 <= selection <= len(sales): + sale = sales[selection - 1] + state_data['selected_sale_id'] = sale['id'] + return _process_invoice_send(sale['id'], state_data, ctx) + except (ValueError, IndexError): + pass + + return ( + f"Por favor responde con un número del 1 al {len(sales)}, o *6* para buscar otra.", + 'invoice_select', + state_data + ) + + +@state('invoice_custom_search') +def handle_invoice_custom_search(text, state_data, ctx): + """Buscar factura por criterios alternativos.""" + if not text or len(text.strip()) < 2: + return "Indícame el folio, fecha (dd/mm/aaaa) o monto de la factura.", 'invoice_custom_search', state_data + + criteria = text.strip() + customer_id = state_data.get('customer_id') + + cur = ctx.tenant_conn.cursor() + + # 1. Buscar por ID exacto + try: + sale_id = int(criteria) + cur.execute( + "SELECT id, created_at, total FROM sales WHERE id = %s AND customer_id = %s AND status = 'completed'", + (sale_id, customer_id) + ) + row = cur.fetchone() + if row: + cur.close() + state_data['selected_sale_id'] = row[0] + return _process_invoice_send(row[0], state_data, ctx) + except ValueError: + pass + + # 2. Buscar por fecha + for fmt in ('%d/%m/%Y', '%d-%m-%Y', '%Y-%m-%d'): + try: + from datetime import datetime + date_obj = datetime.strptime(criteria, fmt) + cur.execute( + "SELECT id, created_at, total FROM sales WHERE DATE(created_at) = %s AND customer_id = %s AND status = 'completed' LIMIT 5", + (date_obj.date(), customer_id) + ) + rows = cur.fetchall() + if len(rows) == 1: + cur.close() + state_data['selected_sale_id'] = rows[0][0] + return _process_invoice_send(rows[0][0], state_data, ctx) + elif len(rows) > 1: + cur.close() + lines = ["Encontré varias facturas en esa fecha:\n"] + for i, r in enumerate(rows, 1): + lines.append(f"{i}. Folio #{r[0]} — ${float(r[2]):,.2f}") + lines.append("\nResponde con el número.") + state_data['invoice_custom_results'] = [{'id': r[0]} for r in rows] + return '\n'.join(lines), 'invoice_custom_results', state_data + except ValueError: + continue + + # 3. Buscar por monto aproximado + try: + amount = float(criteria.replace(',', '').replace('$', '')) + cur.execute( + "SELECT id, created_at, total FROM sales WHERE ABS(total - %s) < 10 AND customer_id = %s AND status = 'completed' LIMIT 5", + (amount, customer_id) + ) + rows = cur.fetchall() + if len(rows) == 1: + cur.close() + state_data['selected_sale_id'] = rows[0][0] + return _process_invoice_send(rows[0][0], state_data, ctx) + elif len(rows) > 1: + cur.close() + lines = ["Encontré varias facturas con ese monto:\n"] + for i, r in enumerate(rows, 1): + if hasattr(r[1], 'strftime'): + date_str = r[1].strftime('%d/%m/%Y') + else: + date_str = str(r[1])[:10] + lines.append(f"{i}. Folio #{r[0]} — ${float(r[2]):,.2f} — {date_str}") + lines.append("\nResponde con el número.") + state_data['invoice_custom_results'] = [{'id': r[0]} for r in rows] + return '\n'.join(lines), 'invoice_custom_results', state_data + except ValueError: + pass + + cur.close() + return ( + "No encontré facturas con ese criterio. 😕\n\n" + "Intenta con:\n" + "• Número de folio exacto\n" + "• Fecha (dd/mm/aaaa)\n" + "• Monto total (ej. 1234.50)\n\n" + "O responde *nueva* si necesitas generar una factura nueva.", + 'invoice_custom_search', + state_data + ) + + +@state('invoice_custom_results') +def handle_invoice_custom_results(text, state_data, ctx): + """Seleccionar de resultados de búsqueda alternativa.""" + results = state_data.get('invoice_custom_results', []) + try: + selection = int(text.strip()) + if 1 <= selection <= len(results): + sale_id = results[selection - 1]['id'] + state_data['selected_sale_id'] = sale_id + return _process_invoice_send(sale_id, state_data, ctx) + except (ValueError, IndexError): + pass + + return f"Responde con un número del 1 al {len(results)}.", 'invoice_custom_results', state_data + + +@state('invoice_capture_fiscal') +def handle_invoice_capture_fiscal(text, state_data, ctx): + """Capturar datos fiscales para nueva factura.""" + if not state_data.get('fiscal_prompted'): + state_data['fiscal_prompted'] = True + return _MSG_INVOICE_FISCAL_PROMPT, 'invoice_capture_fiscal', state_data + + if not text or len(text.strip()) < 5: + return "Por favor envíame tus datos fiscales completos.", 'invoice_capture_fiscal', state_data + + fiscal = _parse_fiscal_data(text.strip()) + + if not fiscal.get('rfc'): + return ( + "No pude identificar tu RFC. Por favor envíame los datos en este formato:\n\n" + "RFC: XXXX000000XXX\n" + "Razón social: Tu Empresa S.A. de C.V.\n" + "Uso CFDI: G03\n" + "CP: 12345", + 'invoice_capture_fiscal', + state_data + ) + + state_data['fiscal_data'] = fiscal + return ( + f"✅ Datos fiscales capturados:\n\n" + f"RFC: {fiscal['rfc']}\n" + f"Razón social: {fiscal['razon_social']}\n" + f"Uso CFDI: {fiscal.get('uso_cfdi', 'G03')}\n" + f"CP: {fiscal.get('cp', 'N/A')}\n\n" + f"Ahora envíame una foto del ticket de compra.", + 'invoice_capture_ticket', + state_data + ) + + +@state('invoice_capture_ticket') +def handle_invoice_capture_ticket(text, state_data, ctx): + """Recibir foto del ticket de compra.""" + if ctx.media_kind == 'image' and ctx.media_base64: + state_data['ticket_image_b64'] = ctx.media_base64 + return ( + "✅ Ticket recibido.\n\n" + "Procesando tu factura... Esto puede tomar unos momentos.", + 'invoice_generate', + state_data + ) + + return ( + "Por favor envíame una *foto* del ticket de compra para validar los datos. 📸\n\n" + "Asegúrate de que se vean claramente los productos, montos y fecha.", + 'invoice_capture_ticket', + state_data + ) + + +@state('invoice_generate') +def handle_invoice_generate(text, state_data, ctx): + """Generar factura vía API SAT (placeholder en fase 1).""" + return ( + "📄 *Factura en proceso*\n\n" + "He recibido todos tus datos y el ticket. Tu solicitud fue enviada al área de facturación.\n\n" + "Te contactaremos en cuanto tu factura esté lista (usualmente en menos de 24 horas hábiles).\n\n" + "¿Necesitas algo más? Escribe *menú* para volver.", + 'invoice_send', + state_data + ) + + +@state('invoice_send') +def handle_invoice_send(text, state_data, ctx): + """Confirmar envío de factura y cerrar flujo.""" + return None, 'support_done', state_data + + +# ─── Funciones auxiliares ──────────────────────────────────────────── + +def _is_greeting(text): + """Detectar si el texto es un saludo.""" + greetings = ('hola', 'buenos dias', 'buenas tardes', 'buenas noches', + 'hey', 'que onda', 'saludos', 'buen dia', 'buena tarde', + 'buena noche', 'qué tal', 'como estas', 'cómo estás') + return text.strip().lower() in greetings + + +def _get_branch_name(tenant_conn, branch_id): + """Obtener nombre de la sucursal.""" + if not tenant_conn: + return 'Autopartes' + try: + cur = tenant_conn.cursor() + if branch_id: + cur.execute("SELECT name 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_business_name'") + row = cur.fetchone() + cur.close() + return row[0] if row and row[0] else 'Autopartes' + except Exception as e: + print(f"[WA-SM] get_branch_name error: {e}") + return 'Autopartes' + + +def _get_branch_phone(tenant_conn, branch_id): + """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 _get_tenant_business_name(tenant_conn): + """Obtener nombre del negocio desde tenant_config.""" + if not tenant_conn: + return 'Autopartes' + try: + cur = tenant_conn.cursor() + cur.execute("SELECT value FROM tenant_config WHERE key = 'tenant_business_name'") + row = cur.fetchone() + cur.close() + return row[0] if row and row[0] else 'Autopartes' + except Exception: + return 'Autopartes' + + +def _get_customer_by_id(tenant_conn, customer_id): + """Wrapper interno para obtener cliente por ID.""" + from services.wa_customer import get_customer_by_id + return get_customer_by_id(tenant_conn, customer_id) + + +def _check_branch_delivery(tenant_conn, branch_id): + """Verificar si la sucursal tiene envío activo.""" + if not tenant_conn: + return False + try: + cur = tenant_conn.cursor() + cur.execute( + "SELECT is_enabled FROM branch_delivery_config WHERE branch_id = %s", + (branch_id,) + ) + row = cur.fetchone() + cur.close() + if row: + return row[0] + + cur = tenant_conn.cursor() + cur.execute("SELECT value FROM tenant_config WHERE key = 'delivery_enabled'") + row = cur.fetchone() + cur.close() + return row[0].lower() == 'true' if row and row[0] else False + except Exception as e: + print(f"[WA-SM] check_branch_delivery error: {e}") + return False + + +def _assess_confidence(ai_resp, original_text): + """Evaluar confianza de la AI en su interpretación.""" + search_query = ai_resp.get('search_query', '') or '' + if not search_query: + return 0.0 + + if re.search(r'[A-Z0-9]{5,}', original_text.upper()): + return 0.95 + + words = search_query.split() + if len(words) <= 1: + return 0.4 + + if ai_resp.get('vehicle', {}).get('brand') and len(words) >= 2: + return 0.85 + + return 0.6 + + +def _build_explore_prompt(description, vehicle=None): + """Construir prompt de exploración para la AI.""" + vehicle_str = "" + if vehicle: + vehicle_str = f"Vehículo: {vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}. " + + return ( + f"El cliente busca una refacción automotriz pero la descripción es ambigua.\n" + f"{vehicle_str}\n" + f"Descripción del cliente: '{description}'\n\n" + f"Tu tarea: Identificar la pieza MÁS PROBABLE que el cliente necesita. " + f"Responde en JSON: {{\"message\":\"nombre de la pieza sugerida\", " + f"\"search_query\":\"términos en inglés para buscar en catálogo\", " + f"\"vehicle\":{{\"brand\":\"...\",\"model\":\"...\",\"year\":\"...\"}}}}\n\n" + f"La 'message' debe ser una sugerencia clara y específica en español, " + f"como 'Balata de freno delantera cerámica' o 'Filtro de aceite Mann W712/80'." + ) + + +def _trigger_learning_route(state_data, ctx): + """Registrar sesión no resuelta y transferir a soporte.""" + try: + from services.wa_learning import register_unresolved_search + register_unresolved_search( + phone=ctx.phone, + customer_id=state_data.get('customer_id'), + description=state_data.get('search_description', ''), + offered_parts=state_data.get('offered_parts', []), + tenant_conn=ctx.tenant_conn + ) + except Exception as e: + print(f"[WA-SM] Learning registration failed: {e}") + + phone_branch = _get_branch_phone(ctx.tenant_conn, ctx.branch_id) + reply = _MSG_LEARNING_TRANSFER.format(phone=phone_branch) + + clean_data = { + 'customer_id': state_data.get('customer_id'), + 'branch_name': state_data.get('branch_name'), + } + return reply, 'support_done', clean_data + + +def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn): + """Wrapper para la función existente en whatsapp_bp (import local para evitar circular).""" + from blueprints.whatsapp_bp import _enrich_wa_reply_with_part as enrich + return enrich(search_query, vehicle, tenant_conn, master_conn) + + +def _parse_fiscal_data(text): + """Extraer RFC, razón social, uso CFDI y CP de texto libre.""" + result = {} + + rfc_match = re.search(r'[A-ZÑ&]{3,4}[0-9]{6}[A-ZÑ&0-9]{3}', text.upper()) + if rfc_match: + result['rfc'] = rfc_match.group(0) + + rs_patterns = [ + r'(?:raz[oó]n social|nombre|empresa)[\s:]+([^\n]+)', + r'(?:razon social)[\s:]+([^\n]+)', + ] + for pat in rs_patterns: + match = re.search(pat, text, re.IGNORECASE) + if match: + result['razon_social'] = match.group(1).strip() + break + + uso_match = re.search(r'(?:uso\s*cfdi|cfdi)[\s:]+([A-Z0-9]{3})', text, re.IGNORECASE) + if uso_match: + result['uso_cfdi'] = uso_match.group(1).upper() + + cp_match = re.search(r'(?:cp|c\.p\.|código postal)[\s:]+(\d{5})', text, re.IGNORECASE) + if cp_match: + result['cp'] = cp_match.group(1) + + if not result.get('razon_social'): + lines = [l.strip() for l in text.split('\n') if l.strip()] + for line in lines: + if not re.search(r'[A-ZÑ&]{3,4}[0-9]{6}', line) and not re.match(r'\d{5}$', line): + if len(line) > 3: + result['razon_social'] = line + break + + return result + + +def _process_invoice_send(sale_id, state_data, ctx): + """Buscar CFDI existente o encolar generación.""" + cur = ctx.tenant_conn.cursor() + + cur.execute(""" + SELECT id, status, xml_signed, uuid_fiscal, provisional_folio + FROM cfdi_queue + WHERE sale_id = %s AND type = 'ingreso' AND status IN ('stamped', 'signed') + ORDER BY created_at DESC LIMIT 1 + """, (sale_id,)) + row = cur.fetchone() + + if row and row[2]: + cur.close() + return ( + f"📄 *Factura encontrada*\n\n" + f"Folio fiscal: {row[4] or row[3] or 'N/A'}\n" + f"UUID: {row[3] or 'N/A'}\n\n" + f"Te la envío en un momento...\n\n" + f"¿Necesitas algo más? Escribe *menú* para volver.", + 'invoice_send', + state_data + ) + + cur.close() + return ( + "📄 Estoy generando tu factura...\n\n" + "Esto puede tomar unos momentos. Te la enviaré en cuanto esté lista.\n\n" + "¿Necesitas algo más? Escribe *menú* para volver.", + 'invoice_send', + state_data + ) + + +# ─── Persistencia de sesión (para uso desde whatsapp_bp) ───────────── + +def get_session(tenant_conn, phone): + """Cargar sesión WA desde DB.""" + from services.wa_quotation import _ensure_sessions_table + _ensure_sessions_table(tenant_conn) + + cur = tenant_conn.cursor() + cur.execute(""" + SELECT state, state_data, customer_id, updated_at + FROM whatsapp_sessions WHERE phone = %s + """, (phone,)) + row = cur.fetchone() + cur.close() + + if row: + return { + 'state': row[0] or 'idle', + 'state_data': row[1] if isinstance(row[1], dict) else (json.loads(row[1]) if row[1] else {}), + 'customer_id': row[2], + 'updated_at': row[3], + } + return {'state': 'idle', 'state_data': {}, 'customer_id': None, 'updated_at': None} + + +def save_session(tenant_conn, phone, state, state_data): + """Persistir estado de sesión.""" + from services.wa_quotation import _ensure_sessions_table + _ensure_sessions_table(tenant_conn) + + cur = tenant_conn.cursor() + cur.execute(""" + INSERT INTO whatsapp_sessions (phone, state, state_data, updated_at) + VALUES (%s, %s, %s, NOW()) + ON CONFLICT (phone) DO UPDATE SET + state = EXCLUDED.state, + state_data = EXCLUDED.state_data, + updated_at = NOW() + """, (phone, state, json.dumps(state_data))) + tenant_conn.commit() + cur.close() diff --git a/pos/services/whatsapp_service.py b/pos/services/whatsapp_service.py index d9bbba2..067b71e 100644 --- a/pos/services/whatsapp_service.py +++ b/pos/services/whatsapp_service.py @@ -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', ''), diff --git a/pos/static/js/app-init.js b/pos/static/js/app-init.js index 92d6b21..eda4ddb 100644 --- a/pos/static/js/app-init.js +++ b/pos/static/js/app-init.js @@ -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) {} + })(); diff --git a/pos/static/js/config.js b/pos/static/js/config.js index ddfadd1..7f403b9 100644 --- a/pos/static/js/config.js +++ b/pos/static/js/config.js @@ -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 diff --git a/pos/static/js/sidebar.js b/pos/static/js/sidebar.js index b742337..824a5c8 100644 --- a/pos/static/js/sidebar.js +++ b/pos/static/js/sidebar.js @@ -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: '' }, @@ -28,14 +38,14 @@ { label: _t('nav_management'), items: [ { name: _t('customers'), href: '/pos/customers', icon: '' }, { name: 'Cotizaciones', href: '/pos/quotations', icon: '' }, - { name: 'Marketplace', href: '/pos/marketplace', icon: '' }, - { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '' }, + moduleEnabled('marketplace') ? { name: 'Marketplace', href: '/pos/marketplace', icon: '' } : null, + moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '' } : null, { name: _t('invoicing'), href: '/pos/invoicing', icon: '' }, { name: _t('accounting'), href: '/pos/accounting', icon: '' }, { name: _t('reports'), href: '/pos/reports', icon: '' }, { name: _t('fleet'), href: '/pos/fleet', icon: '' }, - { name: _t('whatsapp'), href: '/pos/whatsapp', icon: '' }, - ]}, + moduleEnabled('whatsapp') ? { name: _t('whatsapp'), href: '/pos/whatsapp', icon: '' } : null, + ].filter(Boolean)}, { label: _t('nav_system'), items: [ { name: _t('config'), href: '/pos/config', icon: '' }, ]}, diff --git a/pos/static/pwa/sw.js b/pos/static/pwa/sw.js index bd861fa..b89aa78 100644 --- a/pos/static/pwa/sw.js +++ b/pos/static/pwa/sw.js @@ -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); diff --git a/pos/templates/config.html b/pos/templates/config.html index 774f816..1cc351d 100644 --- a/pos/templates/config.html +++ b/pos/templates/config.html @@ -251,7 +251,58 @@ +
+
+
+ +
+
+
Módulos e Integraciones
+
Activa o desactiva funcionalidades del menú para este tenant
+
+
+ +
+
+
+ WhatsApp + Mostrar el menú de WhatsApp Bridge y chat +
+ +
+
+
+ Marketplace + Mostrar el menú de Marketplace interno +
+ +
+
+
+ MercadoLibre + Mostrar el menú de integración con MercadoLibre +
+ +
+
+ +
+
+
+ +
diff --git a/pos/whatsapp-bridge-package.json b/pos/whatsapp-bridge-package.json index 9e9d959..40958b6 100644 --- a/pos/whatsapp-bridge-package.json +++ b/pos/whatsapp-bridge-package.json @@ -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" diff --git a/pos/whatsapp-bridge-server.js b/pos/whatsapp-bridge-server.js index 17dbac2..81a7b1c 100644 --- a/pos/whatsapp-bridge-server.js +++ b/pos/whatsapp-bridge-server.js @@ -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;