- 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
187 lines
7.2 KiB
Python
187 lines
7.2 KiB
Python
"""
|
|
Smart part kits — automatic cross-sell recommendations.
|
|
|
|
When a customer adds a part to their quotation, suggest related
|
|
parts that are typically needed together for a complete job.
|
|
"""
|
|
|
|
# Spanish keywords in part name → related parts to suggest (in Spanish)
|
|
# These appear after a successful "cotizar" command.
|
|
KIT_SUGGESTIONS = {
|
|
"balata": ["disco de freno", "líquido de frenos", "balero de rueda"],
|
|
"disco de freno": ["balata", "líquido de frenos"],
|
|
"alternador": ["banda serpentina", "batería", "regulador de alternador"],
|
|
"batería": ["alternador", "cable de bujía"],
|
|
"marcha": ["batería", "solenoide de marcha"],
|
|
"bujía": ["bobina de encendido", "filtro de aire", "filtro de gasolina"],
|
|
"bobina": ["bujía", "cable de bujía"],
|
|
"bomba de agua": ["termostato", "refrigerante", "manguera de radiador"],
|
|
"radiador": ["manguera de radiador", "termostato", "tapón de radiador"],
|
|
"termostato": ["refrigerante", "manguera de radiador"],
|
|
"amortiguador": ["base de amortiguador", "goma de suspensión", "rótula"],
|
|
"rótula": ["terminal de dirección", "brazo de suspensión", "bujes"],
|
|
"terminal": ["rótula", "brazo de suspensión"],
|
|
"filtro de aceite": ["filtro de aire", "filtro de gasolina", "filtro de habitáculo"],
|
|
"filtro de aire": ["filtro de aceite", "filtro de gasolina", "bujía"],
|
|
"filtro de gasolina": ["filtro de aire", "filtro de aceite", "inyector"],
|
|
"clutch": ["collarín", "disco de clutch", "plato de presión"],
|
|
"collarín": ["clutch", "disco de clutch"],
|
|
"banda de distribución": ["bomba de agua", "tensor", "polea loca"],
|
|
"banda serpentina": ["tensor de banda", "polea loca"],
|
|
"foco": ["foco trasero", "cuarto"],
|
|
"faro": ["foco trasero", "cuarto"],
|
|
"aceite": ["filtro de aceite", "filtro de aire"],
|
|
}
|
|
|
|
|
|
def get_kit_suggestions(part_name: str) -> list:
|
|
"""Return related part names for a given part (Spanish)."""
|
|
if not part_name:
|
|
return []
|
|
name_lower = part_name.lower()
|
|
for keyword, related in KIT_SUGGESTIONS.items():
|
|
if keyword in name_lower:
|
|
return related
|
|
return []
|
|
|
|
|
|
def build_kit_text(part_name: str) -> str:
|
|
"""Build a WhatsApp-friendly kit suggestion text.
|
|
|
|
Returns empty string if no kit is found.
|
|
"""
|
|
suggestions = get_kit_suggestions(part_name)
|
|
if not suggestions:
|
|
return ""
|
|
items = "\n".join(f" • {s.title()}" for s in suggestions[:3])
|
|
return (
|
|
"\n\n🔧 *¿Ya que estás en eso, checa si también necesitas:*\n"
|
|
+ items
|
|
+ '\n\n_Escribe la parte que te interese y la agregamos._'
|
|
)
|
|
|
|
|
|
# ── Urgency detection ────────────────────────────────────────────────
|
|
URGENCY_KEYWORDS = [
|
|
"urgente", "urgencia", "emergencia", "ya", "ahora", "hoy",
|
|
"lo necesito", "se me paro", "no arranca", "no jala",
|
|
"rapido", "apúrate", "apurate", "prisa", "de volada",
|
|
"para hoy", "para ahora", "lo mas pronto", "lo más pronto",
|
|
"inmediato", "express", "exprés",
|
|
]
|
|
|
|
|
|
def is_urgent(text: str) -> bool:
|
|
"""Detect if the customer message signals urgency."""
|
|
if not text:
|
|
return False
|
|
t = text.lower()
|
|
return any(kw in t for kw in URGENCY_KEYWORDS)
|
|
|
|
|
|
def urgency_note() -> str:
|
|
return (
|
|
"\n\n⚡ NOTA DE URGENCIA: El cliente necesita la pieza lo antes posible. "
|
|
"Prioriza stock local y ofrece entrega express (2-4 horas) o recolección inmediata en tienda. "
|
|
"Si no hay stock exacto, ofrece alternativa disponible inmediatamente."
|
|
)
|
|
|
|
|
|
# ── Abandoned quotation follow-up ────────────────────────────────────
|
|
FOLLOW_UP_MINUTES = 15
|
|
|
|
|
|
def should_send_followup(phone: str, tenant_conn) -> str:
|
|
"""Check if we should send a follow-up message for an abandoned quotation.
|
|
|
|
Returns the follow-up text if yes, empty string if no.
|
|
"""
|
|
if not tenant_conn or not phone:
|
|
return ""
|
|
try:
|
|
cur = tenant_conn.cursor()
|
|
# 1. Check if there's an active quotation for this phone
|
|
cur.execute("""
|
|
SELECT id FROM quotations
|
|
WHERE notes LIKE %s AND status = 'active'
|
|
ORDER BY created_at DESC LIMIT 1
|
|
""", (f'%WA:{phone}%',))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
cur.close()
|
|
return ""
|
|
|
|
# 2. Check last bot message mentioning "cotización" or "cotizar"
|
|
cur.execute("""
|
|
SELECT created_at, message_text
|
|
FROM whatsapp_messages
|
|
WHERE phone = %s AND direction = 'outgoing'
|
|
AND (message_text ILIKE '%cotización%' OR message_text ILIKE '%cotizar%')
|
|
ORDER BY created_at DESC LIMIT 1
|
|
""", (phone,))
|
|
last_quote_msg = cur.fetchone()
|
|
cur.close()
|
|
|
|
if not last_quote_msg:
|
|
return ""
|
|
|
|
from datetime import datetime, timezone
|
|
last_time = last_quote_msg[0]
|
|
now = datetime.now(timezone.utc)
|
|
if last_time.tzinfo is None:
|
|
last_time = last_time.replace(tzinfo=timezone.utc)
|
|
|
|
minutes_since = (now - last_time).total_seconds() / 60
|
|
if minutes_since >= FOLLOW_UP_MINUTES:
|
|
return (
|
|
"👋 *¿Todo bien?*\n\n"
|
|
"Veo que estabas armando tu cotización. ¿Te falta algo más o quieres que te la envíe ahora?\n\n"
|
|
"_Escribe *enviar cotización* para ver el total, o dime si necesitas otra parte._"
|
|
)
|
|
except Exception as e:
|
|
print(f"[WA-AI] Follow-up check failed: {e}")
|
|
return ""
|
|
|
|
|
|
# ── Customer purchase history awareness ──────────────────────────────
|
|
def get_purchase_history(phone: str, tenant_conn, limit: int = 3) -> str:
|
|
"""Build a short text summary of recent confirmed quotations for this customer.
|
|
|
|
Returns empty string if no history.
|
|
"""
|
|
if not tenant_conn or not phone:
|
|
return ""
|
|
try:
|
|
cur = tenant_conn.cursor()
|
|
cur.execute("""
|
|
SELECT q.id, q.created_at, q.total,
|
|
ARRAY_AGG(qi.name ORDER BY qi.name) AS items
|
|
FROM quotations q
|
|
JOIN quotation_items qi ON qi.quotation_id = q.id
|
|
WHERE q.notes LIKE %s AND q.status = 'converted'
|
|
GROUP BY q.id, q.created_at, q.total
|
|
ORDER BY q.created_at DESC
|
|
LIMIT %s
|
|
""", (f'%WA:{phone}%', limit))
|
|
rows = cur.fetchall()
|
|
cur.close()
|
|
|
|
if not rows:
|
|
return ""
|
|
|
|
from datetime import datetime, timezone
|
|
now = datetime.now(timezone.utc)
|
|
parts = []
|
|
for qid, created, total, items in rows:
|
|
if created.tzinfo is None:
|
|
created = created.replace(tzinfo=timezone.utc)
|
|
months_ago = (now - created).days // 30
|
|
time_str = f"hace {months_ago} meses" if months_ago > 0 else "recientemente"
|
|
item_list = ", ".join(items[:3])
|
|
parts.append(f"- {time_str}: {item_list} (total ${float(total):,.2f})")
|
|
|
|
return "HISTORIAL DE COMPRAS DEL CLIENTE:\n" + "\n".join(parts)
|
|
except Exception as e:
|
|
print(f"[WA-AI] Purchase history failed: {e}")
|
|
return ""
|