- Add QWEN (qwen3.6) as primary AI backend with short system prompt - Hermes remains as fallback with 45s timeout - Increase QWEN timeout to 35s, max_tokens to 4000 - Add conversation history loading from whatsapp_messages (last 4 msgs) - Persist detected vehicle in whatsapp_sessions table - Add 'limpiar chat' / 'nuevo chat' / 'reset' commands to clear history - Fix CSS conflict: rename whatsapp chat-panel classes to wa-chat-panel - Fix JS ID conflicts with chat.js widget (waChatPanel, waChatMessages, etc.) - Improve no-stock response: conversational with alternatives - Split search_query by | for multi-part lookups - Add DEMO_PROMPTS.md and DEMO_PROMPTS_V2.md
399 lines
13 KiB
Python
399 lines
13 KiB
Python
"""
|
||
WhatsApp Quotation Service — conversational quote builder.
|
||
|
||
Tracks per-phone "open quotations" so a customer can ask about multiple
|
||
parts over several messages and receive a single formatted quotation at
|
||
the end.
|
||
|
||
Flow:
|
||
1. Customer asks about a part → bot shows local inventory match
|
||
2. Customer says "cotizar" / "agregar" → last-shown part added to quote
|
||
3. Repeat for more parts
|
||
4. Customer says "enviar cotización" / "listo" → formatted quote sent
|
||
5. Customer says "limpiar" / "nueva cotización" → quote cleared
|
||
|
||
The quotation is stored in the tenant's existing `quotations` +
|
||
`quotation_items` tables so it also appears in the POS quotation list.
|
||
"""
|
||
|
||
import re
|
||
from datetime import date, timedelta
|
||
|
||
|
||
# ─── Intent detection ────────────────────────────────────────────────
|
||
|
||
# Commands the customer can type (case-insensitive, accent-insensitive)
|
||
# NOTE: "si" is NOT here — it's handled as 'confirm' to avoid ambiguity
|
||
# with "si" after a quotation was sent (which means "confirm order").
|
||
_ADD_PATTERNS = re.compile(
|
||
r'^(cotizar|agregar|agregalo|agrega|añadir|quiero ese|ese mero|'
|
||
r'dame ese|lo quiero|me lo apartas|si.?cotiza)$',
|
||
re.IGNORECASE
|
||
)
|
||
|
||
_SEND_PATTERNS = re.compile(
|
||
r'^(enviar cotizaci[oó]n|listo|enviar|mandar cotizaci[oó]n|ya es todo|'
|
||
r'eso es todo|mandame la cotizaci[oó]n|terminé|termine|ver cotizaci[oó]n|'
|
||
r'mi cotizaci[oó]n|total|cuanto es)$',
|
||
re.IGNORECASE
|
||
)
|
||
|
||
_CLEAR_PATTERNS = re.compile(
|
||
r'^(limpiar|nueva cotizaci[oó]n|borrar cotizaci[oó]n|empezar de nuevo|cancelar cotizaci[oó]n)$',
|
||
re.IGNORECASE
|
||
)
|
||
|
||
# "si", "va", "confirmo" — confirm the quotation (close it as accepted)
|
||
_CONFIRM_PATTERNS = re.compile(
|
||
r'^(si|sí|va|confirmo|confirmar|acepto|de acuerdo|ok|okay|dale)$',
|
||
re.IGNORECASE
|
||
)
|
||
|
||
_QTY_PATTERN = re.compile(
|
||
r'^(cotizar|agregar|dame|quiero)\s+(\d+)$',
|
||
re.IGNORECASE
|
||
)
|
||
|
||
|
||
def detect_quote_intent(text, has_open_quote=False):
|
||
"""Detect if the message is a quotation command.
|
||
|
||
Args:
|
||
text: the user's message
|
||
has_open_quote: True if this phone has an active quotation
|
||
|
||
Returns:
|
||
('add', quantity) — add last part to quote
|
||
('send', None) — send the full quotation
|
||
('clear', None) — clear the quotation
|
||
('confirm', None) — confirm/accept the quotation
|
||
(None, None) — not a quote command, pass to AI
|
||
"""
|
||
if not text:
|
||
return None, None
|
||
|
||
t = text.strip()
|
||
|
||
# Check for quantity: "cotizar 3", "agregar 5"
|
||
qty_match = _QTY_PATTERN.match(t)
|
||
if qty_match:
|
||
return 'add', int(qty_match.group(2))
|
||
|
||
if _ADD_PATTERNS.match(t):
|
||
return 'add', 1
|
||
|
||
if _SEND_PATTERNS.match(t):
|
||
return 'send', None
|
||
|
||
if _CLEAR_PATTERNS.match(t):
|
||
return 'clear', None
|
||
|
||
# "si" / "va" / "confirmo" — only counts as 'confirm' when there's
|
||
# an open quote. Otherwise pass to the AI as normal conversation.
|
||
if has_open_quote and _CONFIRM_PATTERNS.match(t):
|
||
return 'confirm', None
|
||
|
||
return None, None
|
||
|
||
|
||
def confirm_quotation(tenant_conn, phone):
|
||
"""Mark the open quotation as confirmed/accepted."""
|
||
qid = get_open_quotation(tenant_conn, phone)
|
||
if not qid:
|
||
return None
|
||
cur = tenant_conn.cursor()
|
||
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,))
|
||
tenant_conn.commit()
|
||
cur.close()
|
||
clear_last_shown(phone)
|
||
return qid
|
||
|
||
|
||
# ─── Persistent last-shown-part per phone ────────────────────────────
|
||
# Tracks what part the bot last showed so "cotizar" knows what to add.
|
||
# Stored in tenant DB table whatsapp_sessions so it survives restarts.
|
||
|
||
_WHATSAPP_SESSIONS_SQL = """
|
||
CREATE TABLE IF NOT EXISTS whatsapp_sessions (
|
||
phone VARCHAR(50) PRIMARY KEY,
|
||
last_shown JSONB,
|
||
vehicle JSONB,
|
||
updated_at TIMESTAMP DEFAULT NOW()
|
||
);
|
||
"""
|
||
|
||
|
||
def _ensure_sessions_table(tenant_conn):
|
||
cur = tenant_conn.cursor()
|
||
cur.execute(_WHATSAPP_SESSIONS_SQL)
|
||
# Migrate: add vehicle column if table already existed without it
|
||
cur.execute("""
|
||
ALTER TABLE whatsapp_sessions
|
||
ADD COLUMN IF NOT EXISTS vehicle JSONB
|
||
""")
|
||
tenant_conn.commit()
|
||
cur.close()
|
||
|
||
|
||
def set_last_shown_part(phone, part_info):
|
||
"""Store the last part shown to this phone number.
|
||
|
||
part_info: dict with keys inventory_id, part_number, name, brand,
|
||
price, stock, unit
|
||
"""
|
||
# In-memory fallback for when tenant_conn is not available
|
||
from tenant_db import get_tenant_conn
|
||
try:
|
||
conn = get_tenant_conn(11)
|
||
_ensure_sessions_table(conn)
|
||
cur = conn.cursor()
|
||
import json
|
||
cur.execute("""
|
||
INSERT INTO whatsapp_sessions (phone, last_shown, updated_at)
|
||
VALUES (%s, %s, NOW())
|
||
ON CONFLICT (phone) DO UPDATE SET last_shown = EXCLUDED.last_shown, updated_at = NOW()
|
||
""", (phone, json.dumps(part_info)))
|
||
conn.commit()
|
||
cur.close()
|
||
conn.close()
|
||
except Exception as e:
|
||
print(f"[WA-SESSION] Failed to persist last_shown for {phone}: {e}")
|
||
|
||
|
||
def get_last_shown_part(phone):
|
||
from tenant_db import get_tenant_conn
|
||
try:
|
||
conn = get_tenant_conn(11)
|
||
_ensure_sessions_table(conn)
|
||
cur = conn.cursor()
|
||
cur.execute("SELECT last_shown FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||
row = cur.fetchone()
|
||
cur.close()
|
||
conn.close()
|
||
if row and row[0]:
|
||
return row[0]
|
||
except Exception as e:
|
||
print(f"[WA-SESSION] Failed to read last_shown for {phone}: {e}")
|
||
return None
|
||
|
||
|
||
def clear_last_shown(phone):
|
||
from tenant_db import get_tenant_conn
|
||
try:
|
||
conn = get_tenant_conn(11)
|
||
_ensure_sessions_table(conn)
|
||
cur = conn.cursor()
|
||
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||
conn.commit()
|
||
cur.close()
|
||
conn.close()
|
||
except Exception as e:
|
||
print(f"[WA-SESSION] Failed to clear last_shown for {phone}: {e}")
|
||
|
||
|
||
def set_vehicle(phone, vehicle):
|
||
"""Store the detected vehicle for this phone number.
|
||
|
||
vehicle: dict with keys brand, model, year
|
||
"""
|
||
from tenant_db import get_tenant_conn
|
||
try:
|
||
conn = get_tenant_conn(11)
|
||
_ensure_sessions_table(conn)
|
||
cur = conn.cursor()
|
||
import json
|
||
cur.execute("""
|
||
INSERT INTO whatsapp_sessions (phone, vehicle, updated_at)
|
||
VALUES (%s, %s, NOW())
|
||
ON CONFLICT (phone) DO UPDATE SET vehicle = EXCLUDED.vehicle, updated_at = NOW()
|
||
""", (phone, json.dumps(vehicle)))
|
||
conn.commit()
|
||
cur.close()
|
||
conn.close()
|
||
except Exception as e:
|
||
print(f"[WA-SESSION] Failed to persist vehicle for {phone}: {e}")
|
||
|
||
|
||
def get_vehicle(phone):
|
||
"""Retrieve the stored vehicle for this phone number."""
|
||
from tenant_db import get_tenant_conn
|
||
try:
|
||
conn = get_tenant_conn(11)
|
||
_ensure_sessions_table(conn)
|
||
cur = conn.cursor()
|
||
cur.execute("SELECT vehicle FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||
row = cur.fetchone()
|
||
cur.close()
|
||
conn.close()
|
||
if row and row[0]:
|
||
return row[0]
|
||
except Exception as e:
|
||
print(f"[WA-SESSION] Failed to read vehicle for {phone}: {e}")
|
||
return None
|
||
|
||
|
||
def clear_session(phone):
|
||
"""Clear all session data (last_shown + vehicle) for this phone."""
|
||
from tenant_db import get_tenant_conn
|
||
try:
|
||
conn = get_tenant_conn(11)
|
||
_ensure_sessions_table(conn)
|
||
cur = conn.cursor()
|
||
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||
conn.commit()
|
||
cur.close()
|
||
conn.close()
|
||
except Exception as e:
|
||
print(f"[WA-SESSION] Failed to clear session for {phone}: {e}")
|
||
|
||
|
||
# ─── Quotation CRUD ─────────────────────────────────────────────────
|
||
|
||
def get_open_quotation(tenant_conn, phone):
|
||
"""Find an active quotation for this phone, or None."""
|
||
cur = tenant_conn.cursor()
|
||
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()
|
||
cur.close()
|
||
return row[0] if row else None
|
||
|
||
|
||
def create_quotation(tenant_conn, phone):
|
||
"""Create a new quotation tagged with this phone number."""
|
||
cur = tenant_conn.cursor()
|
||
cur.execute("""
|
||
INSERT INTO quotations (subtotal, tax_total, total, status, notes, valid_until)
|
||
VALUES (0, 0, 0, 'active', %s, %s)
|
||
RETURNING id
|
||
""", (f'WA:{phone}', date.today() + timedelta(days=7)))
|
||
qid = cur.fetchone()[0]
|
||
tenant_conn.commit()
|
||
cur.close()
|
||
return qid
|
||
|
||
|
||
def add_item_to_quotation(tenant_conn, quote_id, part_info, quantity=1):
|
||
"""Add a part to an existing quotation and recalculate totals."""
|
||
price = float(part_info.get('price') or 0)
|
||
tax_rate = float(part_info.get('tax_rate') or 0.16)
|
||
subtotal = round(price * quantity, 2)
|
||
|
||
cur = tenant_conn.cursor()
|
||
cur.execute("""
|
||
INSERT INTO quotation_items
|
||
(quotation_id, inventory_id, part_number, name, quantity, unit_price, tax_rate, subtotal)
|
||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||
""", (
|
||
quote_id,
|
||
part_info.get('inventory_id'),
|
||
part_info.get('part_number', ''),
|
||
part_info.get('name', ''),
|
||
quantity,
|
||
price,
|
||
tax_rate,
|
||
subtotal,
|
||
))
|
||
|
||
# Recalculate totals
|
||
cur.execute("""
|
||
SELECT COALESCE(SUM(subtotal), 0),
|
||
COALESCE(SUM(subtotal * tax_rate), 0)
|
||
FROM quotation_items WHERE quotation_id = %s
|
||
""", (quote_id,))
|
||
sub, tax = cur.fetchone()
|
||
cur.execute("""
|
||
UPDATE quotations SET subtotal = %s, tax_total = %s, total = %s
|
||
WHERE id = %s
|
||
""", (sub, tax, round(sub + tax, 2), quote_id))
|
||
|
||
tenant_conn.commit()
|
||
cur.close()
|
||
return subtotal
|
||
|
||
|
||
def get_quotation_detail(tenant_conn, quote_id):
|
||
"""Return full quotation with items."""
|
||
cur = tenant_conn.cursor()
|
||
cur.execute("""
|
||
SELECT id, subtotal, tax_total, total, status, valid_until, created_at
|
||
FROM quotations WHERE id = %s
|
||
""", (quote_id,))
|
||
q = cur.fetchone()
|
||
if not q:
|
||
cur.close()
|
||
return None
|
||
|
||
cur.execute("""
|
||
SELECT part_number, name, quantity, unit_price, tax_rate, subtotal
|
||
FROM quotation_items WHERE quotation_id = %s ORDER BY id
|
||
""", (quote_id,))
|
||
items = cur.fetchall()
|
||
cur.close()
|
||
|
||
return {
|
||
'id': q[0],
|
||
'subtotal': float(q[1]),
|
||
'tax_total': float(q[2]),
|
||
'total': float(q[3]),
|
||
'status': q[4],
|
||
'valid_until': str(q[5]) if q[5] else None,
|
||
'created_at': str(q[6]) if q[6] else None,
|
||
'items': [{
|
||
'part_number': it[0],
|
||
'name': it[1],
|
||
'quantity': it[2],
|
||
'unit_price': float(it[3]),
|
||
'tax_rate': float(it[4]),
|
||
'subtotal': float(it[5]),
|
||
} for it in items],
|
||
}
|
||
|
||
|
||
def clear_quotation(tenant_conn, phone):
|
||
"""Cancel the open quotation for this phone."""
|
||
qid = get_open_quotation(tenant_conn, phone)
|
||
if qid:
|
||
cur = tenant_conn.cursor()
|
||
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (qid,))
|
||
tenant_conn.commit()
|
||
cur.close()
|
||
clear_last_shown(phone)
|
||
return qid
|
||
|
||
|
||
# ─── Format quotation for WhatsApp ──────────────────────────────────
|
||
|
||
def format_quotation_wa(detail):
|
||
"""Format a quotation as a WhatsApp-friendly text message."""
|
||
if not detail or not detail.get('items'):
|
||
return None
|
||
|
||
lines = [
|
||
f'📄 *COTIZACIÓN #{detail["id"]}*',
|
||
f'Fecha: {detail["created_at"][:10] if detail.get("created_at") else "hoy"}',
|
||
f'Vigencia: {detail.get("valid_until") or "7 días"}',
|
||
'',
|
||
'─────────────────────',
|
||
]
|
||
|
||
for i, item in enumerate(detail['items'], 1):
|
||
qty = item['quantity']
|
||
price = item['unit_price']
|
||
sub = item['subtotal']
|
||
lines.append(f'{i}. {item["name"]}')
|
||
lines.append(f' #{item["part_number"]} × {qty} = ${sub:,.2f}')
|
||
|
||
lines.append('─────────────────────')
|
||
lines.append(f' Subtotal: ${detail["subtotal"]:,.2f}')
|
||
lines.append(f' IVA: ${detail["tax_total"]:,.2f}')
|
||
lines.append(f' *TOTAL: ${detail["total"]:,.2f}*')
|
||
lines.append('')
|
||
lines.append('_Responde "si" para confirmar el pedido._')
|
||
lines.append('_Responde "limpiar" para empezar de nuevo._')
|
||
|
||
return '\n'.join(lines)
|