Major features: - Pixel-Perfect glassmorphism design (landing + POS + public catalog) - OEM/Local catalog toggle with Nexpart taxonomy (14 groups, 108 subgroups, 558 part types) - Marketplace B2B Phase 1 (bodegas, POs, status machine, WA+email notifications) - Peer-to-peer inventory (multi-instance, LAN discovery) - WhatsApp: photo→Vision AI, voice→Whisper, conversational quotations - Smart unified search (VIN/plate/part_number/keyword auto-detect) - Shop Supplies tab (vehicle-independent parts) - Chatbot AI fallback chain (5 models) + response cache - CSV inventory import tool + setup_instance.sh installer - Tablet-responsive CSS + sidebar toggle - Filters, export CSV, employee edit, business data save - Quotation system (WA→POS) with auto-print on confirmation - Live stats on landing page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
534 lines
21 KiB
Python
534 lines
21 KiB
Python
# /home/Autopartes/pos/blueprints/whatsapp_bp.py
|
||
"""WhatsApp via Baileys Bridge.
|
||
|
||
Endpoints:
|
||
GET /pos/api/whatsapp/status -- Connection status
|
||
GET /pos/api/whatsapp/qr -- Get QR code
|
||
POST /pos/api/whatsapp/connect -- Start connection
|
||
POST /pos/api/whatsapp/logout -- Disconnect
|
||
POST /pos/api/whatsapp/webhook -- Receive messages (public)
|
||
POST /pos/api/whatsapp/send -- Send message
|
||
GET /pos/api/whatsapp/conversations -- List conversations
|
||
"""
|
||
|
||
from flask import Blueprint, request, jsonify, g
|
||
from middleware import require_auth
|
||
from tenant_db import get_tenant_conn
|
||
from services import whatsapp_service
|
||
|
||
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
||
|
||
|
||
def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn):
|
||
"""Search the refaccionaria's LOCAL inventory and build a WhatsApp reply.
|
||
|
||
Returns:
|
||
(formatted_text, first_part_dict) — first_part_dict is used by the
|
||
quotation system to know what to add when the user says "cotizar".
|
||
first_part_dict has: inventory_id, part_number, name, brand, price, tax_rate
|
||
"""
|
||
if not tenant_conn:
|
||
return None, None
|
||
|
||
try:
|
||
# Translate common English search terms to Spanish for local inventory
|
||
# (the AI sends search_query in English, but local inventory names
|
||
# are often in Spanish)
|
||
from services.translations import PART_TRANSLATIONS
|
||
search_terms = [search_query]
|
||
# Add the Spanish translation if we have one
|
||
for en, es in PART_TRANSLATIONS.items():
|
||
if en.upper() in search_query.upper():
|
||
search_terms.append(es)
|
||
break
|
||
|
||
# Build ILIKE conditions for all search terms
|
||
conditions = []
|
||
params = []
|
||
for term in search_terms:
|
||
conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)")
|
||
like = f'%{term}%'
|
||
params.extend([like, like, like])
|
||
|
||
where_search = ' OR '.join(conditions)
|
||
|
||
cur = tenant_conn.cursor()
|
||
cur.execute(f"""
|
||
SELECT i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
|
||
COALESCE(s.stock, 0) AS stock,
|
||
i.unit, i.location
|
||
FROM inventory i
|
||
LEFT JOIN (
|
||
SELECT inventory_id, SUM(quantity) AS stock
|
||
FROM inventory_operations
|
||
GROUP BY inventory_id
|
||
) s ON s.inventory_id = i.id
|
||
WHERE i.is_active = TRUE
|
||
AND ({where_search})
|
||
ORDER BY
|
||
COALESCE(s.stock, 0) > 0 DESC,
|
||
i.name
|
||
LIMIT 10
|
||
""", params)
|
||
|
||
rows = cur.fetchall()
|
||
cur.close()
|
||
|
||
if not rows:
|
||
return ('❌ No tenemos esa parte en inventario actualmente.\n'
|
||
'_Puedes preguntar por otra parte o visitarnos en tienda._'), None
|
||
|
||
# Split into in-stock and out-of-stock
|
||
in_stock = [r for r in rows if r[6] > 0]
|
||
out_stock = [r for r in rows if r[6] <= 0]
|
||
|
||
# Build the first-part dict for quotation tracking
|
||
# Use the first in-stock part, or first out-of-stock if none available
|
||
best = in_stock[0] if in_stock else (out_stock[0] if out_stock else None)
|
||
first_part = None
|
||
if best:
|
||
first_part = {
|
||
'inventory_id': None, # we'd need the id — fetch it
|
||
'part_number': best[0],
|
||
'name': best[1],
|
||
'brand': best[2] or '',
|
||
'price': float(best[3]) if best[3] else 0,
|
||
'tax_rate': 0.16,
|
||
'stock': best[6],
|
||
'unit': best[7] or 'PZA',
|
||
}
|
||
# Fetch the inventory ID for the quotation item FK
|
||
try:
|
||
cur2 = tenant_conn.cursor()
|
||
cur2.execute("SELECT id FROM inventory WHERE part_number = %s AND is_active = TRUE LIMIT 1",
|
||
(best[0],))
|
||
inv_row = cur2.fetchone()
|
||
if inv_row:
|
||
first_part['inventory_id'] = inv_row[0]
|
||
cur2.close()
|
||
except Exception:
|
||
pass
|
||
|
||
lines = []
|
||
|
||
if in_stock:
|
||
lines.append('✅ *Tenemos en stock:*')
|
||
lines.append('')
|
||
for r in in_stock:
|
||
part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||
brand_str = f'*{brand}*' if brand else ''
|
||
price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio'
|
||
lines.append(f' • {brand_str} {name}')
|
||
lines.append(f' #{part_num} — {price_str} ({stock} {unit or "pzas"} disponibles)')
|
||
lines.append('')
|
||
else:
|
||
lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*')
|
||
lines.append('')
|
||
for r in out_stock[:5]:
|
||
part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||
brand_str = f'*{brand}*' if brand else ''
|
||
price_str = f'${float(p1):,.2f}' if p1 else ''
|
||
lines.append(f' • {brand_str} {name} #{part_num} {price_str}')
|
||
lines.append('')
|
||
lines.append('_Podemos pedirlo — consulta tiempo de entrega._')
|
||
|
||
# Vehicle context
|
||
if vehicle and vehicle.get('brand'):
|
||
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}"
|
||
lines.append(f'🚗 Vehículo: {v_str.strip()}')
|
||
|
||
lines.append('\n📞 _Escribe "cotizar" para agregar a tu cotización._')
|
||
|
||
return '\n'.join(lines), first_part
|
||
|
||
except Exception as e:
|
||
print(f"[WA-AI] Enrichment error: {e}")
|
||
return None, None
|
||
|
||
|
||
@whatsapp_bp.route('/status', methods=['GET'])
|
||
@require_auth()
|
||
def status():
|
||
return jsonify(whatsapp_service.get_status())
|
||
|
||
|
||
@whatsapp_bp.route('/qr', methods=['GET'])
|
||
@require_auth()
|
||
def qr():
|
||
return jsonify(whatsapp_service.get_qr())
|
||
|
||
|
||
@whatsapp_bp.route('/connect', methods=['POST'])
|
||
@require_auth()
|
||
def connect():
|
||
return jsonify(whatsapp_service.connect())
|
||
|
||
|
||
@whatsapp_bp.route('/logout', methods=['POST'])
|
||
@require_auth()
|
||
def logout():
|
||
return jsonify(whatsapp_service.logout())
|
||
|
||
|
||
@whatsapp_bp.route('/webhook', methods=['POST'])
|
||
def webhook():
|
||
"""Receive messages from Baileys bridge (public, no auth).
|
||
|
||
Flow:
|
||
1. Persist the incoming message to the tenant's whatsapp_messages log.
|
||
2. Build inventory context for the AI (what this tenant has in stock).
|
||
3. Ask the chatbot for a reply, enriched with that context.
|
||
4. Send the reply back via the Baileys bridge.
|
||
"""
|
||
data = request.get_json(force=True, silent=True) or {}
|
||
|
||
if data.get('event') != 'messages.upsert':
|
||
return jsonify({'ok': True})
|
||
|
||
msg = whatsapp_service.process_incoming(data)
|
||
if not msg.get('phone') or msg.get('from_me'):
|
||
return jsonify({'ok': True})
|
||
|
||
# Reuse one tenant connection for the whole webhook path — we need it
|
||
# for persistence AND for the inventory-context lookup.
|
||
# TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives.
|
||
tenant_id = 11
|
||
tenant_conn = None
|
||
inventory_context = None
|
||
try:
|
||
tenant_conn = get_tenant_conn(tenant_id)
|
||
|
||
# 1. Log the incoming message (with contact display name)
|
||
cur = tenant_conn.cursor()
|
||
cur.execute("""
|
||
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
|
||
VALUES (%s, 'incoming', %s, %s, %s)
|
||
ON CONFLICT DO NOTHING
|
||
""", (msg['phone'], msg['text'], msg['message_id'], msg.get('push_name') or None))
|
||
tenant_conn.commit()
|
||
cur.close()
|
||
|
||
# 2. Build inventory context once per webhook call so the chatbot
|
||
# can say things like "tengo 5 Bosch BP-123 por $450".
|
||
try:
|
||
from services.ai_chat import get_inventory_context
|
||
inventory_context = get_inventory_context(tenant_conn)
|
||
except Exception as e:
|
||
print(f"[WA-AI] inventory_context failed: {e}")
|
||
inventory_context = None
|
||
except Exception as e:
|
||
print(f"[WA-AI] tenant connection failed: {e}")
|
||
|
||
# 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', '')
|
||
|
||
# ── Check for quotation commands FIRST (before AI) ──
|
||
if 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,
|
||
)
|
||
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)
|
||
if not last_part:
|
||
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
|
||
elif tenant_conn:
|
||
qid = get_open_quotation(tenant_conn, clean_phone)
|
||
if not qid:
|
||
qid = create_quotation(tenant_conn, clean_phone)
|
||
add_item_to_quotation(tenant_conn, qid, last_part, quantity=qty or 1)
|
||
detail = get_quotation_detail(tenant_conn, qid)
|
||
item_count = len(detail['items']) if detail else 0
|
||
reply = (
|
||
f'✅ *{last_part.get("name", "")}* × {qty or 1} agregado a tu cotización.\n'
|
||
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._'
|
||
)
|
||
|
||
elif intent == 'send':
|
||
if tenant_conn:
|
||
qid = get_open_quotation(tenant_conn, clean_phone)
|
||
if qid:
|
||
detail = get_quotation_detail(tenant_conn, qid)
|
||
reply = format_quotation_wa(detail)
|
||
if not reply:
|
||
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
|
||
else:
|
||
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
|
||
|
||
elif intent == 'clear':
|
||
if tenant_conn:
|
||
clear_quotation(tenant_conn, clean_phone)
|
||
reply = '🗑️ Cotización limpiada. Pregunta por partes para empezar una nueva.'
|
||
|
||
elif intent == 'confirm':
|
||
if tenant_conn:
|
||
qid = confirm_quotation(tenant_conn, clean_phone)
|
||
if qid:
|
||
reply = (
|
||
f'✅ *Pedido confirmado!*\n\n'
|
||
f'Tu cotización #{qid} fue registrada.\n'
|
||
f'Nos pondremos en contacto contigo para coordinar la entrega/recolección.\n\n'
|
||
f'¡Gracias por tu compra! 🙏'
|
||
)
|
||
else:
|
||
reply = '⚠️ No tienes una cotización abierta para confirmar.'
|
||
|
||
if intent is not None:
|
||
# It was a quote command — send reply and skip the AI
|
||
if reply:
|
||
result = whatsapp_service.send_message(reply_to, reply)
|
||
if tenant_conn:
|
||
try:
|
||
cur_save = tenant_conn.cursor()
|
||
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
|
||
tenant_conn.commit()
|
||
cur_save.close()
|
||
except Exception:
|
||
pass
|
||
# Clean up and return early
|
||
if tenant_conn:
|
||
try: tenant_conn.close()
|
||
except Exception: pass
|
||
return jsonify({'ok': True})
|
||
|
||
try:
|
||
if media_kind == 'image' and msg.get('media_base64'):
|
||
from services.ai_chat import chat_with_image
|
||
# Prompt: use the caption if provided, else default to
|
||
# "identify this part" which chat_with_image handles gracefully.
|
||
prompt = msg.get('text') or 'Identifica esta parte automotriz y sugiere terminos de busqueda.'
|
||
ai_resp = chat_with_image(
|
||
user_message=prompt,
|
||
image_base64=msg['media_base64'],
|
||
inventory_context=inventory_context,
|
||
)
|
||
reply = ai_resp.get('message', '') or ''
|
||
print(f"[WA-AI] Image from {reply_to}: {reply[:80]}...")
|
||
|
||
elif media_kind == 'audio' and msg.get('media_base64'):
|
||
# Voice note handling — transcribe first, then chat().
|
||
# See services.whisper_local for the transcriber.
|
||
try:
|
||
from services.whisper_local import transcribe_audio_base64
|
||
transcript = transcribe_audio_base64(
|
||
msg['media_base64'],
|
||
mimetype=msg.get('media_mimetype') or 'audio/ogg',
|
||
)
|
||
except ImportError:
|
||
transcript = None
|
||
print("[WA-AI] whisper_local not installed — voice notes skipped")
|
||
except Exception as e:
|
||
transcript = None
|
||
print(f"[WA-AI] Whisper transcription failed: {e}")
|
||
|
||
if transcript:
|
||
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
|
||
from services.ai_chat import chat
|
||
ai_resp = chat(transcript, inventory_context=inventory_context)
|
||
reply = ai_resp.get('message', '') or ''
|
||
# Prefix the reply so the sender knows we understood the voice note
|
||
if reply:
|
||
reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}'
|
||
else:
|
||
reply = ('Recibi tu nota de voz pero no pude transcribirla. '
|
||
'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'], 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')
|
||
if search_q and reply:
|
||
try:
|
||
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_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}")
|
||
|
||
# Send reply if we produced one
|
||
if reply:
|
||
result = whatsapp_service.send_message(reply_to, reply)
|
||
print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}")
|
||
|
||
# Save the bot's reply to DB so it shows in the WhatsApp UI
|
||
if tenant_conn:
|
||
try:
|
||
cur2 = tenant_conn.cursor()
|
||
cur2.execute("""
|
||
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
||
VALUES (%s, 'outgoing', %s)
|
||
""", (msg['phone'], reply))
|
||
tenant_conn.commit()
|
||
cur2.close()
|
||
except Exception as db_err:
|
||
print(f"[WA-AI] Failed to save bot reply to DB: {db_err}")
|
||
|
||
except Exception as e:
|
||
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}")
|
||
|
||
# 4. Clean up the connection
|
||
if tenant_conn is not None:
|
||
try:
|
||
tenant_conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@whatsapp_bp.route('/send', methods=['POST'])
|
||
@require_auth()
|
||
def send():
|
||
data = request.get_json() or {}
|
||
phone = data.get('phone', '')
|
||
message = data.get('message', '')
|
||
if not phone or not message:
|
||
return jsonify({'error': 'phone and message required'}), 400
|
||
|
||
result = whatsapp_service.send_message(phone, message)
|
||
|
||
# Save outgoing message
|
||
try:
|
||
conn = get_tenant_conn(g.tenant_id)
|
||
cur = conn.cursor()
|
||
cur.execute("""
|
||
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
||
VALUES (%s, 'outgoing', %s)
|
||
""", (phone, message))
|
||
conn.commit()
|
||
cur.close()
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
return jsonify(result)
|
||
|
||
|
||
@whatsapp_bp.route('/conversations', methods=['GET'])
|
||
@require_auth()
|
||
def conversations():
|
||
try:
|
||
conn = get_tenant_conn(g.tenant_id)
|
||
cur = conn.cursor()
|
||
# Clean up phone format: strip @lid and @s.whatsapp.net suffixes
|
||
# so all variants of the same number are grouped together.
|
||
cur.execute("""
|
||
WITH cleaned AS (
|
||
SELECT
|
||
REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') AS clean_phone,
|
||
message_text,
|
||
direction,
|
||
created_at,
|
||
push_name
|
||
FROM whatsapp_messages
|
||
)
|
||
SELECT clean_phone,
|
||
(ARRAY_AGG(message_text ORDER BY created_at DESC))[1] AS last_message,
|
||
(ARRAY_AGG(direction ORDER BY created_at DESC))[1] AS last_direction,
|
||
MAX(created_at) AS last_at,
|
||
COUNT(*) AS msg_count,
|
||
(ARRAY_AGG(push_name ORDER BY created_at DESC) FILTER (WHERE push_name IS NOT NULL AND push_name != ''))[1] AS contact_name
|
||
FROM cleaned
|
||
GROUP BY clean_phone
|
||
ORDER BY MAX(created_at) DESC
|
||
LIMIT 50
|
||
""")
|
||
convos = [{
|
||
'phone': r[0],
|
||
'last_message': r[1] or '',
|
||
'last_direction': r[2] or 'incoming',
|
||
'last_at': str(r[3]),
|
||
'count': r[4],
|
||
'contact_name': r[5] or '',
|
||
} for r in cur.fetchall()]
|
||
cur.close()
|
||
conn.close()
|
||
return jsonify({'conversations': convos})
|
||
except Exception as e:
|
||
return jsonify({'conversations': [], 'error': str(e)})
|
||
|
||
|
||
@whatsapp_bp.route('/conversations/<path:phone>', methods=['GET'])
|
||
@require_auth()
|
||
def conversation_messages(phone):
|
||
# Strip @lid or @s.whatsapp.net suffix for DB lookup
|
||
clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '')
|
||
try:
|
||
conn = get_tenant_conn(g.tenant_id)
|
||
cur = conn.cursor()
|
||
# Match all variants of this phone number
|
||
cur.execute("""
|
||
SELECT id, direction, message_text, created_at
|
||
FROM whatsapp_messages
|
||
WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s
|
||
ORDER BY created_at
|
||
LIMIT 100
|
||
""", (clean_phone,))
|
||
msgs = [{
|
||
'id': r[0],
|
||
'direction': r[1],
|
||
'message_text': r[2] or '',
|
||
'created_at': str(r[3]),
|
||
} for r in cur.fetchall()]
|
||
cur.close()
|
||
conn.close()
|
||
return jsonify({'messages': msgs})
|
||
except Exception as e:
|
||
return jsonify({'messages': [], 'error': str(e)})
|
||
|
||
|
||
@whatsapp_bp.route('/conversations/<path:phone>', methods=['DELETE'])
|
||
@require_auth()
|
||
def delete_conversation(phone):
|
||
"""Delete all messages for a phone number."""
|
||
clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '')
|
||
try:
|
||
conn = get_tenant_conn(g.tenant_id)
|
||
cur = conn.cursor()
|
||
cur.execute("""
|
||
DELETE FROM whatsapp_messages
|
||
WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s
|
||
""", (clean_phone,))
|
||
deleted = cur.rowcount
|
||
conn.commit()
|
||
cur.close()
|
||
conn.close()
|
||
return jsonify({'ok': True, 'deleted': deleted})
|
||
except Exception as e:
|
||
return jsonify({'error': str(e)}), 500
|
||
|
||
|
||
@whatsapp_bp.route('/conversations', methods=['DELETE'])
|
||
@require_auth()
|
||
def delete_all_conversations():
|
||
"""Delete ALL whatsapp messages."""
|
||
try:
|
||
conn = get_tenant_conn(g.tenant_id)
|
||
cur = conn.cursor()
|
||
cur.execute("DELETE FROM whatsapp_messages")
|
||
deleted = cur.rowcount
|
||
conn.commit()
|
||
cur.close()
|
||
conn.close()
|
||
return jsonify({'ok': True, 'deleted': deleted})
|
||
except Exception as e:
|
||
return jsonify({'error': str(e)}), 500
|