/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 @@
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
|