feat: module toggles in POS config and Instance Manager
- Add GET/PUT /pos/api/config/modules endpoints in POS config_bp.py - Update sidebar.js to filter nav items based on enabled modules - Add Modules section to POS config.html with toggles for WhatsApp, Marketplace, MercadoLibre - Add module load/save logic to POS config.js - Preload modules in app-init.js for sidebar caching - Add tenant module management to Instance Manager - get_tenant_modules / update_tenant_modules in tenant_service.py - GET/PUT /api/tenants/<id>/modules endpoints in tenants_bp.py - Add modules modal to manager index.html - Add module editing UI and logic to manager.js - Add toggle-switch CSS to manager.css
This commit is contained in:
@@ -440,6 +440,13 @@ def process_sale(conn, sale_data):
|
||||
except Exception:
|
||||
pass # Savings errors never block sales
|
||||
|
||||
# WhatsApp learning hook (non-blocking)
|
||||
try:
|
||||
from services.wa_learning import check_learning_resolution
|
||||
check_learning_resolution(sale_id, customer_id, conn)
|
||||
except Exception:
|
||||
pass # Learning errors never block sales
|
||||
|
||||
return {
|
||||
'id': sale_id,
|
||||
'branch_id': branch_id,
|
||||
|
||||
140
pos/services/wa_customer.py
Normal file
140
pos/services/wa_customer.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
WhatsApp Customer Service — identificación y vinculación de clientes.
|
||||
|
||||
Funciones para buscar, crear y vincular clientes desde el flujo de WhatsApp.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def find_customer_by_phone(phone, tenant_conn):
|
||||
"""Buscar cliente por número de teléfono exacto o parcial."""
|
||||
if not tenant_conn or not phone:
|
||||
return []
|
||||
cur = tenant_conn.cursor()
|
||||
# Limpiar phone de prefijos internacionales para búsqueda flexible
|
||||
clean = phone.replace('+52', '').replace('52', '').lstrip('1')
|
||||
cur.execute("""
|
||||
SELECT id, name, phone, address, rfc
|
||||
FROM customers
|
||||
WHERE phone = %s OR phone LIKE %s OR phone LIKE %s
|
||||
LIMIT 5
|
||||
""", (phone, f'%{clean}', f'%{clean[-10:]}' if len(clean) >= 10 else f'%{clean}'))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [{'id': r[0], 'name': r[1], 'phone': r[2], 'address': r[3], 'rfc': r[4]} for r in rows]
|
||||
|
||||
|
||||
def find_customer_by_name(name, tenant_conn):
|
||||
"""Buscar cliente por nombre (ILIKE)."""
|
||||
if not tenant_conn or not name:
|
||||
return []
|
||||
cur = tenant_conn.cursor()
|
||||
# Buscar por nombre completo o primer palabra
|
||||
first_word = name.split()[0] if name else name
|
||||
cur.execute("""
|
||||
SELECT id, name, phone, address, rfc
|
||||
FROM customers
|
||||
WHERE name ILIKE %s OR name ILIKE %s
|
||||
LIMIT 5
|
||||
""", (f'%{name}%', f'%{first_word}%'))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [{'id': r[0], 'name': r[1], 'phone': r[2], 'address': r[3], 'rfc': r[4]} for r in rows]
|
||||
|
||||
|
||||
def search_customers(query, tenant_conn):
|
||||
"""Buscar por teléfono o nombre."""
|
||||
if not tenant_conn or not query:
|
||||
return []
|
||||
# Detectar si es número de teléfono
|
||||
digits = re.sub(r'\D', '', query)
|
||||
if len(digits) >= 7:
|
||||
by_phone = find_customer_by_phone(digits, tenant_conn)
|
||||
if by_phone:
|
||||
return by_phone
|
||||
return find_customer_by_name(query, tenant_conn)
|
||||
|
||||
|
||||
def get_customer_by_id(tenant_conn, customer_id):
|
||||
"""Obtener cliente por ID."""
|
||||
if not tenant_conn or not customer_id:
|
||||
return None
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, name, phone, address, rfc, vehicle_info
|
||||
FROM customers WHERE id = %s
|
||||
""", (customer_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if row:
|
||||
return {
|
||||
'id': row[0], 'name': row[1], 'phone': row[2],
|
||||
'address': row[3], 'rfc': row[4], 'vehicle_info': row[5]
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def create_customer(tenant_conn, phone, name, email=None, address=None, rfc=None):
|
||||
"""Crear cliente nuevo desde WhatsApp."""
|
||||
if not tenant_conn:
|
||||
return None
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO customers (name, phone, email, address, rfc, is_active, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, TRUE, NOW())
|
||||
RETURNING id
|
||||
""", (name, phone, email, address, rfc))
|
||||
cid = cur.fetchone()[0]
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return cid
|
||||
|
||||
|
||||
def link_wa_customer(phone, customer_id, tenant_conn):
|
||||
"""Vincular número WA a cliente permanentemente."""
|
||||
if not tenant_conn or not phone or not customer_id:
|
||||
return
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO wa_customer_links (phone, customer_id, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (phone) DO UPDATE SET customer_id = EXCLUDED.customer_id, updated_at = NOW()
|
||||
""", (phone, customer_id))
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def get_linked_customer(phone, tenant_conn):
|
||||
"""Obtener customer_id vinculado a un número WA."""
|
||||
if not tenant_conn or not phone:
|
||||
return None
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("SELECT customer_id FROM wa_customer_links WHERE phone = %s", (phone,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def get_customer_address(tenant_conn, customer_id):
|
||||
"""Obtener dirección del cliente."""
|
||||
if not tenant_conn or not customer_id:
|
||||
return None
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("SELECT address FROM customers WHERE id = %s", (customer_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return row[0] if row and row[0] else None
|
||||
|
||||
|
||||
def update_customer_address(tenant_conn, customer_id, address):
|
||||
"""Actualizar dirección del cliente."""
|
||||
if not tenant_conn or not customer_id or not address:
|
||||
return
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE customers SET address = %s WHERE id = %s",
|
||||
(address, customer_id)
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
127
pos/services/wa_learning.py
Normal file
127
pos/services/wa_learning.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
WhatsApp Learning Service — ruta de aprendizaje para piezas no resueltas.
|
||||
|
||||
Registra sesiones donde el bot no pudo identificar una pieza, y las resuelve
|
||||
asíncronamente cuando el cliente realiza una compra futura.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def register_unresolved_search(phone, customer_id, description, offered_parts, tenant_conn):
|
||||
"""Registrar una sesión no resuelta para aprendizaje futuro."""
|
||||
if not tenant_conn or not phone or not description:
|
||||
return None
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO wa_learning_sessions (phone, customer_id, description, offered_parts, status, created_at)
|
||||
VALUES (%s, %s, %s, %s, 'pending', NOW())
|
||||
RETURNING id
|
||||
""", (phone, customer_id, description, json.dumps(offered_parts or [])))
|
||||
sid = cur.fetchone()[0]
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return sid
|
||||
|
||||
|
||||
def find_pending_sessions(phone, tenant_conn):
|
||||
"""Buscar sesiones pendientes de aprendizaje para un número WA."""
|
||||
if not tenant_conn or not phone:
|
||||
return []
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, description, offered_parts, created_at
|
||||
FROM wa_learning_sessions
|
||||
WHERE phone = %s AND status = 'pending'
|
||||
ORDER BY created_at DESC
|
||||
""", (phone,))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [{'id': r[0], 'description': r[1], 'offered_parts': r[2], 'created_at': str(r[3])} for r in rows]
|
||||
|
||||
|
||||
def find_pending_sessions_by_customer(customer_id, tenant_conn):
|
||||
"""Buscar sesiones pendientes por customer_id."""
|
||||
if not tenant_conn or not customer_id:
|
||||
return []
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, phone, description, offered_parts, created_at
|
||||
FROM wa_learning_sessions
|
||||
WHERE customer_id = %s AND status = 'pending'
|
||||
ORDER BY created_at DESC
|
||||
""", (customer_id,))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [{'id': r[0], 'phone': r[1], 'description': r[2], 'offered_parts': r[3], 'created_at': str(r[4])} for r in rows]
|
||||
|
||||
|
||||
def resolve_session(session_id, resolved_part_id, sale_id, tenant_conn):
|
||||
"""Marcar sesión como resuelta con la pieza comprada."""
|
||||
if not tenant_conn or not session_id:
|
||||
return
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE wa_learning_sessions
|
||||
SET status = 'learned', resolved_part_id = %s, resolution_sale_id = %s, resolved_at = NOW()
|
||||
WHERE id = %s
|
||||
""", (resolved_part_id, sale_id, session_id))
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def get_learning_pairs_for_training(tenant_conn, limit=100):
|
||||
"""Obtener pares (descripción del cliente → pieza real) para entrenamiento."""
|
||||
if not tenant_conn:
|
||||
return []
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT l.description, i.name, i.part_number, i.brand
|
||||
FROM wa_learning_sessions l
|
||||
JOIN inventory i ON i.id = l.resolved_part_id
|
||||
WHERE l.status = 'learned' AND l.resolved_at > NOW() - INTERVAL '90 days'
|
||||
ORDER BY l.resolved_at DESC
|
||||
LIMIT %s
|
||||
""", (limit,))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [{'description': r[0], 'part_name': r[1], 'part_number': r[2], 'brand': r[3]} for r in rows]
|
||||
|
||||
|
||||
def check_learning_resolution(sale_id, customer_id, tenant_conn):
|
||||
"""
|
||||
Hook para llamar después de completar una venta.
|
||||
Verifica si esta venta resuelve una sesión de aprendizaje pendiente.
|
||||
"""
|
||||
if not tenant_conn or not customer_id:
|
||||
return
|
||||
|
||||
sessions = find_pending_sessions_by_customer(customer_id, tenant_conn)
|
||||
if not sessions:
|
||||
return
|
||||
|
||||
# Obtener items de esta venta
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT si.inventory_id, i.name, i.part_number
|
||||
FROM sale_items si
|
||||
JOIN inventory i ON i.id = si.inventory_id
|
||||
WHERE si.sale_id = %s
|
||||
""", (sale_id,))
|
||||
sale_items = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
if not sale_items:
|
||||
return
|
||||
|
||||
# Matching heurístico
|
||||
for sess in sessions:
|
||||
desc_words = set(sess['description'].lower().split())
|
||||
for inv_id, item_name, part_number in sale_items:
|
||||
item_words = set(item_name.lower().split())
|
||||
# Intersección de palabras significativas
|
||||
common = desc_words & item_words - {'de', 'la', 'el', 'para', 'un', 'una', 'con', 'y', 'o', 'en', 'al', 'del', 'los', 'las'}
|
||||
if len(common) >= 2:
|
||||
resolve_session(sess['id'], inv_id, sale_id, tenant_conn)
|
||||
print(f"[WA-LEARN] Resolved session {sess['id']} with sale {sale_id}, item {inv_id}")
|
||||
break
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
1366
pos/services/wa_state_machine.py
Normal file
1366
pos/services/wa_state_machine.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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', ''),
|
||||
|
||||
Reference in New Issue
Block a user