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:
186
pos/services/part_kits.py
Normal file
186
pos/services/part_kits.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
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 ""
|
||||
Reference in New Issue
Block a user