feat(whatsapp): QWEN primary AI backend, Hermes fallback, conversation history, vehicle persistence, demo prompts

- 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
This commit is contained in:
2026-05-06 20:27:14 +00:00
parent 371d72887e
commit ff45905b49
33 changed files with 3040 additions and 445 deletions

View File

@@ -356,8 +356,9 @@ def search():
if not q or len(q) < 2:
return jsonify({'data': []})
limit = request.args.get('limit', 50, type=int)
mye_id = request.args.get('mye_id', type=int)
def _do(master, tenant, branch_id):
data = catalog_service.smart_search(master, q, tenant, branch_id, limit)
data = catalog_service.smart_search(master, q, tenant, branch_id, limit, mye_id)
return jsonify({'data': data})
return _with_conns(_do)

View File

@@ -1360,36 +1360,55 @@ def auto_match_item_vehicles(item_id):
part_number, brand, name = row
compat_source = get_compat_source(g.tenant_id)
tecdoc_result = None
qwen_result = None
# TecDoc auto-match
if compat_source in ('tecdoc', 'both'):
master = get_master_conn()
try:
result = auto_match_vehicle_compatibility(master, conn, item_id, part_number,
brand=brand, name=name)
return jsonify(result)
tecdoc_result = auto_match_vehicle_compatibility(master, conn, item_id, part_number,
brand=brand, name=name)
finally:
master.close()
conn.close()
# QWEN AI auto-match
if compat_source == 'qwen':
if compat_source in ('qwen', 'both'):
try:
from services.qwen_fitment import get_vehicle_fitment
from services.inventory_vehicle_compat import save_qwen_fitment
fitment = get_vehicle_fitment(part_number, name, brand)
inserted = save_qwen_fitment(conn, item_id, fitment)
conn.close()
return jsonify({
'matched': inserted > 0,
qwen_myes = [v['mye_id'] for v in fitment.get('vehicles', []) if v.get('mye_id')]
qwen_result = {
'matched': len(qwen_myes) > 0,
'matches': [],
'myes': [v['mye_id'] for v in fitment.get('vehicles', []) if v.get('mye_id')],
'myes': qwen_myes,
'inserted': inserted,
})
'total_qwen': len(qwen_myes),
'confidence': fitment.get('confidence', 0),
'notes': fitment.get('notes', ''),
}
except Exception as e:
conn.close()
return jsonify({'error': str(e)}), 500
qwen_result = {'error': str(e)}
conn.close()
# Return combined or single-source result
if compat_source == 'both':
return jsonify({
'tecdoc': tecdoc_result,
'qwen': qwen_result,
'matched': bool(
(tecdoc_result and tecdoc_result.get('matched'))
or (qwen_result and qwen_result.get('matched'))
),
})
if compat_source == 'tecdoc':
return jsonify(tecdoc_result)
if compat_source == 'qwen':
return jsonify(qwen_result)
return jsonify({'error': 'No compatibility source configured'}), 400

View File

@@ -6,8 +6,9 @@ that validates input, calls the engine, and returns JSON responses.
"""
import json
import jwt
from datetime import datetime, date, timedelta
from flask import Blueprint, request, jsonify, g
from flask import Blueprint, request, jsonify, g, render_template_string
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn
from services.pos_engine import (
@@ -15,6 +16,7 @@ from services.pos_engine import (
get_price_for_customer, get_margin_info
)
from services.audit import log_action
from config import JWT_SECRET
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
@@ -485,6 +487,16 @@ def create_quotation():
currency, exchange_rate
))
# Reserve stock for quotation
from services.quote_reservation import reserve_for_quotation, get_quotation_items_for_reservation
try:
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
reserve_for_quotation(conn, quot_id, reservation_items, employee_id=g.employee_id)
except Exception as res_err:
# Log but don't fail the quote creation
import logging
logging.getLogger('pos').warning(f'Quote reservation failed for #{quot_id}: {res_err}')
log_action(conn, 'QUOTATION_CREATE', 'quotation', quot_id,
new_value={'total': totals['total'], 'items_count': len(items)})
@@ -766,6 +778,270 @@ def get_quotation(quot_id):
return jsonify(quot)
@pos_bp.route('/quotations/<int:quot_id>', methods=['PUT'])
@require_auth('pos.sell')
def update_quotation(quot_id):
"""Replace all items in an existing active quotation.
Body: { items: [...], customer_id, notes, valid_days, currency, exchange_rate }
"""
data = request.get_json() or {}
items = data.get('items', [])
if not items:
return jsonify({'error': 'No items provided'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id, status FROM quotations WHERE id = %s", (quot_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
if row[1] != 'active':
cur.close(); conn.close()
return jsonify({'error': f'Quotation is {row[1]}, cannot edit'}), 400
try:
enriched = _enrich_items(cur, items, data.get('customer_id'))
except ValueError as e:
cur.close(); conn.close()
return jsonify({'error': str(e)}), 400
totals = calculate_totals(enriched)
valid_days = int(data.get('valid_days', 7))
valid_until = (date.today() + timedelta(days=valid_days)).isoformat()
from services.currency import get_exchange_rate
currency = data.get('currency', 'MXN')
if currency not in ('MXN', 'USD'):
cur.close(); conn.close()
return jsonify({'error': f'Unsupported currency: {currency}'}), 400
exchange_rate = data.get('exchange_rate')
if currency != 'MXN' and exchange_rate is None:
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
exchange_rate = float(exchange_rate) if exchange_rate else 1.0
try:
# Release old reservations before deleting items
from services.quote_reservation import (
release_quotation_reservation,
reserve_for_quotation,
get_quotation_items_for_reservation
)
old_items = get_quotation_items_for_reservation(conn, quot_id)
if old_items:
release_quotation_reservation(conn, quot_id, old_items, employee_id=g.employee_id)
# Delete old items
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
# Update header
cur.execute("""
UPDATE quotations
SET customer_id = %s, subtotal = %s, tax_total = %s, total = %s,
valid_until = %s, notes = %s, currency = %s, exchange_rate = %s,
employee_id = %s
WHERE id = %s
""", (
data.get('customer_id'), totals['subtotal'], totals['tax_total'],
totals['total'], valid_until, data.get('notes'),
currency, exchange_rate, g.employee_id, quot_id
))
# Insert new items
for item in totals['items']:
line_subtotal = round(
item['unit_price'] * item['quantity'] * (1 - item['discount_pct'] / 100), 2
)
cur.execute("""
INSERT INTO quotation_items
(quotation_id, inventory_id, part_number, name, quantity,
unit_price, discount_pct, tax_rate, subtotal, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
quot_id, item['inventory_id'], item.get('part_number', ''),
item.get('name', ''), item['quantity'], item['unit_price'],
item['discount_pct'], item['tax_rate'], line_subtotal,
currency, exchange_rate
))
# Reserve stock for new items
new_items = get_quotation_items_for_reservation(conn, quot_id)
if new_items:
reserve_for_quotation(conn, quot_id, new_items, employee_id=g.employee_id)
log_action(conn, 'QUOTATION_UPDATE', 'quotation', quot_id,
new_value={'total': totals['total'], 'items_count': len(items)})
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Quotation updated', 'id': quot_id, 'total': totals['total']})
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/quotations/<int:quot_id>', methods=['PATCH'])
@require_auth('pos.sell')
def patch_quotation(quot_id):
"""Update quotation header fields without touching items."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id, status FROM quotations WHERE id = %s", (quot_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
fields = []
params = []
if 'customer_id' in data:
fields.append('customer_id = %s')
params.append(data['customer_id'])
if 'notes' in data:
fields.append('notes = %s')
params.append(data['notes'])
if 'valid_until' in data:
fields.append('valid_until = %s')
params.append(data['valid_until'])
if 'status' in data and data['status'] in ('active', 'cancelled', 'expired'):
fields.append('status = %s')
params.append(data['status'])
if not fields:
cur.close(); conn.close()
return jsonify({'message': 'No changes'}), 200
params.append(quot_id)
cur.execute(f"UPDATE quotations SET {', '.join(fields)} WHERE id = %s", params)
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Quotation updated'})
@pos_bp.route('/quotations/<int:quot_id>/share', methods=['POST'])
@require_auth('pos.sell')
def share_quotation(quot_id):
"""Generate a public JWT token for viewing this quotation."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id, valid_until, status FROM quotations WHERE id = %s", (quot_id,))
row = cur.fetchone()
cur.close(); conn.close()
if not row:
return jsonify({'error': 'Quotation not found'}), 404
if row[2] != 'active':
return jsonify({'error': 'Only active quotations can be shared'}), 400
valid_until = row[1] or (date.today() + timedelta(days=7))
if isinstance(valid_until, str):
valid_until = datetime.strptime(valid_until, '%Y-%m-%d').date()
payload = {
'type': 'public_quote',
'quot_id': quot_id,
'tenant_id': g.tenant_id,
'exp': datetime.combine(valid_until, datetime.max.time()),
}
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
public_url = request.host_url.rstrip('/') + f'/public/quote/{token}'
return jsonify({'token': token, 'url': public_url})
@pos_bp.route('/public/quote/<token>', methods=['GET'])
def public_quote(token):
"""Unauthenticated public view of a quotation."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
if payload.get('type') != 'public_quote':
return jsonify({'error': 'Invalid token type'}), 400
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Quote expired'}), 410
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 400
# Resolve tenant db
from tenant_db import get_tenant_conn
conn = get_tenant_conn(payload['tenant_id'])
cur = conn.cursor()
cur.execute("""
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
q.created_at, q.notes, q.customer_id, q.currency, q.exchange_rate,
c.name as customer_name, c.phone as customer_phone, c.email as customer_email,
e.name as employee_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
LEFT JOIN employees e ON q.employee_id = e.id
WHERE q.id = %s
""", (payload['quot_id'],))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
cols = ['id', 'subtotal', 'tax_total', 'total', 'valid_until', 'created_at',
'notes', 'customer_id', 'currency', 'exchange_rate', 'customer_name',
'customer_phone', 'customer_email', 'employee_name']
quot = dict(zip(cols, row))
for k in ('subtotal', 'tax_total', 'total', 'exchange_rate'):
if quot.get(k) is not None:
quot[k] = float(quot[k])
cur.execute("""
SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
FROM quotation_items WHERE quotation_id = %s ORDER BY id
""", (payload['quot_id'],))
items = []
for r in cur.fetchall():
items.append({
'part_number': r[0], 'name': r[1], 'quantity': r[2],
'unit_price': float(r[3]) if r[3] else 0,
'discount_pct': float(r[4]) if r[4] else 0,
'tax_rate': float(r[5]) if r[5] else 0,
'subtotal': float(r[6]) if r[6] else 0,
})
cur.close(); conn.close()
html = render_template_string(PUBLIC_QUOTE_TEMPLATE,
quot=quot, items=items, host=request.host_url.rstrip('/'),
token=token)
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
@pos_bp.route('/public/quote/<token>/accept', methods=['POST'])
def public_quote_accept(token):
"""Customer accepts a public quote."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
if payload.get('type') != 'public_quote':
return jsonify({'error': 'Invalid token type'}), 400
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Quote expired'}), 410
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 400
conn = get_tenant_conn(payload['tenant_id'])
cur = conn.cursor()
cur.execute("SELECT status FROM quotations WHERE id = %s", (payload['quot_id'],))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
if row[0] != 'active':
cur.close(); conn.close()
return jsonify({'error': 'Quotation is no longer active'}), 400
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s",
(payload['quot_id'],))
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Cotizacion aceptada. Un asesor se pondra en contacto contigo.'})
@pos_bp.route('/quotations/<int:quot_id>/pdf', methods=['GET'])
@require_auth('pos.view')
def get_quotation_pdf(quot_id):
@@ -1004,6 +1280,19 @@ def convert_quotation(quot_id):
WHERE id = %s
""", (sale['id'], quot_id))
# Convert reservation to actual sale
from services.quote_reservation import (
convert_quotation_reservation,
get_quotation_items_for_reservation
)
try:
res_items = get_quotation_items_for_reservation(conn, quot_id)
if res_items:
convert_quotation_reservation(conn, quot_id, res_items, sale_id=sale['id'], employee_id=g.employee_id)
except Exception as res_err:
import logging
logging.getLogger('pos').warning(f'Quote conversion reservation failed for #{quot_id}: {res_err}')
conn.commit()
cur.close(); conn.close()
return jsonify(sale), 201
@@ -1034,11 +1323,76 @@ def cancel_quotation(quot_id):
return jsonify({'error': f'Quotation is already {quot[1]}'}), 400
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (quot_id,))
# Release reserved stock
from services.quote_reservation import (
release_quotation_reservation,
get_quotation_items_for_reservation
)
try:
res_items = get_quotation_items_for_reservation(conn, quot_id)
if res_items:
release_quotation_reservation(conn, quot_id, res_items, employee_id=g.employee_id)
except Exception as res_err:
import logging
logging.getLogger('pos').warning(f'Quote release on cancel failed for #{quot_id}: {res_err}')
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Quotation cancelled'})
@pos_bp.route('/internal/check-expired-quotations', methods=['POST'])
def check_expired_quotations():
"""Cron endpoint: mark active quotations as expired when valid_until < today.
Can be called internally by systemd timer or Celery beat.
Requires a secret header INTERNAL_API_KEY for safety.
Body (optional): { tenant_id: int } — if omitted, uses g.tenant_id.
"""
from config import INTERNAL_API_KEY
if INTERNAL_API_KEY and request.headers.get('X-Internal-Key') != INTERNAL_API_KEY:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json(silent=True) or {}
tenant_id = data.get('tenant_id') or getattr(g, 'tenant_id', None)
if not tenant_id:
return jsonify({'error': 'tenant_id required'}), 400
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
cur.execute("""
UPDATE quotations
SET status = 'expired'
WHERE status = 'active'
AND valid_until < CURRENT_DATE
RETURNING id
""")
expired_ids = [r[0] for r in cur.fetchall()]
# Release reservations for expired quotes
from services.quote_reservation import (
release_quotation_reservation,
get_quotation_items_for_reservation
)
for qid in expired_ids:
try:
res_items = get_quotation_items_for_reservation(conn, qid)
if res_items:
release_quotation_reservation(conn, qid, res_items)
except Exception as res_err:
import logging
logging.getLogger('pos').warning(f'Quote release on expiry failed for #{qid}: {res_err}')
conn.commit()
cur.close(); conn.close()
return jsonify({
'expired': len(expired_ids),
'ids': expired_ids,
'tenant_id': tenant_id,
})
# ─── Layaways (Apartados) ────────────────────────
@pos_bp.route('/layaways', methods=['POST'])
@@ -1967,3 +2321,109 @@ def print_ticket(sale_id):
raw = generate_ticket(sale_data, business_info, width=width)
return Response(raw, mimetype='application/octet-stream',
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
# ─── Public Quote HTML Template ─────────────────────────────────────────────
PUBLIC_QUOTE_TEMPLATE = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cotizacion #{{ quot.id }}</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#111;padding:16px;line-height:1.5}
.card{max-width:640px;margin:0 auto;background:#fff;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,0.08);overflow:hidden}
.header{background:linear-gradient(135deg,#1f2937,#374151);color:#fff;padding:28px 24px;text-align:center}
.header h1{font-size:22px;font-weight:700;margin-bottom:6px}
.header p{font-size:13px;opacity:.85}
.body{padding:24px}
.meta{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;font-size:13px;color:#4b5563}
.meta div{background:#f9fafb;padding:10px 12px;border-radius:8px}
.meta strong{color:#111;display:block;font-size:12px;text-transform:uppercase;letter-spacing:.4px;margin-bottom:2px}
table{width:100%;border-collapse:collapse;font-size:14px;margin-bottom:16px}
th{text-align:left;padding:10px 8px;background:#f3f4f6;color:#374151;font-size:11px;text-transform:uppercase;letter-spacing:.4px}
td{padding:12px 8px;border-bottom:1px solid #e5e7eb;vertical-align:top}
tr:last-child td{border-bottom:none}
.part{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#6b7280}
.qty{text-align:center}
.price{text-align:right;font-weight:600}
.totals{border-top:2px solid #e5e7eb;padding-top:16px;text-align:right;font-size:14px}
.totals div{margin-bottom:4px;color:#4b5563}
.totals .big{font-size:22px;font-weight:800;color:#111;margin-top:8px}
.actions{padding:0 24px 24px;text-align:center}
.btn{display:inline-block;width:100%;padding:14px 20px;border-radius:10px;border:none;font-size:16px;font-weight:700;cursor:pointer;transition:transform .1s}
.btn-primary{background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff}
.btn-primary:hover{transform:translateY(-1px)}
.btn-primary:active{transform:translateY(0)}
.btn-disabled{background:#e5e7eb;color:#9ca3af;cursor:not-allowed}
.footer{text-align:center;padding:16px;font-size:12px;color:#9ca3af}
.badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:11px;font-weight:700;text-transform:uppercase}
.badge-active{background:#d1fae5;color:#065f46}
.badge-expired{background:#fee2e2;color:#991b1b}
@media(min-width:480px){.meta{grid-template-columns:repeat(3,1fr)}.btn{width:auto;min-width:280px}}
</style>
</head>
<body>
<div class="card">
<div class="header">
<h1>Cotizacion #{{ quot.id }}</h1>
<p>{{ host }}</p>
</div>
<div class="body">
<div class="meta">
<div><strong>Cliente</strong>{{ quot.customer_name or 'Publico general' }}</div>
<div><strong>Fecha</strong>{{ quot.created_at[:10] if quot.created_at else '' }}</div>
<div><strong>Vigencia</strong>{{ quot.valid_until or '' }} <span class="badge badge-{{ 'active' if quot.status == 'active' else 'expired' }}">{{ quot.status }}</span></div>
</div>
<table>
<thead><tr><th>Descripcion</th><th class="qty">Cant</th><th class="price">P. Unit</th><th class="price">Subtotal</th></tr></thead>
<tbody>
{% for it in items %}
<tr>
<td>
<div style="font-weight:600">{{ it.name }}</div>
<div class="part">{{ it.part_number }}</div>
</td>
<td class="qty">{{ it.quantity }}</td>
<td class="price">${{ "{:,.2f}".format(it.unit_price) }}</td>
<td class="price">${{ "{:,.2f}".format(it.subtotal) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="totals">
<div>Subtotal: ${{ "{:,.2f}".format(quot.subtotal) }}</div>
<div>IVA: ${{ "{:,.2f}".format(quot.tax_total) }}</div>
<div class="big">Total: ${{ "{:,.2f}".format(quot.total) }}</div>
</div>
</div>
<div class="actions">
{% if quot.status == 'active' %}
<button class="btn btn-primary" id="acceptBtn" onclick="acceptQuote()">Aceptar cotizacion</button>
{% else %}
<button class="btn btn-disabled" disabled>Cotizacion no disponible</button>
{% endif %}
</div>
<div class="footer">
Precios sujetos a cambio sin previo aviso. Vigencia limitada.
</div>
</div>
<script>
function acceptQuote(){
var btn=document.getElementById('acceptBtn');
btn.disabled=true;btn.textContent='Procesando...';
fetch('/public/quote/{{ token }}/accept',{method:'POST'})
.then(function(r){return r.json();})
.then(function(d){
if(d.error){alert('Error: '+d.error);btn.disabled=false;btn.textContent='Aceptar cotizacion';}
else{btn.textContent='Cotizacion aceptada';btn.className='btn btn-disabled';alert(d.message);}
})
.catch(function(){alert('Error de red');btn.disabled=false;btn.textContent='Aceptar cotizacion';});
}
</script>
</body>
</html>
"""

106
pos/blueprints/public_bp.py Normal file
View File

@@ -0,0 +1,106 @@
"""Public blueprint — unauthenticated routes for shared content.
These routes live outside the /pos/api prefix so they can be accessed
by customers without login.
"""
import jwt
from flask import Blueprint, request, jsonify, render_template_string
from tenant_db import get_tenant_conn
from config import JWT_SECRET
from blueprints.pos_bp import PUBLIC_QUOTE_TEMPLATE
public_bp = Blueprint('public', __name__)
@public_bp.route('/public/quote/<token>', methods=['GET'])
def public_quote(token):
"""Unauthenticated public view of a quotation."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
if payload.get('type') != 'public_quote':
return jsonify({'error': 'Invalid token type'}), 400
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Quote expired'}), 410
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 400
conn = get_tenant_conn(payload['tenant_id'])
cur = conn.cursor()
cur.execute("""
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
q.created_at, q.notes, q.customer_id, q.currency, q.exchange_rate,
q.status,
c.name as customer_name, c.phone as customer_phone, c.email as customer_email,
e.name as employee_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
LEFT JOIN employees e ON q.employee_id = e.id
WHERE q.id = %s
""", (payload['quot_id'],))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
cols = ['id', 'subtotal', 'tax_total', 'total', 'valid_until', 'created_at',
'notes', 'customer_id', 'currency', 'exchange_rate', 'status',
'customer_name', 'customer_phone', 'customer_email', 'employee_name']
quot = dict(zip(cols, row))
for k in ('subtotal', 'tax_total', 'total', 'exchange_rate'):
if quot.get(k) is not None:
quot[k] = float(quot[k])
if quot.get('created_at'):
quot['created_at'] = str(quot['created_at'])
if quot.get('valid_until'):
quot['valid_until'] = str(quot['valid_until'])
cur.execute("""
SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
FROM quotation_items WHERE quotation_id = %s ORDER BY id
""", (payload['quot_id'],))
items = []
for r in cur.fetchall():
items.append({
'part_number': r[0], 'name': r[1], 'quantity': r[2],
'unit_price': float(r[3]) if r[3] else 0,
'discount_pct': float(r[4]) if r[4] else 0,
'tax_rate': float(r[5]) if r[5] else 0,
'subtotal': float(r[6]) if r[6] else 0,
})
cur.close(); conn.close()
html = render_template_string(PUBLIC_QUOTE_TEMPLATE,
quot=quot, items=items, host=request.host_url.rstrip('/'),
token=token)
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
@public_bp.route('/public/quote/<token>/accept', methods=['POST'])
def public_quote_accept(token):
"""Customer accepts a public quote."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
if payload.get('type') != 'public_quote':
return jsonify({'error': 'Invalid token type'}), 400
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Quote expired'}), 410
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 400
conn = get_tenant_conn(payload['tenant_id'])
cur = conn.cursor()
cur.execute("SELECT status FROM quotations WHERE id = %s", (payload['quot_id'],))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
if row[0] != 'active':
cur.close(); conn.close()
return jsonify({'error': 'Quotation is no longer active'}), 400
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s",
(payload['quot_id'],))
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Cotizacion aceptada. Un asesor se pondra en contacto contigo.'})

View File

@@ -13,15 +13,92 @@ Endpoints:
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_tenant_conn
from tenant_db import get_tenant_conn, get_master_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):
def _resolve_mye_ids(vehicle, master_conn):
"""Return list of MYE ids matching vehicle brand/model/year text."""
if not master_conn or not vehicle:
return []
brand = vehicle.get('brand', '').strip()
model = vehicle.get('model', '').strip()
year = str(vehicle.get('year', '')).strip()
if not brand and not model:
return []
cur = master_conn.cursor()
clauses = []
params = []
if brand:
clauses.append("b.name_brand ILIKE %s")
params.append(f'%{brand}%')
if model:
clauses.append("m.name_model ILIKE %s")
params.append(f'%{model}%')
if year and year.isdigit():
clauses.append("y.year_car = %s")
params.append(int(year))
if not clauses:
cur.close()
return []
cur.execute(f"""
SELECT mye.id_mye
FROM model_year_engine mye
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
JOIN years y ON y.id_year = mye.year_id
WHERE {' AND '.join(clauses)}
LIMIT 50
""", tuple(params))
rows = cur.fetchall()
cur.close()
return [r[0] for r in rows]
def _get_conversation_history(phone, tenant_conn, limit=4):
"""Fetch recent messages for *phone* to give the AI conversation context.
Includes both user and assistant messages, truncated to keep token count low.
The most recent message (the one currently being processed) is excluded.
"""
if not tenant_conn or not phone:
return []
try:
cur = tenant_conn.cursor()
cur.execute("""
SELECT direction, message_text
FROM whatsapp_messages
WHERE phone = %s
ORDER BY created_at DESC
LIMIT %s OFFSET 1
""", (phone, limit))
rows = cur.fetchall()
cur.close()
# Reverse so oldest-first (chronological) for the LLM
history = []
for direction, text in reversed(rows):
if not text:
continue
role = "assistant" if direction == "outgoing" else "user"
# Truncate assistant replies more aggressively (they contain JSON/tables)
max_len = 200 if role == "assistant" else 300
truncated = text[:max_len] + ('...' if len(text) > max_len else '')
history.append({"role": role, "content": truncated})
return history
except Exception as e:
print(f"[WA-AI] Failed to load conversation history: {e}")
return []
def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=None):
"""Search the refaccionaria's LOCAL inventory and build a WhatsApp reply.
If *vehicle* is provided and we have a master_conn, we first look up the
MYE ids for that vehicle and JOIN through inventory_vehicle_compat so we
only show parts that are known to fit the user's car.
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".
@@ -31,101 +108,143 @@ def _enrich_wa_reply_with_part(search_query, vehicle, 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])
# Split search_query by '|' into individual terms
raw_terms = [t.strip() for t in (search_query or '').split('|') if t.strip()]
if not raw_terms:
raw_terms = [search_query] if search_query else []
where_search = ' OR '.join(conditions)
# Translate each term to Spanish if possible
search_terms = set()
for term in raw_terms:
search_terms.add(term)
# Check if any English translation matches
for en, es in PART_TRANSLATIONS.items():
if en.upper() == term.upper():
search_terms.add(es)
break
# Also check if the term contains an English word
if en.upper() in term.upper():
search_terms.add(term.upper().replace(en.upper(), es))
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)
search_terms = list(search_terms)
if not search_terms:
return None, None
rows = cur.fetchall()
cur.close()
# Vehicle-aware filtering
mye_ids = _resolve_mye_ids(vehicle, master_conn)
if not rows:
return ('❌ No tenemos esa parte en inventario actualmente.\n'
'_Puedes preguntar por otra parte o visitarnos en tienda._'), None
def _do_search(use_compat=True):
"""Run inventory search. Returns list of rows."""
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])
# 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]
where_search = ' OR '.join(conditions)
compat_clause = ""
if use_compat and mye_ids:
compat_clause = f"AND i.id IN (SELECT inventory_id FROM inventory_vehicle_compat WHERE model_year_engine_id IN ({','.join(['%s']*len(mye_ids))}))"
params.extend(mye_ids)
cur = tenant_conn.cursor()
cur.execute(f"""
SELECT i.id, 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 inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = TRUE
AND ({where_search})
{compat_clause}
ORDER BY
COALESCE(s.stock, 0) > 0 DESC,
i.name
LIMIT 10
""", params)
rows = cur.fetchall()
cur.close()
return rows
# 1. Try with vehicle compatibility filter
rows = _do_search(use_compat=True)
compat_filter_applied = bool(mye_ids)
# 2. If no results with compatibility, try WITHOUT filter
fallback_rows = []
if not rows and mye_ids:
fallback_rows = _do_search(use_compat=False)
if not rows and not fallback_rows:
# Truly nothing found — return a conversational message that doesn't kill the chat
v_str = ""
if vehicle and vehicle.get('brand'):
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}".strip()
msg_parts = [
"🔍 Revisé nuestro inventario y no encontré esas partes en este momento."
]
if v_str:
msg_parts.append(f"Para tu {v_str}, puedo:")
else:
msg_parts.append("Te puedo ayudar de estas formas:")
msg_parts.extend([
"",
"• *Pedirlas por encargo* — te doy tiempo y precio estimado",
"• *Buscar alternativas* — equivalentes de otra marca que sí tengamos",
"• *Sugerir refaccionarias cercanas* — si es urgente",
"",
"¿Qué prefieres? O dime si quieres buscar otra parte."
])
return '\n'.join(msg_parts), None
# Use fallback rows if primary search returned nothing
using_fallback = False
if not rows and fallback_rows:
rows = fallback_rows
using_fallback = True
in_stock = [r for r in rows if r[7] > 0]
out_stock = [r for r in rows if r[7] <= 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,
'inventory_id': best[0],
'part_number': best[1],
'name': best[2],
'brand': best[3] or '',
'price': float(best[4]) if best[4] else 0,
'tax_rate': 0.16,
'stock': best[6],
'unit': best[7] or 'PZA',
'stock': best[7],
'unit': best[8] 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 using_fallback:
lines.append("⚠️ *No encontré partes verificadas para tu vehículo, pero sí tengo estas opciones generales:*")
lines.append("")
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
inv_id, 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:
elif out_stock:
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
inv_id, 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}')
@@ -143,6 +262,9 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn):
except Exception as e:
print(f"[WA-AI] Enrichment error: {e}")
import traceback
traceback.print_exc()
return None, None
return None, None
@@ -194,9 +316,11 @@ def webhook():
# TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives.
tenant_id = 11
tenant_conn = None
master_conn = None
inventory_context = None
try:
tenant_conn = get_tenant_conn(tenant_id)
master_conn = get_master_conn()
# 1. Log the incoming message (with contact display name)
cur = tenant_conn.cursor()
@@ -216,6 +340,22 @@ def webhook():
except Exception as e:
print(f"[WA-AI] inventory_context failed: {e}")
inventory_context = None
# 2b. Append previously-detected vehicle so the AI keeps context
# even when we don't send full conversation history (Hermes is slow with it)
try:
from services.wa_quotation import get_vehicle
saved_vehicle = get_vehicle(clean_phone)
if saved_vehicle and inventory_context:
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
if v_str:
inventory_context += f"\n\nVEHICULO DEL CLIENTE: {v_str}"
elif saved_vehicle:
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
if v_str:
inventory_context = f"VEHICULO DEL CLIENTE: {v_str}"
except Exception as e:
print(f"[WA-AI] vehicle_context failed: {e}")
except Exception as e:
print(f"[WA-AI] tenant connection failed: {e}")
@@ -281,6 +421,33 @@ def webhook():
else:
reply = '⚠️ No tienes una cotización abierta para confirmar.'
# ── Check for conversation reset commands ──
if media_kind == 'text' and msg.get('text'):
txt_lower = msg['text'].lower().strip()
if txt_lower in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar'):
if tenant_conn:
try:
cur_del = tenant_conn.cursor()
cur_del.execute("DELETE FROM whatsapp_messages WHERE phone = %s", (clean_phone,))
tenant_conn.commit()
cur_del.close()
except Exception as del_err:
print(f"[WA-AI] Failed to clear conversation history: {del_err}")
reply = '🗑️ *Conversación reiniciada.*\n\n¡Hola de nuevo! ¿En qué puedo ayudarte?'
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
if tenant_conn:
try: tenant_conn.close()
except Exception: pass
return jsonify({'ok': True})
if intent is not None:
# It was a quote command — send reply and skip the AI
if reply:
@@ -299,6 +466,13 @@ def webhook():
except Exception: pass
return jsonify({'ok': True})
# Load conversation history so the AI remembers context (vehicle, parts, etc.)
conversation_history = []
if tenant_conn:
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=2)
if conversation_history:
print(f"[WA-AI] Loaded {len(conversation_history)} history messages for {clean_phone}")
try:
if media_kind == 'image' and msg.get('media_base64'):
from services.ai_chat import chat_with_image
@@ -308,6 +482,7 @@ def webhook():
ai_resp = chat_with_image(
user_message=prompt,
image_base64=msg['media_base64'],
conversation_history=conversation_history,
inventory_context=inventory_context,
)
reply = ai_resp.get('message', '') or ''
@@ -332,7 +507,7 @@ def webhook():
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)
ai_resp = chat(transcript, conversation_history=conversation_history, inventory_context=inventory_context)
reply = ai_resp.get('message', '') or ''
# Prefix the reply so the sender knows we understood the voice note
if reply:
@@ -344,16 +519,25 @@ def webhook():
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)
ai_resp = chat(msg['text'], conversation_history=conversation_history, 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')
# Persist detected vehicle so we don't lose context between messages
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'):
try:
from services.wa_quotation import set_vehicle
set_vehicle(clean_phone, vehicle)
except Exception as veh_err:
print(f"[WA-AI] Failed to save vehicle: {veh_err}")
if search_q and reply:
try:
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn)
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn, master_conn)
if enrichment:
reply = reply + '\n\n' + enrichment
# Track the found part so "cotizar" can add it
@@ -384,12 +568,17 @@ def webhook():
except Exception as e:
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}")
# 4. Clean up the connection
# 4. Clean up connections
if tenant_conn is not None:
try:
tenant_conn.close()
except Exception:
pass
if master_conn is not None:
try:
master_conn.close()
except Exception:
pass
return jsonify({'ok': True})