From ff45905b49fbb4616eff47ba7d0de60c0582e366 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Wed, 6 May 2026 20:27:14 +0000 Subject: [PATCH] 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 --- DEMO_PROMPTS.md | 181 +++++++++ DEMO_PROMPTS_V2.md | 198 +++++++++ nginx/nexus-pos.conf | 29 +- pos/app.py | 3 + pos/blueprints/catalog_bp.py | 3 +- pos/blueprints/inventory_bp.py | 43 +- pos/blueprints/pos_bp.py | 462 ++++++++++++++++++++- pos/blueprints/public_bp.py | 106 +++++ pos/blueprints/whatsapp_bp.py | 333 +++++++++++---- pos/config.py | 18 + pos/services/ai_chat.py | 314 ++++++++------- pos/services/catalog_service.py | 260 +++++++++++- pos/services/inventory_engine.py | 7 +- pos/services/inventory_vehicle_compat.py | 53 +++ pos/services/quote_reservation.py | 123 ++++++ pos/services/qwen_fitment.py | 165 +++++--- pos/services/wa_quotation.py | 126 +++++- pos/static/css/whatsapp.css | 14 +- pos/static/js/catalog.js | 21 +- pos/static/js/inventory.js | 27 +- pos/static/js/pos.js | 62 ++- pos/static/js/whatsapp.js | 60 ++- pos/static/js/whatsapp2.js | 489 +++++++++++++++++++++++ pos/static/pwa/sw.js | 8 +- pos/templates/catalog.html | 2 +- pos/templates/inventory.html | 2 +- pos/templates/quotations.html | 69 +++- pos/templates/whatsapp.html | 20 +- pos/tenant_db.py | 89 +++-- systemd/nexus-pos.service | 3 + tests/e2e/auth-guard.spec.js | 24 +- tests/e2e/inventory.spec.js | 95 ++++- tests/e2e/pos-checkout.spec.js | 76 +++- 33 files changed, 3040 insertions(+), 445 deletions(-) create mode 100644 DEMO_PROMPTS.md create mode 100644 DEMO_PROMPTS_V2.md create mode 100644 pos/blueprints/public_bp.py create mode 100644 pos/services/quote_reservation.py create mode 100644 pos/static/js/whatsapp2.js diff --git a/DEMO_PROMPTS.md b/DEMO_PROMPTS.md new file mode 100644 index 0000000..9d44688 --- /dev/null +++ b/DEMO_PROMPTS.md @@ -0,0 +1,181 @@ +# 🧪 Prompts de Prueba — Demo WhatsApp Agent + +> **Fecha:** mañana +> **Backend primario:** QWEN (qwen3.6) — ~1-3 segundos +> **Fallback:** Hermes (hermes-agent) — ~10-30 segundos si QWEN falla +> **Contexto persistente:** vehículo guardado en sesión, historial de últimos 4 mensajes + +--- + +## 1. Saludo + búsqueda simple con vehículo + +**Prompt:** +``` +hola, necesito balatas para un Nissan Tsuru 2015 +``` + +**¿Qué validar?** +- Responde en ~2-4 segundos +- Detecta vehículo: `{"brand": "Nissan", "model": "Tsuru", "year": 2015}` +- `search_query`: `Brake Pad` (o similar en inglés) +- Muestra resultados de inventario si hay stock + +--- + +## 2. Síntoma mecánico (diagnóstico) + +**Prompt:** +``` +mi carro vibra al frenar, que puede ser? +``` + +**¿Qué validar?** +- Responde en ~2-5 segundos +- Identifica síntoma: discos de freno / balatas desgastadas +- `search_query`: `Brake Disc` +- Da diagnóstico + lista de partes probables + +--- + +## 3. Tune-up completo (cotización múltiple) + +**Prompt:** +``` +quiero hacer el tune up a mi Renault Duster 2018 +``` + +**¿Qué validar?** +- Responde en ~15-20 segundos (el prompt más complejo, paciencia aquí) +- Detecta vehículo: `{"brand": "RENAULT", "model": "Duster", "year": 2018}` +- `search_query`: `Spark Plug|Air Filter|Oil Filter|Fuel Filter` (separado por `|`) +- Muestra tabla de partes recomendadas + +--- + +## 4. Seguimiento — "Sí, pásame la cotización" + +**Prompt:** +``` +si, pasame la cotizacion +``` + +**¿Qué validar?** +- **Crítico:** Recuerda el contexto (Renault Duster 2018 + tune-up) +- No pida "¿qué vehículo?" de nuevo +- `search_query` contiene múltiples partes separadas por `|` +- Responde en ~2-5 segundos (muy rápido porque ya tiene historial) + +--- + +## 5. Agregar a cotización + +**Prompt:** +``` +cotizar +``` + +**¿Qué validar?** +- Detecta intent de cotización +- Agrega la última parte mostrada a la cotización abierta +- Responde con conteo de ítems y total parcial +- Incluye botón "Enviar Cotización" si se usa web + +--- + +## 6. Preguntar por otra parte (contexto mixto) + +**Prompt:** +``` +y cuanto cuesta un alternador para el mismo carro? +``` + +**¿Qué validar?** +- Recuerda "el mismo carro" = Renault Duster 2018 +- `search_query`: `Alternator` +- Muestra precio + stock del alternador + +--- + +## 7. Enviar cotización final + +**Prompt:** +``` +enviar cotizacion +``` + +**¿Qué validar?** +- Envía la cotización completa formateada +- Muestra todos los ítems agregados con precios +- Incluye mensaje: *"Escribe 'sí' para confirmar tu pedido"* + +--- + +## 8. Confirmar pedido + +**Prompt:** +``` +si +``` + +**¿Qué validar?** +- Confirma la cotización como pedido +- Responde: *"✅ Pedido confirmado! Tu cotización #X fue registrada..."* +- Guarda la cotización con estado `confirmed` + +--- + +## 9. Limpiar conversación + +**Prompt:** +``` +limpiar chat +``` + +**¿Qué validar?** +- Borra historial de mensajes de la DB +- Responde: *"🗑️ Conversación reiniciada. ¡Hola de nuevo! ¿En qué puedo ayudarte?"* +- Próximo mensaje debe comportarse como conversación nueva + +--- + +## 10. Parte sin stock / no en inventario + +**Prompt:** +``` +necesito un turbo para BMW X5 2022 +``` + +**¿Qué validar?** +- Detecta vehículo: `{"brand": "BMW", "model": "X5", "year": 2022}` +- Si no hay en inventario, responde de forma conversacional: + - *"No encontré ese turbo en stock, pero puedo..."* + - Ofrece: pedido por encargo, alternativas, o sugerir tiendas +- **NO** responde con mensaje seco tipo *"❌ No tenemos esa parte"* + +--- + +## ⚡ Checklist rápido antes de la demo + +- [ ] WhatsApp Bridge está `state: open` (verificar en UI) +- [ ] Gunicorn está corriendo (`systemctl status nexus-pos`) +- [ ] El QR está escaneado y la instancia está conectada +- [ ] Limpiar historial de conversaciones de prueba anteriores +- [ ] Probar al menos 3 prompts de los de arriba en vivo +- [ ] Tener plan B: si QWEN falla, Hermes responderá (más lento pero funciona) + +## 🚨 Qué hacer si algo falla durante la demo + +1. **Timeout / "El asistente tardó mucho"** + - Esperar 10-15 segundos y reenviar el mensaje + - QWEN a veces tiene picos de latencia + +2. **El agente "olvida" el vehículo** + - Escribir `limpiar chat` y empezar de nuevo + - O mencionar el vehículo explícitamente en cada mensaje + +3. **No abre el panel de WhatsApp en la web** + - Hard refresh: **Ctrl+F5** + - O abrir en pestaña de incógnito + +4. **Error de conexión del Bridge** + - En la UI de WhatsApp, clic en **"Conectar WhatsApp"** y re-escanear QR diff --git a/DEMO_PROMPTS_V2.md b/DEMO_PROMPTS_V2.md new file mode 100644 index 0000000..b078d96 --- /dev/null +++ b/DEMO_PROMPTS_V2.md @@ -0,0 +1,198 @@ +# 🧪 10 Prompts de Demo — Funcionalidades del Agente WhatsApp + +> Usa estos prompts en orden o saltando entre ellos para mostrar la versatilidad del agente. + +--- + +## 1. Búsqueda directa con vehículo clásico mexicano + +**Prompt:** +``` +Necesito bujías para un Nissan Tsuru 2015 +``` + +**¿Qué demuestra?** +- Detección precisa de vehículo mexicano clásico (Tsuru) +- Traducción automática a inglés: `Spark Plug` +- Búsqueda en inventario local con compatibilidad de vehículo + +**Respuesta esperada:** Tabla de bujías NGK/Bosch con stock y precios. + +--- + +## 2. Diagnóstico por síntoma — suspensión + +**Prompt:** +``` +Mi carro se jala hacia la izquierda al frenar, qué puede ser? +``` + +**¿Qué demuestra?** +- Capacidad de diagnóstico sin mencionar una parte específica +- Relaciona síntoma con partes probables: terminales, rotulas, balatas del lado izquierdo +- Genera `search_query`: `Tie Rod End` + +**Respuesta esperada:** Diagnóstico + lista de partes probables ordenadas por probabilidad. + +--- + +## 3. Cotización de kit completo — embrague + +**Prompt:** +``` +Cuánto cuesta cambiar el clutch de un Pointer 2010? +``` + +**¿Qué demuestra?** +- Detecta "Pointer" como modelo mexicano +- Interpreta "cambiar el clutch" como kit completo (embrague + plato + collarín) +- Genera múltiples `search_query` separados por `|`: `Clutch Kit|Clutch Plate|Release Bearing` + +**Respuesta esperada:** Lista de componentes del kit de embrague con precios. + +--- + +## 4. Mantenimiento preventivo — servicio de 50,000 km + +**Prompt:** +``` +Quiero el servicio de 50 mil kilómetros para mi Jetta 2019 +``` + +**¿Qué demuestra?** +- Entiende "servicio de 50,000 km" como paquete de mantenimiento +- Genera lista completa: aceite, filtros, bujías, refrigerante, frenos +- `search_query`: `Oil Filter|Air Filter|Spark Plug|Coolant|Brake Pad` + +**Respuesta esperada:** Paquete de servicio con todas las partes necesarias. + +--- + +## 5. Parte sin especificar vehículo (prueba de persistencia) + +**Prompt:** +``` +Y el filtro de aceite cuánto cuesta? +``` + +**¿Qué demuestra?** +- **Contexto persistente:** recuerda que se habló del Jetta 2019 (prompt 4) +- No pide "¿para qué carro?" de nuevo +- Usa el vehículo guardado en sesión automáticamente + +**Respuesta esperada:** Precio del filtro de aceite compatible con Jetta 2019. + +--- + +## 6. Falla eléctrica — no arranca + +**Prompt:** +``` +Mi camioneta no quiere arrancar en las mañanas, qué le puede faltar? +``` + +**¿Qué demuestra?** +- Diagnóstico eléctrico sin mencionar parte específica +- Sugiere: batería, motor de arranque, alternador, cables de bujías +- `search_query`: `Starter Motor` (la parte más probable) + +**Respuesta esperada:** Diagnóstico con 3-4 partes posibles + la más probable primero. + +--- + +## 7. Combo de frenos completos + +**Prompt:** +``` +Cotízame frenos completos delanteros para un Aveo 2017 +``` + +**¿Qué demuestra?** +- Interpreta "frenos completos delanteros" como combo: balatas + discos + líquido +- Genera `search_query`: `Brake Pad|Brake Disc|Brake Fluid` +- Ofrece cotización de múltiples ítems de una sola vez + +**Respuesta esperada:** Tabla con balatas, discos y líquido de frenos compatibles. + +--- + +## 8. Parte para vehículo europeo (sin stock local) + +**Prompt:** +``` +Busco un radiador para Audi A4 2021 +``` + +**¿Qué demuestra?** +- Detección de vehículo europeo con formato correcto +- Cuando no hay stock, responde de forma conversacional (NO un mensaje seco) +- Ofrece alternativas: pedido por encargo, equivalentes, o tiendas cercanas + +**Respuesta esperada:** +> "No encontré ese radiador en stock para tu Audi A4 2021, pero puedo: +> • Pedirlo por encargo con 3-5 días de entrega +> • Buscar un equivalente de otra marca +> ¿Qué prefieres?" + +--- + +## 9. Agregar segunda parte a cotización abierta + +**Prompt:** +``` +También agrega un filtro de aire +``` + +**¿Qué demuestra?** +- Flujo conversacional de cotización multi-paso +- Agrega ítem adicional a la cotización ya abierta +- Actualiza conteo de productos y total parcial + +**Respuesta esperada:** +> "✅ *Filtro de aire* × 1 agregado. Llevas 4 productos — total parcial: $1,240.50" + +--- + +## 10. Confirmar pedido y cerrar venta + +**Prompt:** +``` +Sí, todo bien, confirmo el pedido +``` + +**¿Qué demuestra?** +- Detecta intención de confirmación ("sí", "confirmo", "todo bien") +- Cierra la cotización como pedido confirmado +- Genera número de pedido y mensaje de cierre profesional + +**Respuesta esperada:** +> "✅ *Pedido confirmado!* +> Tu cotización #42 fue registrada. +> Nos pondremos en contacto contigo para coordinar la entrega. +> ¡Gracias por tu compra! 🙏" + +--- + +## 🎬 Sugerencia de guión para la demo (8-10 minutos) + +| Minuto | Prompt | Efecto demo | +|--------|--------|-------------| +| 0:00 | Prompt 1 (Tsuru bujías) | Saludo rápido, resultados en 2s | +| 0:45 | Prompt 2 (se jala al frenar) | Diagnóstico inteligente | +| 1:45 | Prompt 4 (servicio Jetta) | Paquete completo de mantenimiento | +| 3:00 | Prompt 5 (filtro de aceite) | "¿Recuerdas el carro?" — contexto persistente | +| 3:45 | Prompt 7 (frenos Aveo) | Cotización múltiple con tabla | +| 4:45 | Prompt 9 (agregar filtro de aire) | Cotización conversacional | +| 5:30 | Prompt 10 (confirmo pedido) | Cierre de venta | +| 6:00 | Prompt 8 (Audi sin stock) | Manejo elegante de "no tengo" | +| 6:45 | Prompt 3 (Pointer clutch) | Kit completo con precios | +| 7:30 | Prompt 6 (no arranca) | Diagnóstico final | + +--- + +## 🛡️ Plan de contingencia + +Si en algún momento QWEN tarda más de 30 segundos: +1. Decir: *"Voy a reenviar el mensaje, a veces el asistente necesita un segundo intento"* +2. Reenviar el mismo prompt +3. Si sigue lento, usar `limpiar chat` y empezar ese flujo de nuevo diff --git a/nginx/nexus-pos.conf b/nginx/nexus-pos.conf index 7e418d7..41fb102 100644 --- a/nginx/nexus-pos.conf +++ b/nginx/nexus-pos.conf @@ -29,26 +29,14 @@ server { listen 80; server_name nexusautoparts.com www.nexusautoparts.com; - # Static asset caching - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { - expires 6M; - add_header Cache-Control "public, immutable"; - add_header X-Content-Type-Options nosniff always; - } - - # Auto-serve minified JS/CSS when available (transparent to templates) - location ~* ^(.+)\.js$ { - try_files $1.min.js $uri =404; - expires 6M; - add_header Cache-Control "public, immutable"; - add_header X-Content-Type-Options nosniff always; - } - - location ~* ^(.+)\.css$ { - try_files $1.min.css $uri =404; + # POS static assets — served directly by nginx (not proxied) + # ^~ prevents regex locations from intercepting these requests + location ^~ /pos/static/ { + alias /home/Autopartes/pos/static/; expires 6M; add_header Cache-Control "public, immutable"; add_header X-Content-Type-Options nosniff always; + access_log off; } location / { @@ -75,11 +63,14 @@ server { add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; - # Static asset caching - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + # POS static assets — served directly by nginx (not proxied) + # ^~ prevents regex locations from intercepting these requests + location ^~ /pos/static/ { + alias /home/Autopartes/pos/static/; expires 6M; add_header Cache-Control "public, immutable"; add_header X-Content-Type-Options nosniff always; + access_log off; } location / { diff --git a/pos/app.py b/pos/app.py index 52d4163..9341fb9 100644 --- a/pos/app.py +++ b/pos/app.py @@ -32,6 +32,9 @@ def create_app(): from blueprints.pos_bp import pos_bp app.register_blueprint(pos_bp) + from blueprints.public_bp import public_bp + app.register_blueprint(public_bp) + from blueprints.customers_bp import customers_bp app.register_blueprint(customers_bp) diff --git a/pos/blueprints/catalog_bp.py b/pos/blueprints/catalog_bp.py index 28b23a0..be2bfe5 100644 --- a/pos/blueprints/catalog_bp.py +++ b/pos/blueprints/catalog_bp.py @@ -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) diff --git a/pos/blueprints/inventory_bp.py b/pos/blueprints/inventory_bp.py index 64c816c..ea17aec 100644 --- a/pos/blueprints/inventory_bp.py +++ b/pos/blueprints/inventory_bp.py @@ -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 diff --git a/pos/blueprints/pos_bp.py b/pos/blueprints/pos_bp.py index a21f951..7e0ee85 100644 --- a/pos/blueprints/pos_bp.py +++ b/pos/blueprints/pos_bp.py @@ -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/', 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/', 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//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/', 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//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//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 = """ + + + + + +Cotizacion #{{ quot.id }} + + + +
+
+

Cotizacion #{{ quot.id }}

+

{{ host }}

+
+
+
+
Cliente{{ quot.customer_name or 'Publico general' }}
+
Fecha{{ quot.created_at[:10] if quot.created_at else '—' }}
+
Vigencia{{ quot.valid_until or '—' }} {{ quot.status }}
+
+ + + + {% for it in items %} + + + + + + + {% endfor %} + +
DescripcionCantP. UnitSubtotal
+
{{ it.name }}
+
{{ it.part_number }}
+
{{ it.quantity }}${{ "{:,.2f}".format(it.unit_price) }}${{ "{:,.2f}".format(it.subtotal) }}
+
+
Subtotal: ${{ "{:,.2f}".format(quot.subtotal) }}
+
IVA: ${{ "{:,.2f}".format(quot.tax_total) }}
+
Total: ${{ "{:,.2f}".format(quot.total) }}
+
+
+
+ {% if quot.status == 'active' %} + + {% else %} + + {% endif %} +
+ +
+ + + +""" diff --git a/pos/blueprints/public_bp.py b/pos/blueprints/public_bp.py new file mode 100644 index 0000000..5c74766 --- /dev/null +++ b/pos/blueprints/public_bp.py @@ -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/', 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//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.'}) diff --git a/pos/blueprints/whatsapp_bp.py b/pos/blueprints/whatsapp_bp.py index b393da3..3f8ad9c 100644 --- a/pos/blueprints/whatsapp_bp.py +++ b/pos/blueprints/whatsapp_bp.py @@ -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}) diff --git a/pos/config.py b/pos/config.py index e03a15e..8107800 100644 --- a/pos/config.py +++ b/pos/config.py @@ -43,6 +43,15 @@ if not OPENROUTER_API_KEY: RuntimeWarning ) +# ─── Hermes Agent ────────────────────────────────────────────────────────── +HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://192.168.10.71:8642/v1") +HERMES_API_KEY = os.environ.get("HERMES_API_KEY") +if not HERMES_API_KEY: + warnings.warn( + "HERMES_API_KEY not set. Hermes Agent integration will fall back to OpenRouter.", + RuntimeWarning + ) + # ─── SMTP ────────────────────────────────────────────────────────────────── SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com') SMTP_PORT = int(os.environ.get('SMTP_PORT', '587')) @@ -75,3 +84,12 @@ MEILI_ENABLED = os.environ.get('MEILI_ENABLED', 'true').lower() == 'true' # ─── Catalog OEM Access ──────────────────────────────────────────────────── CATALOG_OEM_ENABLED = os.environ.get('CATALOG_OEM_ENABLED', 'false').lower() == 'true' + +# ─── QWEN AI Fitment (private cloud server) ──────────────────────────────── +QWEN_API_URL = os.environ.get('QWEN_API_URL', '') +QWEN_API_KEY = os.environ.get('QWEN_API_KEY', '') +QWEN_MODEL = os.environ.get('QWEN_MODEL', 'qwen3.6') + + +# ─── Internal Cron / Job Security ────────────────────────────────────────── +INTERNAL_API_KEY = os.environ.get('INTERNAL_API_KEY', '') diff --git a/pos/services/ai_chat.py b/pos/services/ai_chat.py index 8474132..cfe2dae 100644 --- a/pos/services/ai_chat.py +++ b/pos/services/ai_chat.py @@ -3,9 +3,15 @@ import requests import json -from config import OPENROUTER_API_KEY +from config import OPENROUTER_API_KEY, HERMES_API_URL, HERMES_API_KEY +from config import QWEN_API_URL, QWEN_API_KEY, QWEN_MODEL OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" +HERMES_ENABLED = bool(HERMES_API_KEY and HERMES_API_URL) +HERMES_CHAT_URL = (HERMES_API_URL.rstrip('/') + '/chat/completions') if HERMES_API_URL else None + +QWEN_ENABLED = bool(QWEN_API_KEY and QWEN_API_URL) +QWEN_CHAT_URL = (QWEN_API_URL.rstrip('/') + '/chat/completions') if QWEN_API_URL else None # ⚠️ SOLO MODELOS GRATUITOS — No cambiar a modelos de pago. # El modelo DEBE terminar en ":free" para garantizar costo $0. @@ -24,11 +30,69 @@ FALLBACK_MODELS = [ "meta-llama/llama-3.3-70b-instruct:free", # Meta — último fallback ] +# Hermes Agent model (OpenAI-compatible API server) +HERMES_MODEL = "hermes-agent" + def _validate_model(model_id): - """Ensure only free models are used. Raises if model is not free.""" + """Ensure only free models are used. Raises if model is not free. + + Skips validation for Hermes Agent and QWEN models (self-hosted / private API). + """ + if model_id == HERMES_MODEL: + return + if model_id == QWEN_MODEL: + return if not model_id.endswith(':free'): raise ValueError(f"BLOQUEADO: Solo se permiten modelos gratuitos (:free). Modelo '{model_id}' no es gratuito.") + +def _post_chat_completion(url, api_key, model_id, messages, max_tokens=800, temperature=0.3, timeout=25): + """Generic OpenAI-compatible chat completion POST. + + Returns the parsed response dict on success, None on failure. + """ + try: + resp = requests.post( + url, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": model_id, + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + }, + timeout=timeout, + ) + if resp.status_code == 429: + print(f"[AI] Rate limited on {model_id} ({url})") + return None + if resp.status_code >= 400: + print(f"[AI] HTTP {resp.status_code} on {model_id} ({url}): {resp.text[:200]}") + return None + data = resp.json() + choice = data.get("choices", [{}])[0] + content = choice.get("message", {}).get("content") or "" + content = content.strip() + finish = choice.get("finish_reason", "") + if not content: + print(f"[AI] Empty response from {model_id} (finish={finish})") + return None + return {"content": content, "finish_reason": finish, "model": model_id} + except Exception as e: + print(f"[AI] Error with {model_id} ({url}): {e}") + return None + + +SYSTEM_PROMPT_SHORT = """Eres un asistente de refaccionaria automotriz mexicana. Ayuda a encontrar autopartes. +Responde SIEMPRE en formato JSON: {"message":"...","search_query":"...","vehicle":{"brand":"...","model":"...","year":...}} +search_query va EN INGLES cuando el usuario pide una parte. Traducciones: Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector. +No preguntes mas si ya puedes buscar. Si el usuario describe un sintoma, diagnostica y sugiere partes. +Cuando pida cotizacion o multiples partes, search_query DEBE usar | para separar cada parte: "Brake Pad|Air Filter|Oil Filter|Spark Plug". +""" + SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes. IMPORTANTE: Responde SIEMPRE en formato JSON valido con esta estructura: @@ -161,6 +225,7 @@ def get_inventory_context(tenant_conn, branch_id=None): VISION_MODEL = "google/gemma-3-27b-it:free" +HERMES_VISION_MODEL = "hermes-agent" VISION_SYSTEM_PROMPT = """Eres un experto en identificación de autopartes. El usuario te envía una foto de una parte automotriz. Tu trabajo es: @@ -219,54 +284,41 @@ def chat_with_image(user_message, image_base64, conversation_history=None, inven ] messages.append({"role": "user", "content": user_content}) - import time - max_retries = 3 - - for attempt in range(max_retries): + # Try Hermes first for vision (if enabled), fallback to OpenRouter + backends = [] + if HERMES_ENABLED: + backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_VISION_MODEL)) + if OPENROUTER_API_KEY: + backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, VISION_MODEL)) + + last_error = None + for url, key, model_id in backends: + _validate_model(model_id) + result = _post_chat_completion(url, key, model_id, messages, max_tokens=500, temperature=0.3, timeout=30) + if result is None: + last_error = "api_error" + continue + content = result["content"] try: - resp = requests.post( - OPENROUTER_URL, - headers={ - "Authorization": f"Bearer {OPENROUTER_API_KEY}", - "Content-Type": "application/json", - }, - json={ - "model": VISION_MODEL, - "messages": messages, - "max_tokens": 500, - "temperature": 0.3, - }, - timeout=30, - ) - if resp.status_code == 429: - wait = (attempt + 1) * 5 - if attempt < max_retries - 1: - time.sleep(wait) - continue - return {"message": "El asistente esta ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None} - resp.raise_for_status() - data = resp.json() - content = data["choices"][0]["message"]["content"] - - try: - stripped = content.strip() - if stripped.startswith("```"): - lines = stripped.split("\n") - json_str = "\n".join(lines[1:-1]) - parsed = json.loads(json_str) - else: - parsed = json.loads(stripped) + stripped = content.strip() + if stripped.startswith("```"): + lines = stripped.split("\n") + json_str = "\n".join(lines[1:-1]) + parsed = json.loads(json_str) return parsed - except (json.JSONDecodeError, IndexError): - return {"message": content, "search_query": None, "vehicle": None} - except Exception as e: - if attempt < max_retries - 1: - continue - return { - "message": f"Error al analizar imagen: {str(e)}", - "search_query": None, - "vehicle": None, - } + else: + parsed = json.loads(stripped) + return parsed + except (json.JSONDecodeError, IndexError): + return {"message": content, "search_query": None, "vehicle": None} + + if last_error == "api_error": + return {"message": "El asistente esta ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None} + return { + "message": f"Error al analizar imagen: {last_error}", + "search_query": None, + "vehicle": None, + } def classify_part(part_number): @@ -287,47 +339,32 @@ def classify_part(part_number): {"role": "user", "content": prompt} ] - import time - max_retries = 3 - - for attempt in range(max_retries): + # Try Hermes first (if enabled), fallback to OpenRouter + backends = [] + if HERMES_ENABLED: + backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL)) + if OPENROUTER_API_KEY: + backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, MODEL)) + + for url, key, model_id in backends: + _validate_model(model_id) + result = _post_chat_completion(url, key, model_id, messages, max_tokens=300, temperature=0.2, timeout=15) + if result is None: + continue + content = result["content"] try: - resp = requests.post( - OPENROUTER_URL, - headers={ - "Authorization": f"Bearer {OPENROUTER_API_KEY}", - "Content-Type": "application/json", - }, - json={ - "model": MODEL, - "messages": messages, - "max_tokens": 300, - "temperature": 0.2, - }, - timeout=15, - ) - if resp.status_code == 429: - wait = (attempt + 1) * 5 - if attempt < max_retries - 1: - time.sleep(wait) - continue - return {"name": None, "brand": None, "vehicle": None, "category": None} - resp.raise_for_status() - data = resp.json() - content = data["choices"][0]["message"]["content"] - stripped = content.strip() if stripped.startswith("```"): lines = stripped.split("\n") json_str = "\n".join(lines[1:-1]) parsed = json.loads(json_str) + return parsed else: parsed = json.loads(stripped) - return parsed + return parsed except Exception: - if attempt < max_retries - 1: - continue - return {"name": None, "brand": None, "vehicle": None, "category": None} + continue + return {"name": None, "brand": None, "vehicle": None, "category": None} # ═══════════════════════════════════════════════════════════════════════════ @@ -491,74 +528,71 @@ def chat(user_message, conversation_history=None, inventory_context=None): last_error = None - # Try each model in the fallback chain on 429 (rate limit) - for model_id in FALLBACK_MODELS: - _validate_model(model_id) # Block paid models - try: - resp = requests.post( - OPENROUTER_URL, - headers={ - "Authorization": f"Bearer {OPENROUTER_API_KEY}", - "Content-Type": "application/json", - }, - json={ - "model": model_id, - "messages": messages, - "max_tokens": 800, - "temperature": 0.3, - }, - timeout=25, - ) - if resp.status_code == 429: + # Build backend list: QWEN first (fast, ~1s), then Hermes (specialized, ~30s), then OpenRouter + backends = [] + if QWEN_ENABLED: + backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 35, SYSTEM_PROMPT_SHORT, 4000)) + if HERMES_ENABLED: + backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL, 45, SYSTEM_PROMPT, 800)) + if OPENROUTER_API_KEY: + for m in FALLBACK_MODELS: + backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, m, 25, SYSTEM_PROMPT, 800)) + + for url, key, model_id, timeout_sec, sys_prompt, max_tok in backends: + _validate_model(model_id) + # Use backend-specific system prompt and max_tokens + sys_content = sys_prompt + if inventory_context: + sys_content = sys_prompt + "\n\n" + inventory_context + msgs = [{"role": "system", "content": sys_content}] + if conversation_history: + msgs.extend(conversation_history) + msgs.append({"role": "user", "content": user_message}) + result = _post_chat_completion(url, key, model_id, msgs, max_tokens=max_tok, temperature=0.3, timeout=timeout_sec) + if result is None: + if url == QWEN_CHAT_URL: + print(f"[AI] QWEN failed, trying Hermes fallback...") + last_error = "qwen_failed" + elif url == HERMES_CHAT_URL: + print(f"[AI] Hermes failed, trying OpenRouter fallback...") + last_error = "hermes_timeout" + else: print(f"[AI] Rate limited on {model_id}, trying next model...") last_error = "rate_limit" - continue - if resp.status_code >= 400: - print(f"[AI] HTTP {resp.status_code} on {model_id}: {resp.text[:200]}") - last_error = f"http_{resp.status_code}" - continue - data = resp.json() - choice = data.get("choices", [{}])[0] - content = choice.get("message", {}).get("content", "").strip() - finish = choice.get("finish_reason", "") - - if not content: - print(f"[AI] Empty response from {model_id} (finish={finish})") - last_error = "empty_response" - continue - - print(f"[AI] Response from {model_id} (finish={finish}, {len(content)} chars)") - - # Try to parse JSON response - try: - stripped = content.strip() - if stripped.startswith("```"): - lines = stripped.split("\n") - json_str = "\n".join(lines[1:-1]) - parsed = json.loads(json_str) - else: - parsed = json.loads(stripped) - # Successful JSON response — cache it - if cache_key: - _cache_set(cache_key, parsed) - return parsed - except (json.JSONDecodeError, IndexError): - fallback = {"message": content, "search_query": None, "vehicle": None} - # Cache the fallback too — the model gave us a real answer, - # it just wasn't JSON. Next hit saves the API call. - if cache_key: - _cache_set(cache_key, fallback) - return fallback - except Exception as e: - print(f"[AI] Error with {model_id}: {e}") - last_error = str(e) continue + + content = result["content"] + finish = result["finish_reason"] + print(f"[AI] Response from {model_id} (finish={finish}, {len(content)} chars)") + + # Try to parse JSON response + try: + stripped = content.strip() + if stripped.startswith("```"): + lines = stripped.split("\n") + json_str = "\n".join(lines[1:-1]) + parsed = json.loads(json_str) + else: + parsed = json.loads(stripped) + # Successful JSON response — cache it + if cache_key: + _cache_set(cache_key, parsed) + return parsed + except (json.JSONDecodeError, IndexError): + fallback = {"message": content, "search_query": None, "vehicle": None} + # Cache the fallback too — the model gave us a real answer, + # it just wasn't JSON. Next hit saves the API call. + if cache_key: + _cache_set(cache_key, fallback) + return fallback # All models exhausted — DON'T cache errors, we want retries next time if last_error == "rate_limit": return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None} + if last_error == "hermes_timeout": + return {"message": "El asistente tardó mucho en responder. Intenta de nuevo en un momento.", "search_query": None, "vehicle": None} return { - "message": f"Error de conexion: {last_error}", + "message": "El asistente no está disponible en este momento. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None, } diff --git a/pos/services/catalog_service.py b/pos/services/catalog_service.py index 19750dd..4b0603e 100644 --- a/pos/services/catalog_service.py +++ b/pos/services/catalog_service.py @@ -17,7 +17,8 @@ import re import redis from services.na_models import is_na_model -from services.translations import translate_part_name, translate_category +from services.translations import translate_part_name, translate_category, PART_TRANSLATIONS +from services.nexpart_taxonomy import translate_taxonomy_node # Lazy Redis client for catalog caches _redis_client = None @@ -632,6 +633,120 @@ def get_shop_supplies_parts(master_conn, group_slug, subgroup_slug, part_type_sl ) +def _normalize_es(text): + """Lowercase and strip accents for Spanish text matching.""" + if not text: + return '' + text = text.lower() + for a, b in [('á', 'a'), ('é', 'e'), ('í', 'i'), ('ó', 'o'), ('ú', 'u'), + ('ü', 'u'), ('ñ', 'n')]: + text = text.replace(a, b) + return text + + +def _local_name_matches_part_type(name, part_type_slug): + """Check if a local inventory item name matches a Nexpart part_type. + + Uses translation layers: + 1. Direct substring (original slug in name) — legacy + 2. Full Spanish translation via translate_taxonomy_node + 3. Sub-phrase translations via PART_TRANSLATIONS + 4. Word-level matching (handles plurals and partial matches) + 5. Extra synonym mappings for Mexican aftermarket terminology + """ + if not name or not part_type_slug: + return True + + name_norm = _normalize_es(name) + slug_lower = part_type_slug.lower() + + # 1. Legacy direct match + if slug_lower in name_norm: + return True + + candidates = [] + + # 2. Full translation of the part_type slug + translated = translate_taxonomy_node(part_type_slug) + if translated and translated != part_type_slug: + candidates.append(_normalize_es(translated)) + + # 3. Sub-phrase translation: find the longest PART_TRANSLATIONS key + # that is contained in the part_type_slug. + best_key = None + best_len = 0 + for en_key, es_val in PART_TRANSLATIONS.items(): + if en_key.lower() in slug_lower and len(en_key) > best_len: + best_key = en_key + best_len = len(en_key) + if best_key: + candidates.append(_normalize_es(PART_TRANSLATIONS[best_key])) + + # 4. Word-level matching: any significant word (4+ chars) from the + # candidate translations must appear in the local name. + # Also strip trailing 's' to handle plurals (balatas -> balata). + for cand in candidates: + if cand in name_norm: + return True + words = [w for w in cand.split() if len(w) >= 4] + for w in words: + if w in name_norm: + return True + # plural fallback + if w.endswith('s') and w[:-1] in name_norm: + return True + if w.endswith('es') and w[:-2] in name_norm: + return True + + # 5. Extra synonyms for common Mexican aftermarket terms + # Map English sub-phrases to additional Spanish keywords. + EXTRA_SYNONYMS = { + 'brake pad': ['balata', 'pastilla'], + 'brake shoe': ['zapata', 'balata'], + 'brake disc': ['disco', 'rotor'], + 'brake rotor': ['disco', 'rotor'], + 'shock absorber': ['amortiguador', 'amortiguadores'], + 'strut': ['amortiguador', 'torre', 'estrut'], + 'spark plug': ['bujia', 'bujía', 'bujias'], + 'air filter': ['filtro de aire', 'filtro aire'], + 'oil filter': ['filtro de aceite', 'filtro aceite'], + 'fuel filter': ['filtro de gasolina', 'filtro gasolina'], + 'cabin filter': ['filtro de cabina', 'filtro cabina', 'filtro de polen'], + 'timing belt': ['banda de tiempo', 'banda distribucion', 'correa de distribucion'], + 'drive belt': ['banda de accesorios', 'banda alternador'], + 'water pump': ['bomba de agua'], + 'alternator': ['alternador'], + 'starter': ['marcha', 'motor de arranque'], + 'radiator': ['radiador'], + 'thermostat': ['termostato'], + 'wheel bearing': ['balero', 'rodamiento'], + 'hub assembly': ['maza', 'cubo'], + 'control arm': ['horquilla', 'brazo'], + 'tie rod': ['terminal', 'rotula'], + 'ball joint': ['rotula', 'rotula'], + 'clutch kit': ['kit de clutch', 'kit de embrague'], + 'clutch disc': ['disco de clutch', 'disco de embrague'], + 'axle': ['flecha', 'punta de eje', 'homocinetica'], + 'cv joint': ['homocinetica', 'punta de eje'], + 'oxygen sensor': ['sensor de oxigeno', 'sensor o2'], + 'ignition coil': ['bobina', 'bobina de encendido'], + 'wiper': ['pluma', 'limpiaparabrisas', 'escobilla'], + 'headlight': ['faro', 'faro delantero'], + 'taillight': ['calavera', 'faro trasero'], + 'turn signal': ['direccional', 'cuarto'], + 'fog light': ['faro de niebla'], + 'battery': ['bateria', 'acumulador'], + 'horn': ['claxon', 'bocina'], + } + for en_phrase, es_keywords in EXTRA_SYNONYMS.items(): + if en_phrase in slug_lower: + for kw in es_keywords: + if _normalize_es(kw) in name_norm: + return True + + return False + + def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug, part_type_slug, tenant_conn, branch_id, page=1, per_page=30): @@ -659,13 +774,14 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug, ) # Inject local inventory items linked to this vehicle # (get_parts_local with oem_part_ids skips mye_id, so we call it separately) + local_injected = 0 if tenant_conn and mye_id: from services.inventory_vehicle_compat import get_inventory_by_vehicle local_rows = get_inventory_by_vehicle(tenant_conn, master_conn, mye_id, branch_id) for lr in local_rows: inv_id, pn, name, brand, p1, p2, p3, img, desc, stock = lr # Only include if name roughly matches the Nexpart part_type - if part_type_slug and part_type_slug.lower() not in (name or '').lower(): + if part_type_slug and not _local_name_matches_part_type(name, part_type_slug): continue result['data'].append({ 'id_part': f'inv:{inv_id}', @@ -686,6 +802,13 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug, 'price_usd': None, 'source': 'local_inventory', }) + local_injected += 1 + # Update pagination total to include injected local items + if local_injected: + result['pagination']['total'] = result['pagination'].get('total', 0) + local_injected + result['pagination']['total_pages'] = ( + (result['pagination']['total'] + per_page - 1) // per_page + ) return result @@ -1299,13 +1422,14 @@ def _search_meili_fallback(master_conn, q, limit): return None -def smart_search(master_conn, q, tenant_conn, branch_id, limit=50): +def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None): """Search parts by part number or text. Enriches with local stock. Strategy: 1. Try Meilisearch first (sub-100ms full-text + typo tolerance) 2. Fallback to PostgreSQL tsvector / ILIKE if Meilisearch is down - 3. Always enriches results with local stock from tenant DB + 3. Search local inventory items by part_number or name + 4. Always enriches results with local stock from tenant DB """ q = q.strip() if not q or len(q) < 2: @@ -1349,10 +1473,6 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50): """, (tsquery, f'%{q}%', f'%{q}%', tsquery, limit)) rows = cur.fetchall() - if not rows: - cur.close() - return [] - part_ids = [r[0] for r in rows] oem_numbers = [r[1] for r in rows] @@ -1390,6 +1510,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50): local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, part_ids) results = [] + seen_local_ids = set() for r in rows: part_id = r[0] oem = r[1] @@ -1403,10 +1524,133 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50): 'local_price': local['price_1'] if local else None, 'vehicle_info': vehicle_info_map.get(part_id, ''), }) + # Track which local inventory items are already shown via OEM link + if local: + seen_local_ids.add(local.get('inventory_id')) + + # ── Inject local inventory items that match the query directly ────────── + if tenant_conn: + local_items = _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit) + for li in local_items: + if li['inventory_id'] in seen_local_ids: + continue + results.append({ + 'id_part': f"inv:{li['inventory_id']}", + 'oem_part_number': li['part_number'], + 'name': li['name'], + 'image_url': li['image_url'], + 'local_stock': li['stock'], + 'local_price': li['price_1'], + 'vehicle_info': '', + 'source': 'local_inventory', + }) + if len(results) >= limit: + break return results +def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit): + """Search tenant inventory items by part_number or name. + + If mye_id is provided, only returns items compatible with that vehicle. + """ + if tenant_conn is None: + return [] + cur = tenant_conn.cursor() + clean_q = q.replace(' ', '').upper() + + # Helper to strip accents in SQL for case-insensitive matching + _SQL_UNACCENT = """ + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + UPPER(i.name) + , 'Á', 'A'), 'É', 'E'), 'Í', 'I'), 'Ó', 'O'), 'Ú', 'U') + , 'À', 'A'), 'È', 'E'), 'Ì', 'I'), 'Ò', 'O'), 'Ù', 'U') + """ + _q_unaccent = q.upper() + for a, b in [('Á', 'A'), ('É', 'E'), ('Í', 'I'), ('Ó', 'O'), ('Ú', 'U'), + ('À', 'A'), ('È', 'E'), ('Ì', 'I'), ('Ò', 'O'), ('Ù', 'U'), + ('Ä', 'A'), ('Ë', 'E'), ('Ï', 'I'), ('Ö', 'O'), ('Ü', 'U'), + ('Ñ', 'N')]: + _q_unaccent = _q_unaccent.replace(a, b) + + if mye_id: + # Search only items linked to the given vehicle + if branch_id: + cur.execute(f""" + SELECT i.id, i.part_number, i.name, i.image_url, + i.price_1, COALESCE(s.stock, 0) as stock + FROM inventory i + JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id + LEFT JOIN inventory_stock_summary s + ON s.inventory_id = i.id AND s.branch_id = %s + WHERE ivc.model_year_engine_id = %s + AND i.is_active = true + AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s + OR {_SQL_UNACCENT} LIKE %s) + ORDER BY i.name + LIMIT %s + """, (branch_id, mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit)) + else: + cur.execute(f""" + SELECT i.id, i.part_number, i.name, i.image_url, + i.price_1, COALESCE(SUM(s.stock), 0) as stock + FROM inventory i + JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id + LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id + WHERE ivc.model_year_engine_id = %s + AND i.is_active = true + AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s + OR {_SQL_UNACCENT} LIKE %s) + GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1 + ORDER BY i.name + LIMIT %s + """, (mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit)) + else: + # Search all active inventory items + if branch_id: + cur.execute(f""" + SELECT i.id, i.part_number, i.name, i.image_url, + i.price_1, COALESCE(s.stock, 0) as stock + FROM inventory i + LEFT JOIN inventory_stock_summary s + ON s.inventory_id = i.id AND s.branch_id = %s + WHERE i.is_active = true + AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s + OR {_SQL_UNACCENT} LIKE %s) + ORDER BY i.name + LIMIT %s + """, (branch_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit)) + else: + cur.execute(f""" + SELECT i.id, i.part_number, i.name, i.image_url, + i.price_1, COALESCE(SUM(s.stock), 0) as stock + FROM inventory i + LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id + WHERE i.is_active = true + AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s + OR {_SQL_UNACCENT} LIKE %s) + GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1 + ORDER BY i.name + LIMIT %s + """, (f'%{clean_q}%', f'%{_q_unaccent}%', limit)) + + rows = cur.fetchall() + cur.close() + return [ + { + 'inventory_id': r[0], + 'part_number': r[1], + 'name': r[2], + 'image_url': r[3], + 'price_1': float(r[4]) if r[4] is not None else None, + 'stock': int(r[5]) if r[5] is not None else 0, + } + for r in rows + ] + + # ───────────────────────────────────────────────────────────────────────────── # LOCAL STOCK HELPERS # ───────────────────────────────────────────────────────────────────────────── diff --git a/pos/services/inventory_engine.py b/pos/services/inventory_engine.py index b518276..b7f115e 100644 --- a/pos/services/inventory_engine.py +++ b/pos/services/inventory_engine.py @@ -96,12 +96,13 @@ def get_stock_bulk(conn, branch_id=None): def record_operation(conn, inventory_id, branch_id, operation_type, quantity, - reference_id=None, reference_type=None, cost_at_time=None, notes=None): + reference_id=None, reference_type=None, cost_at_time=None, + notes=None, employee_id=None): """Record a single inventory operation. Does NOT commit — caller controls transaction. Args: quantity: positive for entries (PURCHASE, RETURN, INITIAL), negative for exits (SALE) - operation_type: SALE, PURCHASE, RETURN, ADJUST, TRANSFER, INITIAL + operation_type: SALE, PURCHASE, RETURN, ADJUST, TRANSFER, INITIAL, QUOTE_RESERVE, QUOTE_RELEASE """ cur = conn.cursor() cur.execute(""" @@ -113,7 +114,7 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity, """, ( inventory_id, branch_id, operation_type, quantity, reference_id, reference_type, cost_at_time, - _safe_g('employee_id'), + employee_id if employee_id is not None else _safe_g('employee_id'), _safe_g('device_id'), notes )) diff --git a/pos/services/inventory_vehicle_compat.py b/pos/services/inventory_vehicle_compat.py index 720c5b8..a4b79ee 100644 --- a/pos/services/inventory_vehicle_compat.py +++ b/pos/services/inventory_vehicle_compat.py @@ -403,3 +403,56 @@ def save_qwen_fitment(tenant_conn, inventory_id, fitment_result): tenant_conn.commit() cur.close() return inserted + + +def get_inventory_by_vehicle(tenant_conn, master_conn, mye_id, branch_id=None): + """Return local inventory items compatible with a given vehicle (MYE). + + Args: + tenant_conn: Connection to tenant DB. + master_conn: Connection to master DB (kept for API consistency). + mye_id: model_year_engine_id. + branch_id: Optional branch filter for stock. + + Returns: + List of tuples: (id, part_number, name, brand, price_1, price_2, price_3, + image_url, description, stock) + """ + cur = tenant_conn.cursor() + + if branch_id: + # Stock for specific branch + cur.execute(""" + SELECT i.id, i.part_number, i.name, i.brand, + i.price_1, i.price_2, i.price_3, + i.image_url, i.description, + COALESCE(s.stock, 0) as stock + FROM inventory i + JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id + LEFT JOIN inventory_stock_summary s + ON s.inventory_id = i.id AND s.branch_id = %s + WHERE ivc.model_year_engine_id = %s + AND i.is_active = true + ORDER BY i.name + """, (branch_id, mye_id)) + else: + # Total stock across all branches + cur.execute(""" + SELECT i.id, i.part_number, i.name, i.brand, + i.price_1, i.price_2, i.price_3, + i.image_url, i.description, + COALESCE(SUM(s.stock), 0) as stock + FROM inventory i + JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id + LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id + WHERE ivc.model_year_engine_id = %s + AND i.is_active = true + GROUP BY i.id, i.part_number, i.name, i.brand, + i.price_1, i.price_2, i.price_3, + i.image_url, i.description + ORDER BY i.name + """, (mye_id,)) + + rows = cur.fetchall() + cur.close() + return rows diff --git a/pos/services/quote_reservation.py b/pos/services/quote_reservation.py new file mode 100644 index 0000000..5041caa --- /dev/null +++ b/pos/services/quote_reservation.py @@ -0,0 +1,123 @@ +"""Quotation stock reservation engine. + +Uses inventory_operations with operation types: + QUOTE_RESERVE — negative quantity, reserves stock when quote is created + QUOTE_RELEASE — positive quantity, restores stock when quote is cancelled/expired + QUOTE_CONVERT — neutral (just a marker), actual sale uses SALE operation + +The trigger update_stock_summary() recalculates inventory_stock_summary +by summing ALL operations, so reservations automatically affect visible stock. +""" +from services.inventory_engine import record_operation + + +def reserve_for_quotation(conn, quotation_id, items, employee_id=None): + """Reserve stock for each item in a new quotation. + + Args: + conn: tenant DB connection (not committed by this function). + quotation_id: the quotations.id. + items: list of dicts with inventory_id, quantity, branch_id (optional). + employee_id: optional, passed explicitly when g.employee_id is unavailable. + Returns: + list of operation IDs. + """ + op_ids = [] + for item in items: + inv_id = item.get('inventory_id') + qty = item.get('quantity', 0) + branch_id = item.get('branch_id') + if not inv_id or qty <= 0: + continue + op_id = record_operation( + conn, inv_id, branch_id, 'QUOTE_RESERVE', + quantity=-qty, + reference_id=quotation_id, + reference_type='quotation', + notes=f'Reserva cotizacion #{quotation_id}' + ) + op_ids.append(op_id) + return op_ids + + +def release_quotation_reservation(conn, quotation_id, items, employee_id=None): + """Release previously reserved stock (cancel, expire, or convert). + + Args: + conn: tenant DB connection. + quotation_id: the quotations.id. + items: list of dicts with inventory_id, quantity, branch_id. + employee_id: optional. + Returns: + list of operation IDs. + """ + op_ids = [] + for item in items: + inv_id = item.get('inventory_id') + qty = item.get('quantity', 0) + branch_id = item.get('branch_id') + if not inv_id or qty <= 0: + continue + op_id = record_operation( + conn, inv_id, branch_id, 'QUOTE_RELEASE', + quantity=qty, + reference_id=quotation_id, + reference_type='quotation', + notes=f'Liberacion cotizacion #{quotation_id}' + ) + op_ids.append(op_id) + return op_ids + + +def convert_quotation_reservation(conn, quotation_id, items, sale_id=None, employee_id=None): + """Convert reservation to actual sale. + + Flow: + 1. Release the reservation (QUOTE_RELEASE +qty) + 2. Record the actual sale (SALE -qty) + + Args: + conn: tenant DB connection. + quotation_id: the quotations.id. + items: list of dicts with inventory_id, quantity, branch_id. + sale_id: the resulting sales.id (for reference). + employee_id: optional. + Returns: + list of operation IDs. + """ + op_ids = release_quotation_reservation(conn, quotation_id, items, employee_id) + for item in items: + inv_id = item.get('inventory_id') + qty = item.get('quantity', 0) + branch_id = item.get('branch_id') + if not inv_id or qty <= 0: + continue + op_id = record_operation( + conn, inv_id, branch_id, 'SALE', + quantity=-qty, + reference_id=sale_id or quotation_id, + reference_type='sale' if sale_id else 'quotation', + notes=f'Venta convertida de cotizacion #{quotation_id}' + ) + op_ids.append(op_id) + return op_ids + + +def get_quotation_items_for_reservation(conn, quotation_id): + """Fetch items from a quotation joined with inventory to get branch_id. + + Returns list of dicts: {inventory_id, quantity, branch_id} + """ + cur = conn.cursor() + cur.execute(""" + SELECT qi.inventory_id, qi.quantity, i.branch_id + FROM quotation_items qi + JOIN inventory i ON i.id = qi.inventory_id + WHERE qi.quotation_id = %s + """, (quotation_id,)) + rows = cur.fetchall() + cur.close() + return [ + {'inventory_id': r[0], 'quantity': r[1], 'branch_id': r[2]} + for r in rows + ] diff --git a/pos/services/qwen_fitment.py b/pos/services/qwen_fitment.py index aafb77e..a9f4ebf 100644 --- a/pos/services/qwen_fitment.py +++ b/pos/services/qwen_fitment.py @@ -38,11 +38,11 @@ def get_vehicle_fitment(part_number, name, brand): json={ 'model': QWEN_MODEL, 'messages': [ - {'role': 'system', 'content': 'Eres un experto en autopartes mexicanas. Devuelve SIEMPRE JSON valido sin markdown.'}, + {'role': 'system', 'content': 'Eres un experto en autopartes mexicanas y del mercado aftermarket norteamericano. Devuelve SIEMPRE JSON valido sin markdown.'}, {'role': 'user', 'content': prompt} ], 'temperature': 0.2, - 'max_tokens': 2048, + 'max_tokens': 4096, }, timeout=45, ) @@ -86,29 +86,37 @@ def get_vehicle_fitment(part_number, name, brand): def _build_prompt(part_number, name, brand): brand_str = brand or 'desconocida' - return f"""Dado el siguiente repuesto automotriz: + return f"""Dado el siguiente repuesto automotriz para el mercado mexicano y aftermarket norteamericano: - Numero de parte: {part_number} -- Nombre: {name} -- Marca del vehiculo: {brand_str} +- Nombre/descripcion: {name} +- Marca del fabricante: {brand_str} -Devuelve UNICAMENTE un JSON valido (sin markdown, sin backticks) con esta estructura exacta: +Devuelve UNICAMENTE un JSON valido (sin markdown, sin backticks, sin texto adicional) con esta estructura exacta: {{ "vehicles": [ - {{"make": "Toyota", "model": "Corolla", "year": 2015, "engine": "1.8L 16V"}}, - {{"make": "Toyota", "model": "Matrix", "year": 2014, "engine": "1.8L"}} + {{ + "make": "Toyota", + "model": "Corolla", + "year": 2015, + "engine": "1.8L 16V", + "engine_code": "2ZR-FE", + "notes": "Sedan y hatchback" + }} ], "confidence": 0.92, - "notes": "Compatible con motor 2ZR-FE" + "notes": "Compatible con plataforma E170. Verificar traccion delantera." }} -Reglas: -1. "make" es la marca del vehiculo (ej: Toyota, Nissan, Ford, Volkswagen). -2. "model" es el modelo exacto. -3. "year" es el ano numerico (int). Si hay rango de anos, usa el ano inicial. -4. "engine" es la descripcion del motor (ej: "1.8L", "2.0L TDI", "V6 3.5L"). -5. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 30. -6. Si no conoces el motor exacto, usa "desconocido". -7. confidence entre 0.0 y 1.0. +Reglas obligatorias: +1. "make" = marca del vehiculo (ej: Toyota, Nissan, Ford, Volkswagen, Chevrolet, Honda, Hyundai, Kia, Mazda, Subaru). +2. "model" = modelo exacto. Si hay variantes (ej: Civic Sedan vs Civic Coupe), incluye la variante. +3. "year" = ano numerico (int). Si hay rango de anos (ej: 2003-2008), genera una entrada POR CADA ANO del rango. NO uses rangos. +4. "engine" = descripcion del motor (ej: "1.8L", "2.0L TDI", "V6 3.5L", "1.6L Turbo"). Si no conoces el motor, usa "desconocido". +5. "engine_code" = codigo exacto del motor SI LO CONOCES (ej: "2ZR-FE", "K24Z7", "EA888"). Si no lo conoces, usa "" (string vacio). +6. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 100. Para piezas genericas (bujias, filtros, balatas, amortiguadores) incluye TODOS los modelos aplicables. +7. "confidence" entre 0.0 y 1.0. Usa valores altos (>0.85) solo si estas muy seguro. +8. Incluye marcas y modelos populares en Mexico (Nissan Tsuru, VW Sedan/Vocho, Chevy Monza, Ford Ka, etc.) cuando apliquen. +9. Si la pieza es universal o de alta compatibilidad, indicalo en "notes". """ @@ -150,6 +158,7 @@ def _normalize_vehicle(v): model = v.get('model') or v.get('modelo') or '' year_raw = v.get('year') or v.get('ano') or v.get('año') or v.get('years') or v.get('anos') or '' engine = v.get('engine') or v.get('motor') or '' + engine_code = v.get('engine_code') or v.get('codigo_motor') or v.get('motor_code') or '' # Parse year (may be int, string, or range like "2003-2008") years = [] @@ -167,11 +176,31 @@ def _normalize_vehicle(v): if m2: years = [int(m2.group(1))] - return make, model, years, engine + return make, model, years, engine, engine_code + + +def _extract_displacement(engine): + """Extract numeric displacement (L) from engine string, e.g. '1.8L 16V' -> 1.8.""" + if not engine or engine.lower() == 'desconocido': + return None + # Match patterns like 1.8L, 2.0L, 3.5L, 1.6, etc. + match = re.search(r'(\d+\.?\d*)\s*[Ll]', engine) + if match: + try: + return float(match.group(1)) + except ValueError: + return None + return None def _validate_vehicles(vehicles): - """Look up each vehicle in master DB and enrich with mye_id.""" + """Look up each vehicle in master DB and enrich with mye_id. + + Validation strategy (in order of preference): + 1. Exact engine_code match (most precise) + 2. Displacement-based match (e.g. all 1.8L engines for that make/model/year) + 3. Broad make/model/year match (all engines for that make/model/year) + """ from tenant_db import get_master_conn try: master = get_master_conn() @@ -183,30 +212,66 @@ def _validate_vehicles(vehicles): seen_mye = set() for v in vehicles: - make, model, years, engine = _normalize_vehicle(v) + make, model, years, engine, engine_code = _normalize_vehicle(v) if not make or not model or not years: continue for year in years: - # First try with exact engine match; if no result, fall back to - # make/model/year only. Engine descriptions rarely line up between - # QWEN and the master DB, so the fallback is the common path. - cur.execute(""" - SELECT mye.id_mye - FROM model_year_engine mye - JOIN models m ON mye.model_id = m.id_model - JOIN brands b ON m.brand_id = b.id_brand - JOIN years y ON mye.year_id = y.id_year - JOIN engines e ON mye.engine_id = e.id_engine - WHERE b.name_brand ILIKE %s - AND m.name_model ILIKE %s - AND y.year_car = %s - AND e.name_engine ILIKE %s - LIMIT 1 - """, (make, f'%{model}%', year, engine or '%')) - row = cur.fetchone() + matched_myes = [] - if not row: + # Strategy 1: engine_code match (most precise) + if engine_code: + cur.execute(""" + SELECT mye.id_mye + FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id_model + JOIN brands b ON m.brand_id = b.id_brand + JOIN years y ON mye.year_id = y.id_year + JOIN engines e ON mye.engine_id = e.id_engine + WHERE b.name_brand ILIKE %s + AND m.name_model ILIKE %s + AND y.year_car = %s + AND e.engine_code ILIKE %s + """, (make, f'%{model}%', year, f'%{engine_code}%')) + matched_myes = [r[0] for r in cur.fetchall()] + + # Strategy 2: displacement-based match + if not matched_myes: + disp = _extract_displacement(engine) + if disp is not None: + disp_pattern = f'{disp:.1f}L' + cur.execute(""" + SELECT mye.id_mye + FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id_model + JOIN brands b ON m.brand_id = b.id_brand + JOIN years y ON mye.year_id = y.id_year + JOIN engines e ON mye.engine_id = e.id_engine + WHERE b.name_brand ILIKE %s + AND m.name_model ILIKE %s + AND y.year_car = %s + AND e.name_engine ILIKE %s + """, (make, f'%{model}%', year, f'%{disp_pattern}%')) + matched_myes = [r[0] for r in cur.fetchall()] + + # Strategy 3: exact engine string match (legacy) + if not matched_myes and engine and engine.lower() != 'desconocido': + cur.execute(""" + SELECT mye.id_mye + FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id_model + JOIN brands b ON m.brand_id = b.id_brand + JOIN years y ON mye.year_id = y.id_year + JOIN engines e ON mye.engine_id = e.id_engine + WHERE b.name_brand ILIKE %s + AND m.name_model ILIKE %s + AND y.year_car = %s + AND e.name_engine ILIKE %s + """, (make, f'%{model}%', year, engine)) + matched_myes = [r[0] for r in cur.fetchall()] + + # Strategy 4: broad make/model/year fallback (all engines) + if not matched_myes: cur.execute(""" SELECT mye.id_mye FROM model_year_engine mye @@ -216,19 +281,21 @@ def _validate_vehicles(vehicles): WHERE b.name_brand ILIKE %s AND m.name_model ILIKE %s AND y.year_car = %s - LIMIT 1 """, (make, f'%{model}%', year)) - row = cur.fetchone() + matched_myes = [r[0] for r in cur.fetchall()] - if row and row[0] not in seen_mye: - seen_mye.add(row[0]) - validated.append({ - 'make': make, - 'model': model, - 'year': year, - 'engine': engine, - 'mye_id': row[0], - }) + # Deduplicate and add to results + for mye_id in matched_myes: + if mye_id not in seen_mye: + seen_mye.add(mye_id) + validated.append({ + 'make': make, + 'model': model, + 'year': year, + 'engine': engine, + 'engine_code': engine_code, + 'mye_id': mye_id, + }) cur.close() master.close() diff --git a/pos/services/wa_quotation.py b/pos/services/wa_quotation.py index 10dbc8c..70a0437 100644 --- a/pos/services/wa_quotation.py +++ b/pos/services/wa_quotation.py @@ -109,11 +109,30 @@ def confirm_quotation(tenant_conn, phone): return qid -# ─── In-memory last-shown-part per phone ───────────────────────────── +# ─── Persistent last-shown-part per phone ──────────────────────────── # Tracks what part the bot last showed so "cotizar" knows what to add. -# Key: phone (clean, no @lid). Value: dict with inventory item info. +# Stored in tenant DB table whatsapp_sessions so it survives restarts. -_last_shown = {} +_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): @@ -122,15 +141,110 @@ def set_last_shown_part(phone, part_info): part_info: dict with keys inventory_id, part_number, name, brand, price, stock, unit """ - _last_shown[phone] = part_info + # 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): - return _last_shown.get(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): - _last_shown.pop(phone, None) + 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 ───────────────────────────────────────────────── diff --git a/pos/static/css/whatsapp.css b/pos/static/css/whatsapp.css index faf275a..1e3c0d2 100644 --- a/pos/static/css/whatsapp.css +++ b/pos/static/css/whatsapp.css @@ -217,14 +217,14 @@ /* ─── Right panel: chat view ─────────────────────────────────────── */ - .chat-panel { + .wa-chat-panel { flex: 1; display: flex; flex-direction: column; background: var(--color-bg-base); } - .chat-panel__header { + .wa-chat-panel__header { display: flex; align-items: center; justify-content: space-between; @@ -234,18 +234,18 @@ flex-shrink: 0; } - .chat-panel__phone { + .wa-chat-panel__phone { font-weight: var(--font-weight-semibold); font-size: var(--text-body); color: var(--color-text-primary); } - .chat-panel__actions { + .wa-chat-panel__actions { display: flex; gap: var(--space-2); } - .chat-panel__messages { + .wa-chat-panel__messages { flex: 1; overflow-y: auto; padding: var(--space-4); @@ -500,7 +500,7 @@ @media (max-width: 768px) { .conv-panel { width: 100%; min-width: 0; } - .chat-panel { display: none; } + .wa-chat-panel { display: none; width: 100%; } .messenger.has-active-chat .conv-panel { display: none; } - .messenger.has-active-chat .chat-panel { display: flex; } + .messenger.has-active-chat .wa-chat-panel { display: flex !important; width: 100%; } } diff --git a/pos/static/js/catalog.js b/pos/static/js/catalog.js index 8889a72..c239a27 100644 --- a/pos/static/js/catalog.js +++ b/pos/static/js/catalog.js @@ -1382,19 +1382,28 @@ }); function runSearch(q) { - apiFetch(API + '/search?q=' + encodeURIComponent(q) + '&limit=20').then(function (data) { + var url = API + '/search?q=' + encodeURIComponent(q) + '&limit=20'; + if (nav.engine && nav.engine.id_mye) { + url += '&mye_id=' + nav.engine.id_mye; + } + apiFetch(url).then(function (data) { if (!data || !data.data || !data.data.length) { searchDropdown.innerHTML = '
Sin resultados para "' + esc(q) + '"
'; searchDropdown.classList.add('is-visible'); return; } searchDropdown.innerHTML = data.data.map(function (r) { + var isLocal = r.source === 'local_inventory' || (typeof r.id_part === 'string' && r.id_part.indexOf('inv:') === 0); var stockLabel = r.local_stock > 0 ? 'Stock: ' + r.local_stock + '' : ''; - return '
' + + var localBadge = isLocal + ? 'Stock Local' + : ''; + var oemNum = isLocal ? (r.oem_part_number || r.part_number || '') : (r.oem_part_number || ''); + return '
' + '
' + - '
' + esc(r.oem_part_number) + '
' + + '
' + localBadge + esc(oemNum) + '
' + '
' + esc(r.name) + '
' + (r.vehicle_info ? '
' + esc(r.vehicle_info) + '
' : '') + '
' + @@ -1408,6 +1417,12 @@ searchDropdown.classList.remove('is-visible'); var pid = this.dataset.partId; if (typeof pid === 'string' && pid.indexOf('inv:') === 0) { + var info = '💠 Stock Local\n\n' + + 'Parte: ' + (this.dataset.pn || 'N/A') + '\n' + + 'Nombre: ' + (this.dataset.name || '') + '\n' + + 'Precio: $' + (this.dataset.price || '—') + '\n' + + 'Stock: ' + (this.dataset.stock || 0) + ' pzas'; + alert(info); return; } openPartDetail(parseInt(pid)); diff --git a/pos/static/js/inventory.js b/pos/static/js/inventory.js index 3e04a0c..67b39fc 100644 --- a/pos/static/js/inventory.js +++ b/pos/static/js/inventory.js @@ -14,6 +14,16 @@ var currentSearch = ''; var draftCountId = null; var inventoryVS = null; + var compatSource = 'both'; // default, loaded from config + + // Load compatibility source setting + (function loadCompatSource() { + fetch('/pos/api/config/vehicle-compat-source', { headers: { 'Authorization': 'Bearer ' + token } }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.source) compatSource = d.source; + }).catch(function() {}); + })(); // --- API helper --- function apiFetch(url, opts) { @@ -695,7 +705,9 @@ } else { html2 += '

Sin vehiculos vinculados.

'; } - html2 += '
Busca en catalogo central y vincula automaticamente
'; + var btnLabel = compatSource === 'qwen' ? 'Auto-Match con IA (QWEN)' : (compatSource === 'both' ? 'Auto-Match (TecDoc + IA)' : 'Auto-Match por TecDoc'); + var btnDesc = compatSource === 'qwen' ? 'Busca compatibilidad usando inteligencia artificial' : (compatSource === 'both' ? 'Busca en catalogo central y con IA' : 'Busca en catalogo central y vincula automaticamente'); + html2 += '
' + btnDesc + '
'; el.innerHTML = html2; }) .catch(function() { @@ -735,7 +747,18 @@ headers: { 'Authorization': 'Bearer ' + token } }).then(function(r) { return r.json(); }) .then(function(d) { - alert('Auto-match completado. Vehiculos vinculados: ' + (d.matched || 0)); + var msg = ''; + if (d.tecdoc && d.qwen) { + var t = d.tecdoc.matched ? (d.tecdoc.matched_count || d.tecdoc.matches ? d.tecdoc.matches.length : 0) : 0; + var q = d.qwen.total_qwen || 0; + var qi = d.qwen.inserted || 0; + msg = 'Auto-match completado.\nTecDoc: ' + t + ' vehiculos.\nIA QWEN: ' + qi + ' nuevos vinculados (de ' + q + ' encontrados).'; + } else if (d.myes) { + msg = 'Auto-match completado. Vehiculos encontrados: ' + (d.total_qwen || d.myes.length) + ' (nuevos vinculados: ' + (d.inserted || 0) + ')'; + } else { + msg = 'Auto-match completado. Vehiculos vinculados: ' + (d.matched ? 'Si' : 'No'); + } + alert(msg); viewProductDetail(itemId); }).catch(function() { alert('Error en auto-match'); }); } diff --git a/pos/static/js/pos.js b/pos/static/js/pos.js index f6246b8..0a0ee77 100644 --- a/pos/static/js/pos.js +++ b/pos/static/js/pos.js @@ -82,8 +82,10 @@ const POS = (() => { console.warn('Could not parse token:', e); } - // Load cart from localStorage (from catalog) + // Load cart from localStorage (from catalog or quotation edit/convert) const catalogCart = localStorage.getItem('pos_cart'); + const editQuoteId = localStorage.getItem('pos_edit_quote_id'); + const convertQuoteId = localStorage.getItem('pos_convert_quote_id'); if (catalogCart) { try { const items = JSON.parse(catalogCart); @@ -93,6 +95,12 @@ const POS = (() => { localStorage.removeItem('pos_cart'); } catch (e) { console.warn('Could not load catalog cart:', e); } } + if (editQuoteId) { + showToast(`Modo edicion: Cotizacion #${editQuoteId}. Guardar actualizara la cotizacion.`); + } + if (convertQuoteId) { + showToast(`Modo conversion: Cotizacion #${convertQuoteId}. El pago convertira la cotizacion en venta.`); + } // Load current register await loadRegister(); @@ -702,10 +710,29 @@ const POS = (() => { confirmBtn.textContent = 'Procesando...'; try { - const sale = await api('/pos/api/sales', { - method: 'POST', - body: JSON.stringify(saleData), - }); + const convertQuoteId = localStorage.getItem('pos_convert_quote_id'); + let sale; + if (convertQuoteId) { + const convertData = { + register_id: currentRegister ? currentRegister.id : null, + payment_method: paymentMethod, + sale_type: 'cash', + amount_paid: amountPaid, + payment_details: paymentDetails, + }; + sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', { + method: 'POST', + body: JSON.stringify(convertData), + }); + localStorage.removeItem('pos_convert_quote_id'); + showToast(`Cotizacion #${convertQuoteId} convertida a venta #${sale.id}`); + } else { + sale = await api('/pos/api/sales', { + method: 'POST', + body: JSON.stringify(saleData), + }); + showToast(`Venta #${sale.id} completada`); + } lastSaleId = sale.id; lastSaleData = sale; @@ -717,8 +744,6 @@ const POS = (() => { selectedRow = -1; clearCustomer(); renderCart(); - - showToast(`Venta #${sale.id} completada`); } catch (e) { alert('Error al procesar venta: ' + e.message); } finally { @@ -790,12 +815,25 @@ const POS = (() => { customer_id: currentCustomer ? currentCustomer.id : null, }; + const editQuoteId = localStorage.getItem('pos_edit_quote_id'); + try { - const result = await api('/pos/api/quotations', { - method: 'POST', - body: JSON.stringify(body), - }); - showToast(`Cotizacion #${result.id} guardada. Total: ${fmt(result.total)}`); + if (editQuoteId) { + const result = await api('/pos/api/quotations/' + editQuoteId, { + method: 'PUT', + body: JSON.stringify(body), + }); + localStorage.removeItem('pos_edit_quote_id'); + localStorage.removeItem('pos_edit_quote_customer_id'); + localStorage.removeItem('pos_edit_quote_notes'); + showToast(`Cotizacion #${editQuoteId} actualizada. Total: ${fmt(result.total)}`); + } else { + const result = await api('/pos/api/quotations', { + method: 'POST', + body: JSON.stringify(body), + }); + showToast(`Cotizacion #${result.id} guardada. Total: ${fmt(result.total)}`); + } } catch (e) { alert('Error: ' + e.message); } diff --git a/pos/static/js/whatsapp.js b/pos/static/js/whatsapp.js index 6b028a5..8a7608f 100644 --- a/pos/static/js/whatsapp.js +++ b/pos/static/js/whatsapp.js @@ -65,13 +65,13 @@ // -- DOM refs -------------------------------------------------------------- var convList = document.getElementById('convList'); - var chatMessages = document.getElementById('chatMessages'); + var chatMessages = document.getElementById('waChatMessages') || document.getElementById('chatMessages'); var chatHeader = document.getElementById('chatHeaderPhone'); - var chatInput = document.getElementById('chatInput'); - var sendBtn = document.getElementById('sendBtn'); + var chatInput = document.getElementById('waChatInput') || document.getElementById('chatInput'); + var sendBtn = document.getElementById('waSendBtn') || document.getElementById('sendBtn'); var newChatBtn = document.getElementById('newChatBtn'); var emptyState = document.getElementById('emptyState'); - var chatPanel = document.getElementById('chatPanel'); + var chatPanel = document.getElementById('waChatPanel') || document.getElementById('chatPanel'); var statusDot = document.getElementById('statusDot'); var statusText = document.getElementById('statusText'); var connectSection = document.getElementById('connectSection'); @@ -275,6 +275,7 @@ activePhone = null; chatPanel.style.display = 'none'; emptyState.style.display = ''; + if (messengerArea) messengerArea.classList.remove('has-active-chat'); } loadConversations(); } else { @@ -300,42 +301,65 @@ var activeContactName = ''; function openConversation(phone, contactName) { - activePhone = phone; - // Use contact name if available; fall back to formatted phone - var isLid = phone.length > 13 || !/^(52|1|44|34)/.test(phone); - activeContactName = contactName || ''; - chatHeader.textContent = activeContactName || (isLid ? 'Contacto WhatsApp' : fmtPhone(phone)); - emptyState.style.display = 'none'; - chatPanel.style.display = 'flex'; + try { + console.log('[WA-UI] Opening conversation:', phone, contactName); + activePhone = phone; + // Use contact name if available; fall back to formatted phone + var isLid = phone.length > 13 || !/^(52|1|44|34)/.test(phone); + activeContactName = contactName || ''; + chatHeader.textContent = activeContactName || (isLid ? 'Contacto WhatsApp' : fmtPhone(phone)); + emptyState.style.display = 'none'; + chatPanel.style.display = 'flex'; + console.log('[WA-UI] chatPanel display set to flex. chatPanel element:', chatPanel ? 'exists' : 'null'); + // Add has-active-chat class for mobile responsive layout + if (messengerArea) messengerArea.classList.add('has-active-chat'); - convList.querySelectorAll('.conv-item').forEach(function (el) { - el.classList.toggle('is-active', el.getAttribute('data-phone') === phone); - }); + convList.querySelectorAll('.conv-item').forEach(function (el) { + el.classList.toggle('is-active', el.getAttribute('data-phone') === phone); + }); - loadMessages(phone); - startPolling(); + loadMessages(phone); + startPolling(); + } catch (e) { + console.error('[WA-UI] openConversation error:', e); + } } function loadMessages(phone) { + console.log('[WA-UI] loadMessages start:', phone); api('GET', '/conversations/' + encodeURIComponent(phone)).then(function (data) { + console.log('[WA-UI] loadMessages response:', data); + if (data.error) { + console.error('[WA-UI] loadMessages error:', data.error); + chatMessages.innerHTML = '
Error cargando mensajes: ' + escHtml(data.error) + '
'; + return; + } var msgs = data.messages || []; + console.log('[WA-UI] loadMessages messages count:', msgs.length); renderMessages(msgs); + }).catch(function (err) { + console.error('[WA-UI] loadMessages network error:', err); + chatMessages.innerHTML = '
Error de red al cargar mensajes
'; }); } function renderMessages(msgs) { + console.log('[WA-UI] renderMessages called with', msgs.length, 'messages'); + if (!chatMessages) { + console.error('[WA-UI] chatMessages element is null!'); + return; + } var html = ''; msgs.forEach(function (m) { var cls = m.direction === 'outgoing' ? 'msg-bubble--out' : 'msg-bubble--in'; - // Support both 'text' and 'message_text' keys (backend changed) var text = m.message_text || m.text || ''; - // Support both 'created_at' and 'date' keys var time = m.created_at || m.date || ''; html += '
' + '
' + escHtml(text).replace(/\n/g, '
') + '
' + '
' + fmtTime(time) + '
' + '
'; }); + console.log('[WA-UI] renderMessages HTML length:', html.length); chatMessages.innerHTML = html || '
Sin mensajes
'; chatMessages.scrollTop = chatMessages.scrollHeight; } diff --git a/pos/static/js/whatsapp2.js b/pos/static/js/whatsapp2.js new file mode 100644 index 0000000..8a7608f --- /dev/null +++ b/pos/static/js/whatsapp2.js @@ -0,0 +1,489 @@ +/** + * whatsapp.js — WhatsApp via Evolution API + * + * Connection flow: Create instance -> Scan QR -> Connected + * Left panel: conversation list (phone numbers + last message preview) + * Right panel: chat view with message bubbles + * Bottom: text input + send button + */ +(function () { + 'use strict'; + + var token = localStorage.getItem('pos_token'); + if (!token) { window.location.href = '/pos/login'; return; } + + var API = '/pos/api/whatsapp'; + var activePhone = null; + var pollTimer = null; + var statusPollTimer = null; + var connectionState = 'unknown'; // 'open', 'close', 'connecting', 'unknown' + + // -- Helpers --------------------------------------------------------------- + + function authHeaders() { + return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }; + } + + function api(method, path, body) { + var opts = { method: method, headers: authHeaders() }; + if (body) opts.body = JSON.stringify(body); + return fetch(API + path, opts).then(function (r) { + if (r.status === 401) { window.location.href = '/pos/login'; } + return r.json(); + }); + } + + function escHtml(s) { + var d = document.createElement('div'); + d.textContent = s || ''; + return d.innerHTML; + } + + function fmtTime(iso) { + if (!iso) return ''; + var d = new Date(iso); + var now = new Date(); + var isToday = d.toDateString() === now.toDateString(); + if (isToday) { + return d.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }); + } + return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' }) + + ' ' + d.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }); + } + + function fmtPhone(phone) { + if (!phone) return ''; + if (phone.length === 13 && phone.startsWith('521')) { + return '+52 1 ' + phone.slice(3, 5) + ' ' + phone.slice(5, 9) + ' ' + phone.slice(9); + } + if (phone.length === 12 && phone.startsWith('52')) { + return '+52 ' + phone.slice(2, 4) + ' ' + phone.slice(4, 8) + ' ' + phone.slice(8); + } + return '+' + phone; + } + + // -- DOM refs -------------------------------------------------------------- + + var convList = document.getElementById('convList'); + var chatMessages = document.getElementById('waChatMessages') || document.getElementById('chatMessages'); + var chatHeader = document.getElementById('chatHeaderPhone'); + var chatInput = document.getElementById('waChatInput') || document.getElementById('chatInput'); + var sendBtn = document.getElementById('waSendBtn') || document.getElementById('sendBtn'); + var newChatBtn = document.getElementById('newChatBtn'); + var emptyState = document.getElementById('emptyState'); + var chatPanel = document.getElementById('waChatPanel') || document.getElementById('chatPanel'); + var statusDot = document.getElementById('statusDot'); + var statusText = document.getElementById('statusText'); + var connectSection = document.getElementById('connectSection'); + var messengerArea = document.getElementById('messengerArea'); + var qrImg = document.getElementById('qrImg'); + var qrPlaceholder = document.getElementById('qrPlaceholder'); + var connectBtn = document.getElementById('connectBtn'); + var disconnectBtn = document.getElementById('disconnectBtn'); + var refreshQrBtn = document.getElementById('refreshQrBtn'); + + // -- Connection management ------------------------------------------------- + + function checkInstanceStatus() { + api('GET', '/status').then(function (data) { + var state = (data.instance || data).state || data.state || 'close'; + updateConnectionUI(state); + }).catch(function () { + updateConnectionUI('close'); + }); + } + + function updateConnectionUI(state) { + connectionState = state; + + if (state === 'open') { + statusDot.className = 'status-dot status-dot--ok'; + statusText.textContent = 'Conectado'; + connectSection.style.display = 'none'; + messengerArea.style.display = 'flex'; + disconnectBtn.style.display = ''; + connectBtn.style.display = 'none'; + // Load conversations + start polling on page load / reconnect + loadConversations(); + startPolling(); + } else if (state === 'connecting') { + statusDot.className = 'status-dot status-dot--warn'; + statusText.textContent = 'Escaneando QR...'; + connectSection.style.display = 'flex'; + messengerArea.style.display = 'none'; + disconnectBtn.style.display = 'none'; + connectBtn.style.display = 'none'; + refreshQrBtn.style.display = ''; + } else { + // close or unknown + statusDot.className = 'status-dot status-dot--error'; + statusText.textContent = 'Desconectado'; + connectSection.style.display = 'flex'; + messengerArea.style.display = 'none'; + disconnectBtn.style.display = 'none'; + connectBtn.style.display = ''; + refreshQrBtn.style.display = 'none'; + qrImg.style.display = 'none'; + qrPlaceholder.style.display = ''; + } + } + + function doConnect() { + connectBtn.disabled = true; + connectBtn.textContent = 'Creando instancia...'; + + api('POST', '/connect').then(function (data) { + connectBtn.disabled = false; + connectBtn.textContent = 'Conectar WhatsApp'; + + if (data.error) { + alert('Error: ' + (data.error.message || data.error)); + return; + } + + // Instance created, now fetch QR + fetchQR(); + }).catch(function () { + connectBtn.disabled = false; + connectBtn.textContent = 'Conectar WhatsApp'; + alert('Error de red al crear instancia'); + }); + } + + function fetchQR() { + qrPlaceholder.textContent = 'Generando QR...'; + + api('GET', '/qr').then(function (data) { + var base64 = data.qr || data.base64 || data.qrcode || ''; + if (base64) { + qrImg.src = base64.startsWith('data:') ? base64 : 'data:image/png;base64,' + base64; + qrImg.style.display = 'block'; + qrPlaceholder.style.display = 'none'; + refreshQrBtn.style.display = ''; + updateConnectionUI('connecting'); + + // Start polling for connection state while QR is shown + startStatusPolling(); + } else if ((data.instance && data.instance.state === 'open') || data.state === 'open') { + // Already connected + updateConnectionUI('open'); + loadConversations(); + } else { + qrPlaceholder.textContent = 'No se pudo generar el QR. Intenta de nuevo.'; + qrPlaceholder.style.display = ''; + qrImg.style.display = 'none'; + } + }).catch(function () { + qrPlaceholder.textContent = 'Error al obtener QR'; + }); + } + + function doDisconnect() { + if (!confirm('Desconectar WhatsApp?')) return; + api('POST', '/logout').then(function () { + updateConnectionUI('close'); + stopStatusPolling(); + }); + } + + function startStatusPolling() { + stopStatusPolling(); + statusPollTimer = setInterval(function () { + api('GET', '/status').then(function (data) { + var state = (data.instance || data).state || data.state || 'close'; + if (state === 'open') { + updateConnectionUI('open'); + stopStatusPolling(); + loadConversations(); + startPolling(); + } + }); + }, 3000); + } + + function stopStatusPolling() { + if (statusPollTimer) { + clearInterval(statusPollTimer); + statusPollTimer = null; + } + } + + connectBtn.addEventListener('click', doConnect); + disconnectBtn.addEventListener('click', doDisconnect); + refreshQrBtn.addEventListener('click', fetchQR); + + // -- Load conversations ---------------------------------------------------- + + function loadConversations() { + api('GET', '/conversations').then(function (data) { + var convs = data.conversations || []; + if (convs.length === 0) { + convList.innerHTML = '
No hay conversaciones
'; + return; + } + var html = ''; + convs.forEach(function (c) { + var isActive = c.phone === activePhone; + var dirIcon = c.last_direction === 'outgoing' ? '↗ ' : '↙ '; + // Show contact name if available, otherwise try to format the phone. + // LID numbers (15+ digits, no country code pattern) show as "Contacto" + var displayName = c.contact_name || ''; + if (!displayName) { + var isLid = c.phone.length > 13 || !/^(52|1|44|34)/.test(c.phone); + displayName = isLid ? 'Contacto WhatsApp' : fmtPhone(c.phone); + } + html += '
' + + '
' + escHtml(displayName) + '
' + + '
' + dirIcon + escHtml(c.last_message || '(sin texto)') + '
' + + '
' + fmtTime(c.last_at) + '
' + + '' + + '
'; + }); + // "Borrar todo" button at the bottom + html += '
' + + '' + + '
'; + convList.innerHTML = html; + + convList.querySelectorAll('.conv-item').forEach(function (el) { + el.addEventListener('click', function (e) { + if (e.target.classList.contains('conv-item__delete')) return; + var name = el.querySelector('.conv-item__phone') ? el.querySelector('.conv-item__phone').textContent : ''; + openConversation(el.getAttribute('data-phone'), name); + }); + }); + + // Wire delete buttons + convList.querySelectorAll('.conv-item__delete').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.stopPropagation(); + var phone = btn.getAttribute('data-del-phone'); + if (confirm('Borrar conversacion con ' + fmtPhone(phone) + '?')) { + deleteConversation(phone); + } + }); + }); + }).catch(function () { + convList.innerHTML = '
Error cargando conversaciones
'; + }); + } + + function deleteConversation(phone) { + api('DELETE', '/conversations/' + encodeURIComponent(phone)).then(function (res) { + if (res.ok) { + if (activePhone === phone) { + activePhone = null; + chatPanel.style.display = 'none'; + emptyState.style.display = ''; + if (messengerArea) messengerArea.classList.remove('has-active-chat'); + } + loadConversations(); + } else { + alert('Error: ' + (res.error || 'unknown')); + } + }); + } + + window.deleteAllConversations = function () { + if (!confirm('Borrar TODAS las conversaciones? Esta accion no se puede deshacer.')) return; + api('DELETE', '/conversations').then(function (res) { + if (res.ok) { + activePhone = null; + chatPanel.style.display = 'none'; + emptyState.style.display = ''; + loadConversations(); + } + }); + }; + + // -- Open a conversation --------------------------------------------------- + + var activeContactName = ''; + + function openConversation(phone, contactName) { + try { + console.log('[WA-UI] Opening conversation:', phone, contactName); + activePhone = phone; + // Use contact name if available; fall back to formatted phone + var isLid = phone.length > 13 || !/^(52|1|44|34)/.test(phone); + activeContactName = contactName || ''; + chatHeader.textContent = activeContactName || (isLid ? 'Contacto WhatsApp' : fmtPhone(phone)); + emptyState.style.display = 'none'; + chatPanel.style.display = 'flex'; + console.log('[WA-UI] chatPanel display set to flex. chatPanel element:', chatPanel ? 'exists' : 'null'); + // Add has-active-chat class for mobile responsive layout + if (messengerArea) messengerArea.classList.add('has-active-chat'); + + convList.querySelectorAll('.conv-item').forEach(function (el) { + el.classList.toggle('is-active', el.getAttribute('data-phone') === phone); + }); + + loadMessages(phone); + startPolling(); + } catch (e) { + console.error('[WA-UI] openConversation error:', e); + } + } + + function loadMessages(phone) { + console.log('[WA-UI] loadMessages start:', phone); + api('GET', '/conversations/' + encodeURIComponent(phone)).then(function (data) { + console.log('[WA-UI] loadMessages response:', data); + if (data.error) { + console.error('[WA-UI] loadMessages error:', data.error); + chatMessages.innerHTML = '
Error cargando mensajes: ' + escHtml(data.error) + '
'; + return; + } + var msgs = data.messages || []; + console.log('[WA-UI] loadMessages messages count:', msgs.length); + renderMessages(msgs); + }).catch(function (err) { + console.error('[WA-UI] loadMessages network error:', err); + chatMessages.innerHTML = '
Error de red al cargar mensajes
'; + }); + } + + function renderMessages(msgs) { + console.log('[WA-UI] renderMessages called with', msgs.length, 'messages'); + if (!chatMessages) { + console.error('[WA-UI] chatMessages element is null!'); + return; + } + var html = ''; + msgs.forEach(function (m) { + var cls = m.direction === 'outgoing' ? 'msg-bubble--out' : 'msg-bubble--in'; + var text = m.message_text || m.text || ''; + var time = m.created_at || m.date || ''; + html += '
' + + '
' + escHtml(text).replace(/\n/g, '
') + '
' + + '
' + fmtTime(time) + '
' + + '
'; + }); + console.log('[WA-UI] renderMessages HTML length:', html.length); + chatMessages.innerHTML = html || '
Sin mensajes
'; + chatMessages.scrollTop = chatMessages.scrollHeight; + } + + // -- Send message ---------------------------------------------------------- + + function doSend() { + var text = chatInput.value.trim(); + if (!text || !activePhone) return; + + chatInput.value = ''; + sendBtn.disabled = true; + + api('POST', '/send', { phone: activePhone, message: text }).then(function (res) { + sendBtn.disabled = false; + if (res.error) { + alert('Error: ' + res.error); + } else { + loadMessages(activePhone); + loadConversations(); + } + }).catch(function () { + sendBtn.disabled = false; + alert('Error de red al enviar mensaje'); + }); + } + + sendBtn.addEventListener('click', doSend); + chatInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + doSend(); + } + }); + + // -- New conversation ------------------------------------------------------ + + newChatBtn.addEventListener('click', function () { + var phone = prompt('Numero de telefono (formato: 5215512345678):'); + if (phone) { + phone = phone.replace(/[\s\-\+\(\)]/g, ''); + openConversation(phone); + loadConversations(); + } + }); + + // -- Send quotation -------------------------------------------------------- + + var quoteBtn = document.getElementById('sendQuoteBtn'); + if (quoteBtn) { + quoteBtn.addEventListener('click', function () { + if (!activePhone) { alert('Selecciona una conversacion primero'); return; } + + // Fetch available quotations and let user pick one + fetch('/pos/api/quotations?per_page=20', { headers: authHeaders() }) + .then(function (r) { return r.json(); }) + .then(function (d) { + var quotes = (d.data || []).filter(function (q) { return q.status === 'active'; }); + if (quotes.length === 0) { + alert('No hay cotizaciones activas. Crea una desde el POS (F4) o via WhatsApp.'); + return; + } + var msg = 'Cotizaciones activas:\n'; + quotes.forEach(function (q) { + msg += '#' + q.id + ' — $' + q.total.toFixed(2) + ' (' + (q.customer_name || q.source || 'sin cliente') + ')\n'; + }); + var quoteId = prompt(msg + '\nEscribe el ID de la cotizacion a enviar:'); + if (!quoteId) return; + + // Fetch the quotation detail and send it formatted + fetch('/pos/api/quotations/' + quoteId, { headers: authHeaders() }) + .then(function (r) { return r.json(); }) + .then(function (q) { + if (q.error) { alert('Error: ' + q.error); return; } + // Format the quotation as a WhatsApp message + var lines = ['📄 *COTIZACIÓN #' + q.id + '*', '']; + (q.items || []).forEach(function (it, i) { + lines.push((i + 1) + '. ' + it.name); + lines.push(' #' + it.part_number + ' × ' + it.quantity + ' = $' + it.subtotal.toFixed(2)); + }); + lines.push('─────────────'); + lines.push('Subtotal: $' + q.subtotal.toFixed(2)); + lines.push('IVA: $' + q.tax_total.toFixed(2)); + lines.push('*TOTAL: $' + q.total.toFixed(2) + '*'); + + var text = lines.join('\n'); + api('POST', '/send', { phone: activePhone, message: text }).then(function (res) { + if (res.error) { + alert('Error enviando: ' + res.error); + } else { + loadMessages(activePhone); + loadConversations(); + } + }); + }); + }); + }); + } + + // -- Polling for new messages ---------------------------------------------- + + function startPolling() { + if (pollTimer) clearInterval(pollTimer); + pollTimer = setInterval(function () { + if (activePhone) loadMessages(activePhone); + loadConversations(); + }, 10000); + } + + // -- Init ------------------------------------------------------------------ + + checkInstanceStatus(); + + // Also check periodically (every 30s) in case connection drops + setInterval(checkInstanceStatus, 30000); + + // -- User info for sidebar ------------------------------------------------- + try { + var payload = JSON.parse(atob(token.split('.')[1])); + window.POS_USER = { + name: payload.name || 'Usuario', + roleLabel: (payload.role || '').charAt(0).toUpperCase() + (payload.role || '').slice(1), + initials: (payload.name || 'U').split(' ').map(function(w){return w[0]}).join('').slice(0,2).toUpperCase() + }; + } catch(e) {} + +})(); diff --git a/pos/static/pwa/sw.js b/pos/static/pwa/sw.js index 744a867..9fff508 100644 --- a/pos/static/pwa/sw.js +++ b/pos/static/pwa/sw.js @@ -2,7 +2,7 @@ // Nexus POS — Service Worker v3 // Self-contained vanilla JS. No external imports. -const CACHE_NAME = 'nexus-pos-v3'; +const CACHE_NAME = 'nexus-pos-v4'; const APP_SHELL = [ '/pos/login', @@ -176,7 +176,11 @@ self.addEventListener('fetch', function (event) { return; } - // API calls → network-first + // API calls → network-first (except WhatsApp which must be real-time) + if (url.pathname.indexOf('/pos/api/whatsapp/') !== -1) { + // WhatsApp endpoints need fresh server data; skip SW caching + return; + } if (url.pathname.indexOf('/pos/api/') !== -1) { event.respondWith(networkFirst(req)); return; diff --git a/pos/templates/catalog.html b/pos/templates/catalog.html index b17e88e..721435f 100644 --- a/pos/templates/catalog.html +++ b/pos/templates/catalog.html @@ -273,7 +273,7 @@ - + diff --git a/pos/templates/inventory.html b/pos/templates/inventory.html index 8d41e19..9b06d3e 100644 --- a/pos/templates/inventory.html +++ b/pos/templates/inventory.html @@ -815,7 +815,7 @@ - + diff --git a/pos/templates/quotations.html b/pos/templates/quotations.html index bba0c86..a831058 100644 --- a/pos/templates/quotations.html +++ b/pos/templates/quotations.html @@ -114,7 +114,12 @@ html += '
Total: $' + fmt(q.total) + '
'; html += '
'; - html += '
'; + html += '
'; + if (q.status === 'active') { + html += ''; + html += ''; + html += ''; + } html += ''; html += ''; html += ''; @@ -140,6 +145,68 @@ }); }; + window.editQuote = function(id) { + fetch(API + '/quotations/' + id, { headers: headers() }) + .then(function(r) { return r.json(); }) + .then(function(q) { + if (!q.items) { alert('Error cargando cotización'); return; } + var cartItems = q.items.map(function(it) { + return { + inventory_id: it.inventory_id, + part_number: it.part_number, + name: it.name, + quantity: it.quantity, + unit_price: it.unit_price, + discount_pct: it.discount_pct, + tax_rate: it.tax_rate + }; + }); + localStorage.setItem('pos_edit_quote_id', id); + localStorage.setItem('pos_edit_quote_customer_id', q.customer_id || ''); + localStorage.setItem('pos_edit_quote_notes', q.notes || ''); + localStorage.setItem('pos_cart', JSON.stringify(cartItems)); + window.location.href = '/pos'; + }); + }; + + window.convertQuote = function(id) { + fetch(API + '/quotations/' + id, { headers: headers() }) + .then(function(r) { return r.json(); }) + .then(function(q) { + if (!q.items) { alert('Error cargando cotización'); return; } + var cartItems = q.items.map(function(it) { + return { + inventory_id: it.inventory_id, + part_number: it.part_number, + name: it.name, + quantity: it.quantity, + unit_price: it.unit_price, + discount_pct: it.discount_pct, + tax_rate: it.tax_rate + }; + }); + localStorage.setItem('pos_convert_quote_id', id); + localStorage.setItem('pos_cart', JSON.stringify(cartItems)); + window.location.href = '/pos'; + }); + }; + + window.shareQuote = function(id) { + fetch(API + '/quotations/' + id + '/share', { method: 'POST', headers: headers() }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.url) { + navigator.clipboard.writeText(d.url).then(function() { + alert('Link copiado al portapapeles:\n' + d.url); + }).catch(function() { + prompt('Copia este link:', d.url); + }); + } else { + alert('Error: ' + (d.error || 'desconocido')); + } + }); + }; + // Close modal on outside click document.getElementById('quoteModal').addEventListener('click', function(e) { if (e.target === this) this.classList.remove('open'); diff --git a/pos/templates/whatsapp.html b/pos/templates/whatsapp.html index 2bc7120..8173a2d 100644 --- a/pos/templates/whatsapp.html +++ b/pos/templates/whatsapp.html @@ -4,6 +4,9 @@ + + + WhatsApp — Nexus Autoparts POS @@ -89,10 +92,11 @@
Los mensajes de WhatsApp aparecen aqui en tiempo real
-