Files
Autoparts-DB/pos/services/wa_state_machine.py
consultoria-as 718fa06888 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
2026-05-28 00:21:52 +00:00

1367 lines
52 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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', '', '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', '', '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', '', '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', '', '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', '', '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', '', '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', '', '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()