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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
106
pos/blueprints/public_bp.py
Normal 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.'})
|
||||
@@ -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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user