- 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
1367 lines
52 KiB
Python
1367 lines
52 KiB
Python
"""
|
||
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()
|