""" 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()