feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes

- Add MercadoLibre OAuth, listings, orders, webhooks and category search
- New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py
- New marketplace_external.html/js with ML management UI
- Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors
- Inventory: new .btn--meli styles, select/label CSS fixes
- WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog
- DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue
- Add Celery tasks for ML sync and webhook processing
- Sidebar: MercadoLibre navigation link
This commit is contained in:
2026-05-26 04:24:07 +00:00
parent 50c0dbe7d4
commit a236187f3a
66 changed files with 7335 additions and 498 deletions

View File

@@ -15,21 +15,37 @@ from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_tenant_conn, get_master_conn
from services import whatsapp_service
from config import WHATSAPP_BRIDGE_URL, WHATSAPP_BRIDGE_KEY
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
def _get_whatsapp_config(conn):
"""Read WhatsApp bridge configuration from tenant_config.
Returns dict with bridge_url, enabled, etc."""
Falls back to global server config (config.py / env vars) when tenant
has no explicit WhatsApp settings. This allows the shared bridge to work
out of the box for all tenants.
"""
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'")
config = {row[0]: row[1] for row in cur.fetchall()}
cur.close()
bridge_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL or ''
bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or ''
enabled_raw = config.get('whatsapp_enabled', '').lower()
if enabled_raw == 'true':
enabled = True
elif enabled_raw == 'false':
enabled = False
else:
# No explicit tenant setting: auto-enable if a bridge URL is configured
enabled = bool(bridge_url)
return {
'bridge_url': config.get('whatsapp_bridge_url', ''),
'bridge_key': config.get('whatsapp_bridge_key', ''),
'enabled': config.get('whatsapp_enabled', 'false').lower() == 'true',
'bridge_url': bridge_url,
'bridge_key': bridge_key,
'enabled': enabled,
'phone_number': config.get('whatsapp_phone_number', ''),
}
@@ -194,27 +210,9 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=N
fallback_rows = _do_search(use_compat=False)
if not rows and not fallback_rows:
# Truly nothing found — return a conversational message that doesn't kill the chat
v_str = ""
if vehicle and vehicle.get('brand'):
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}".strip()
msg_parts = [
"🔍 Revisé nuestro inventario y no encontré esas partes en este momento."
]
if v_str:
msg_parts.append(f"Para tu {v_str}, puedo:")
else:
msg_parts.append("Te puedo ayudar de estas formas:")
msg_parts.extend([
"",
"• *Pedirlas por encargo* — te doy tiempo y precio estimado",
"• *Buscar alternativas* — equivalentes de otra marca que sí tengamos",
"• *Sugerir refaccionarias cercanas* — si es urgente",
"",
"¿Qué prefieres? O dime si quieres buscar otra parte."
])
return '\n'.join(msg_parts), None
# Nothing found in local inventory — let the AI's original response stand.
# The webhook will append a soft note instead of replacing the message.
return None, None
# Use fallback rows if primary search returned nothing
using_fallback = False
@@ -366,6 +364,11 @@ def webhook():
except Exception:
tenant_id = None
# Prepare phone and reply target early
reply_to = msg.get('jid') or msg['phone']
media_kind = msg.get('media_kind', 'text')
clean_phone = msg.get('phone', '')
tenant_conn = None
master_conn = None
inventory_context = None
@@ -394,11 +397,34 @@ def webhook():
print(f"[WA-AI] inventory_context failed: {e}")
inventory_context = None
# 2c. Urgency detection — if customer signals urgency, add a note
try:
from services.part_kits import is_urgent, urgency_note
if msg.get('text') and is_urgent(msg['text']):
if inventory_context:
inventory_context += urgency_note()
else:
inventory_context = urgency_note().strip()
except Exception as e:
print(f"[WA-AI] urgency detection failed: {e}")
# 2d. Purchase history — append recent confirmed orders for this customer
try:
from services.part_kits import get_purchase_history
history = get_purchase_history(clean_phone, tenant_conn)
if history:
if inventory_context:
inventory_context += "\n\n" + history
else:
inventory_context = history
except Exception as e:
print(f"[WA-AI] Purchase history failed: {e}")
# 2b. Append previously-detected vehicle so the AI keeps context
# even when we don't send full conversation history (Hermes is slow with it)
try:
from services.wa_quotation import get_vehicle
saved_vehicle = get_vehicle(clean_phone)
saved_vehicle = get_vehicle(tenant_conn, clean_phone)
if saved_vehicle and inventory_context:
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
if v_str:
@@ -411,25 +437,68 @@ def webhook():
print(f"[WA-AI] vehicle_context failed: {e}")
except Exception as e:
print(f"[WA-AI] tenant connection failed: {e}")
if tenant_conn:
try:
tenant_conn.rollback()
except Exception:
pass
# 3. Dispatch by media kind + quotation commands
reply = None
reply_to = msg.get('jid') or msg['phone']
media_kind = msg.get('media_kind', 'text')
clean_phone = msg.get('phone', '')
# ── Abandoned quotation follow-up ──
# If customer has an active quote and hasn't interacted in 15+ min,
# send a gentle nudge before processing their current message.
try:
from services.part_kits import should_send_followup
followup = should_send_followup(clean_phone, tenant_conn)
if followup:
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
if tenant_conn:
cur_fu = tenant_conn.cursor()
cur_fu.execute(
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
(clean_phone, followup)
)
tenant_conn.commit()
cur_fu.close()
except Exception as fu_err:
print(f"[WA-AI] Follow-up send failed: {fu_err}")
# ── Location message → nearest branch ──
if media_kind == 'location' and msg.get('latitude') is not None and msg.get('longitude') is not None:
from services.geo_branches import find_nearest_branch
nearest = find_nearest_branch(tenant_conn, msg['latitude'], msg['longitude'])
if nearest:
reply = (
f"📍 *Sucursal más cercana:*\n\n"
f"*{nearest['name']}*\n"
f"📌 {nearest['address']}\n"
f"📞 {nearest['phone']}\n"
f"🚗 Aprox. *{nearest['distance_km']} km* de tu ubicación\n\n"
f"¿Te gustaría recoger tu pedido ahí o prefieres envío a domicilio?"
)
else:
reply = (
"📍 Gracias por tu ubicación.\n\n"
"Actualmente no tenemos sucursales registradas con coordenadas. "
"¿En qué ciudad te encuentras? Te puedo indicar nuestras opciones de envío."
)
# ── Check for quotation commands FIRST (before AI) ──
if media_kind == 'text' and msg.get('text'):
if not reply and media_kind == 'text' and msg.get('text'):
from services.wa_quotation import (
detect_quote_intent, get_open_quotation, create_quotation,
add_item_to_quotation, get_quotation_detail, format_quotation_wa,
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part,
)
from services.quote_image import generate_quote_image
from services.whatsapp_service import send_image
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone))
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open)
if intent == 'add':
last_part = get_last_shown_part(clean_phone)
last_part = get_last_shown_part(tenant_conn, clean_phone)
if not last_part:
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
elif tenant_conn:
@@ -444,6 +513,14 @@ def webhook():
f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n'
f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._'
)
# Smart kit suggestion — cross-sell related parts
try:
from services.part_kits import build_kit_text
kit_text = build_kit_text(last_part.get('name', ''))
if kit_text:
reply += kit_text
except Exception as kit_err:
print(f"[WA-AI] Kit suggestion failed: {kit_err}")
elif intent == 'send':
if tenant_conn:
@@ -453,6 +530,32 @@ def webhook():
reply = format_quotation_wa(detail)
if not reply:
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
else:
# Generate rich visual quote image and send it
try:
quote_items = []
for it in detail.get('items', []):
quote_items.append({
'name': it.get('name', ''),
'sku': it.get('sku', ''),
'qty': it.get('quantity', 1),
'price': float(it.get('unit_price', 0)),
'total': float(it.get('total', 0)),
})
totals = {
'subtotal': float(detail.get('subtotal', 0)),
'tax': float(detail.get('tax', 0)),
'total': float(detail.get('total', 0)),
}
tenant_name = tenant_config.get('business_name', 'Autopartes')
b64_img = generate_quote_image(quote_items, totals, tenant_name=tenant_name)
img_result = send_image(clean_phone, caption="Aquí está tu cotización 👇", base64_image=b64_img, bridge_url=bridge_url)
if img_result.get('success'):
reply = "📎 *Te envié tu cotización en imagen.*\n\n" + reply
else:
print(f"[WA-AI] Image send failed: {img_result}")
except Exception as img_err:
print(f"[WA-AI] Quote image generation failed: {img_err}")
else:
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
@@ -522,7 +625,7 @@ def webhook():
# Load conversation history so the AI remembers context (vehicle, parts, etc.)
conversation_history = []
if tenant_conn:
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=2)
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=4)
if conversation_history:
print(f"[WA-AI] Loaded {len(conversation_history)} history messages for {clean_phone}")
@@ -570,35 +673,59 @@ def webhook():
'Puedes escribirme el mensaje?')
elif msg.get('text'):
# Plain text message — standard chatbot flow
from services.ai_chat import chat
ai_resp = chat(msg['text'], conversation_history=conversation_history, inventory_context=inventory_context)
reply = ai_resp.get('message', '') or ''
# Enrich: if the AI returned a search_query, look up real parts
# from the catalog and append them to the WhatsApp reply.
search_q = ai_resp.get('search_query')
vehicle = ai_resp.get('vehicle')
# Persist detected vehicle so we don't lose context between messages
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'):
txt = msg['text'].strip().lower()
# Quick welcome menu for new customers with no vehicle
is_greeting = txt in ('hola', 'buenos dias', 'buenas tardes', 'buenas noches', 'hey', 'que onda', 'saludos')
if is_greeting:
try:
from services.wa_quotation import set_vehicle
set_vehicle(clean_phone, vehicle)
except Exception as veh_err:
print(f"[WA-AI] Failed to save vehicle: {veh_err}")
from services.wa_quotation import get_vehicle
veh = get_vehicle(tenant_conn, clean_phone)
if not veh:
reply = (
"¡Qué onda! Bienvenido a *Autopartes Estrada*.\n\n"
"Soy Juan, tu vendedor. Para ayudarte rápido, dime:\n\n"
"1⃣ *Marca, modelo y año* de tu vehículo\n"
"2⃣ La *parte* que necesitas\n"
"3⃣ O escribe *menú* para ver opciones\n\n"
'_Ejemplo: "Necesito balatas para Tsuru 2015"_'
)
except Exception:
pass
if search_q and reply:
try:
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn, master_conn)
if enrichment:
reply = reply + '\n\n' + enrichment
# Track the found part so "cotizar" can add it
if found_part:
from services.wa_quotation import set_last_shown_part
set_last_shown_part(clean_phone, found_part)
except Exception as enrich_err:
print(f"[WA-AI] Enrichment failed: {enrich_err}")
if not reply:
# Plain text message — standard chatbot flow
from services.ai_chat import chat
ai_resp = chat(msg['text'], conversation_history=conversation_history, inventory_context=inventory_context)
reply = ai_resp.get('message', '') or ''
# Enrich: if the AI returned a search_query, look up real parts
# from the catalog and append them to the WhatsApp reply.
search_q = ai_resp.get('search_query')
vehicle = ai_resp.get('vehicle')
# Persist detected vehicle so we don't lose context between messages
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'):
try:
from services.wa_quotation import set_vehicle
set_vehicle(tenant_conn, clean_phone, vehicle)
except Exception as veh_err:
print(f"[WA-AI] Failed to save vehicle: {veh_err}")
if search_q and reply:
try:
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn, master_conn)
if enrichment:
reply = reply + '\n\n' + enrichment
elif not found_part and vehicle and vehicle.get('brand'):
# Only say "not in stock" when we have a specific vehicle
# and still found nothing. Otherwise let the AI ask for vehicle info.
reply = reply + '\n\n' + "_No tengo esa pieza exacta en stock para tu modelo ahora, pero puedo pedirla por encargo o buscar alternativas. ¿Te interesa?_"
# Track the found part so "cotizar" can add it
if found_part:
from services.wa_quotation import set_last_shown_part
set_last_shown_part(tenant_conn, clean_phone, found_part)
except Exception as enrich_err:
print(f"[WA-AI] Enrichment failed: {enrich_err}")
# Send reply if we produced one
if reply: