Files
Autoparts-DB/pos/blueprints/whatsapp_bp.py
consultoria-as e95f7cf684 feat: complete session — catalog, marketplace, WhatsApp, peer-to-peer, install scripts
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>
2026-04-18 05:35:53 +00:00

534 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# /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