feat(whatsapp): QWEN primary AI backend, Hermes fallback, conversation history, vehicle persistence, demo prompts
- Add QWEN (qwen3.6) as primary AI backend with short system prompt - Hermes remains as fallback with 45s timeout - Increase QWEN timeout to 35s, max_tokens to 4000 - Add conversation history loading from whatsapp_messages (last 4 msgs) - Persist detected vehicle in whatsapp_sessions table - Add 'limpiar chat' / 'nuevo chat' / 'reset' commands to clear history - Fix CSS conflict: rename whatsapp chat-panel classes to wa-chat-panel - Fix JS ID conflicts with chat.js widget (waChatPanel, waChatMessages, etc.) - Improve no-stock response: conversational with alternatives - Split search_query by | for multi-part lookups - Add DEMO_PROMPTS.md and DEMO_PROMPTS_V2.md
This commit is contained in:
181
DEMO_PROMPTS.md
Normal file
181
DEMO_PROMPTS.md
Normal file
@@ -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
|
||||
198
DEMO_PROMPTS_V2.md
Normal file
198
DEMO_PROMPTS_V2.md
Normal file
@@ -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
|
||||
@@ -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 / {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -356,8 +356,9 @@ def search():
|
||||
if not q or len(q) < 2:
|
||||
return jsonify({'data': []})
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
def _do(master, tenant, branch_id):
|
||||
data = catalog_service.smart_search(master, q, tenant, branch_id, limit)
|
||||
data = catalog_service.smart_search(master, q, tenant, branch_id, limit, mye_id)
|
||||
return jsonify({'data': data})
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@@ -1360,36 +1360,55 @@ def auto_match_item_vehicles(item_id):
|
||||
part_number, brand, name = row
|
||||
compat_source = get_compat_source(g.tenant_id)
|
||||
|
||||
tecdoc_result = None
|
||||
qwen_result = None
|
||||
|
||||
# TecDoc auto-match
|
||||
if compat_source in ('tecdoc', 'both'):
|
||||
master = get_master_conn()
|
||||
try:
|
||||
result = auto_match_vehicle_compatibility(master, conn, item_id, part_number,
|
||||
brand=brand, name=name)
|
||||
return jsonify(result)
|
||||
tecdoc_result = auto_match_vehicle_compatibility(master, conn, item_id, part_number,
|
||||
brand=brand, name=name)
|
||||
finally:
|
||||
master.close()
|
||||
conn.close()
|
||||
|
||||
# QWEN AI auto-match
|
||||
if compat_source == 'qwen':
|
||||
if compat_source in ('qwen', 'both'):
|
||||
try:
|
||||
from services.qwen_fitment import get_vehicle_fitment
|
||||
from services.inventory_vehicle_compat import save_qwen_fitment
|
||||
fitment = get_vehicle_fitment(part_number, name, brand)
|
||||
inserted = save_qwen_fitment(conn, item_id, fitment)
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'matched': inserted > 0,
|
||||
qwen_myes = [v['mye_id'] for v in fitment.get('vehicles', []) if v.get('mye_id')]
|
||||
qwen_result = {
|
||||
'matched': len(qwen_myes) > 0,
|
||||
'matches': [],
|
||||
'myes': [v['mye_id'] for v in fitment.get('vehicles', []) if v.get('mye_id')],
|
||||
'myes': qwen_myes,
|
||||
'inserted': inserted,
|
||||
})
|
||||
'total_qwen': len(qwen_myes),
|
||||
'confidence': fitment.get('confidence', 0),
|
||||
'notes': fitment.get('notes', ''),
|
||||
}
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
qwen_result = {'error': str(e)}
|
||||
|
||||
conn.close()
|
||||
|
||||
# Return combined or single-source result
|
||||
if compat_source == 'both':
|
||||
return jsonify({
|
||||
'tecdoc': tecdoc_result,
|
||||
'qwen': qwen_result,
|
||||
'matched': bool(
|
||||
(tecdoc_result and tecdoc_result.get('matched'))
|
||||
or (qwen_result and qwen_result.get('matched'))
|
||||
),
|
||||
})
|
||||
if compat_source == 'tecdoc':
|
||||
return jsonify(tecdoc_result)
|
||||
if compat_source == 'qwen':
|
||||
return jsonify(qwen_result)
|
||||
|
||||
return jsonify({'error': 'No compatibility source configured'}), 400
|
||||
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ that validates input, calls the engine, and returns JSON responses.
|
||||
"""
|
||||
|
||||
import json
|
||||
import jwt
|
||||
from datetime import datetime, date, timedelta
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask import Blueprint, request, jsonify, g, render_template_string
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.pos_engine import (
|
||||
@@ -15,6 +16,7 @@ from services.pos_engine import (
|
||||
get_price_for_customer, get_margin_info
|
||||
)
|
||||
from services.audit import log_action
|
||||
from config import JWT_SECRET
|
||||
|
||||
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
||||
|
||||
@@ -485,6 +487,16 @@ def create_quotation():
|
||||
currency, exchange_rate
|
||||
))
|
||||
|
||||
# Reserve stock for quotation
|
||||
from services.quote_reservation import reserve_for_quotation, get_quotation_items_for_reservation
|
||||
try:
|
||||
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
reserve_for_quotation(conn, quot_id, reservation_items, employee_id=g.employee_id)
|
||||
except Exception as res_err:
|
||||
# Log but don't fail the quote creation
|
||||
import logging
|
||||
logging.getLogger('pos').warning(f'Quote reservation failed for #{quot_id}: {res_err}')
|
||||
|
||||
log_action(conn, 'QUOTATION_CREATE', 'quotation', quot_id,
|
||||
new_value={'total': totals['total'], 'items_count': len(items)})
|
||||
|
||||
@@ -766,6 +778,270 @@ def get_quotation(quot_id):
|
||||
return jsonify(quot)
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>', methods=['PUT'])
|
||||
@require_auth('pos.sell')
|
||||
def update_quotation(quot_id):
|
||||
"""Replace all items in an existing active quotation.
|
||||
|
||||
Body: { items: [...], customer_id, notes, valid_days, currency, exchange_rate }
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
items = data.get('items', [])
|
||||
if not items:
|
||||
return jsonify({'error': 'No items provided'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT id, status FROM quotations WHERE id = %s", (quot_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
if row[1] != 'active':
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Quotation is {row[1]}, cannot edit'}), 400
|
||||
|
||||
try:
|
||||
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
||||
except ValueError as e:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
totals = calculate_totals(enriched)
|
||||
valid_days = int(data.get('valid_days', 7))
|
||||
valid_until = (date.today() + timedelta(days=valid_days)).isoformat()
|
||||
|
||||
from services.currency import get_exchange_rate
|
||||
currency = data.get('currency', 'MXN')
|
||||
if currency not in ('MXN', 'USD'):
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Unsupported currency: {currency}'}), 400
|
||||
exchange_rate = data.get('exchange_rate')
|
||||
if currency != 'MXN' and exchange_rate is None:
|
||||
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
|
||||
exchange_rate = float(exchange_rate) if exchange_rate else 1.0
|
||||
|
||||
try:
|
||||
# Release old reservations before deleting items
|
||||
from services.quote_reservation import (
|
||||
release_quotation_reservation,
|
||||
reserve_for_quotation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
old_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if old_items:
|
||||
release_quotation_reservation(conn, quot_id, old_items, employee_id=g.employee_id)
|
||||
|
||||
# Delete old items
|
||||
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
|
||||
|
||||
# Update header
|
||||
cur.execute("""
|
||||
UPDATE quotations
|
||||
SET customer_id = %s, subtotal = %s, tax_total = %s, total = %s,
|
||||
valid_until = %s, notes = %s, currency = %s, exchange_rate = %s,
|
||||
employee_id = %s
|
||||
WHERE id = %s
|
||||
""", (
|
||||
data.get('customer_id'), totals['subtotal'], totals['tax_total'],
|
||||
totals['total'], valid_until, data.get('notes'),
|
||||
currency, exchange_rate, g.employee_id, quot_id
|
||||
))
|
||||
|
||||
# Insert new items
|
||||
for item in totals['items']:
|
||||
line_subtotal = round(
|
||||
item['unit_price'] * item['quantity'] * (1 - item['discount_pct'] / 100), 2
|
||||
)
|
||||
cur.execute("""
|
||||
INSERT INTO quotation_items
|
||||
(quotation_id, inventory_id, part_number, name, quantity,
|
||||
unit_price, discount_pct, tax_rate, subtotal, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (
|
||||
quot_id, item['inventory_id'], item.get('part_number', ''),
|
||||
item.get('name', ''), item['quantity'], item['unit_price'],
|
||||
item['discount_pct'], item['tax_rate'], line_subtotal,
|
||||
currency, exchange_rate
|
||||
))
|
||||
|
||||
# Reserve stock for new items
|
||||
new_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if new_items:
|
||||
reserve_for_quotation(conn, quot_id, new_items, employee_id=g.employee_id)
|
||||
|
||||
log_action(conn, 'QUOTATION_UPDATE', 'quotation', quot_id,
|
||||
new_value={'total': totals['total'], 'items_count': len(items)})
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Quotation updated', 'id': quot_id, 'total': totals['total']})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>', methods=['PATCH'])
|
||||
@require_auth('pos.sell')
|
||||
def patch_quotation(quot_id):
|
||||
"""Update quotation header fields without touching items."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT id, status FROM quotations WHERE id = %s", (quot_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
|
||||
fields = []
|
||||
params = []
|
||||
if 'customer_id' in data:
|
||||
fields.append('customer_id = %s')
|
||||
params.append(data['customer_id'])
|
||||
if 'notes' in data:
|
||||
fields.append('notes = %s')
|
||||
params.append(data['notes'])
|
||||
if 'valid_until' in data:
|
||||
fields.append('valid_until = %s')
|
||||
params.append(data['valid_until'])
|
||||
if 'status' in data and data['status'] in ('active', 'cancelled', 'expired'):
|
||||
fields.append('status = %s')
|
||||
params.append(data['status'])
|
||||
|
||||
if not fields:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'No changes'}), 200
|
||||
|
||||
params.append(quot_id)
|
||||
cur.execute(f"UPDATE quotations SET {', '.join(fields)} WHERE id = %s", params)
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Quotation updated'})
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>/share', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def share_quotation(quot_id):
|
||||
"""Generate a public JWT token for viewing this quotation."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, valid_until, status FROM quotations WHERE id = %s", (quot_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close(); conn.close()
|
||||
if not row:
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
if row[2] != 'active':
|
||||
return jsonify({'error': 'Only active quotations can be shared'}), 400
|
||||
|
||||
valid_until = row[1] or (date.today() + timedelta(days=7))
|
||||
if isinstance(valid_until, str):
|
||||
valid_until = datetime.strptime(valid_until, '%Y-%m-%d').date()
|
||||
|
||||
payload = {
|
||||
'type': 'public_quote',
|
||||
'quot_id': quot_id,
|
||||
'tenant_id': g.tenant_id,
|
||||
'exp': datetime.combine(valid_until, datetime.max.time()),
|
||||
}
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
||||
public_url = request.host_url.rstrip('/') + f'/public/quote/{token}'
|
||||
return jsonify({'token': token, 'url': public_url})
|
||||
|
||||
|
||||
@pos_bp.route('/public/quote/<token>', methods=['GET'])
|
||||
def public_quote(token):
|
||||
"""Unauthenticated public view of a quotation."""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||
if payload.get('type') != 'public_quote':
|
||||
return jsonify({'error': 'Invalid token type'}), 400
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Quote expired'}), 410
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 400
|
||||
|
||||
# Resolve tenant db
|
||||
from tenant_db import get_tenant_conn
|
||||
conn = get_tenant_conn(payload['tenant_id'])
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
|
||||
q.created_at, q.notes, q.customer_id, q.currency, q.exchange_rate,
|
||||
c.name as customer_name, c.phone as customer_phone, c.email as customer_email,
|
||||
e.name as employee_name
|
||||
FROM quotations q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
LEFT JOIN employees e ON q.employee_id = e.id
|
||||
WHERE q.id = %s
|
||||
""", (payload['quot_id'],))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
|
||||
cols = ['id', 'subtotal', 'tax_total', 'total', 'valid_until', 'created_at',
|
||||
'notes', 'customer_id', 'currency', 'exchange_rate', 'customer_name',
|
||||
'customer_phone', 'customer_email', 'employee_name']
|
||||
quot = dict(zip(cols, row))
|
||||
for k in ('subtotal', 'tax_total', 'total', 'exchange_rate'):
|
||||
if quot.get(k) is not None:
|
||||
quot[k] = float(quot[k])
|
||||
|
||||
cur.execute("""
|
||||
SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
|
||||
FROM quotation_items WHERE quotation_id = %s ORDER BY id
|
||||
""", (payload['quot_id'],))
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'part_number': r[0], 'name': r[1], 'quantity': r[2],
|
||||
'unit_price': float(r[3]) if r[3] else 0,
|
||||
'discount_pct': float(r[4]) if r[4] else 0,
|
||||
'tax_rate': float(r[5]) if r[5] else 0,
|
||||
'subtotal': float(r[6]) if r[6] else 0,
|
||||
})
|
||||
cur.close(); conn.close()
|
||||
|
||||
html = render_template_string(PUBLIC_QUOTE_TEMPLATE,
|
||||
quot=quot, items=items, host=request.host_url.rstrip('/'),
|
||||
token=token)
|
||||
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||
|
||||
|
||||
@pos_bp.route('/public/quote/<token>/accept', methods=['POST'])
|
||||
def public_quote_accept(token):
|
||||
"""Customer accepts a public quote."""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||
if payload.get('type') != 'public_quote':
|
||||
return jsonify({'error': 'Invalid token type'}), 400
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Quote expired'}), 410
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 400
|
||||
|
||||
conn = get_tenant_conn(payload['tenant_id'])
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT status FROM quotations WHERE id = %s", (payload['quot_id'],))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
if row[0] != 'active':
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation is no longer active'}), 400
|
||||
|
||||
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s",
|
||||
(payload['quot_id'],))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Cotizacion aceptada. Un asesor se pondra en contacto contigo.'})
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>/pdf', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def get_quotation_pdf(quot_id):
|
||||
@@ -1004,6 +1280,19 @@ def convert_quotation(quot_id):
|
||||
WHERE id = %s
|
||||
""", (sale['id'], quot_id))
|
||||
|
||||
# Convert reservation to actual sale
|
||||
from services.quote_reservation import (
|
||||
convert_quotation_reservation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
try:
|
||||
res_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if res_items:
|
||||
convert_quotation_reservation(conn, quot_id, res_items, sale_id=sale['id'], employee_id=g.employee_id)
|
||||
except Exception as res_err:
|
||||
import logging
|
||||
logging.getLogger('pos').warning(f'Quote conversion reservation failed for #{quot_id}: {res_err}')
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify(sale), 201
|
||||
@@ -1034,11 +1323,76 @@ def cancel_quotation(quot_id):
|
||||
return jsonify({'error': f'Quotation is already {quot[1]}'}), 400
|
||||
|
||||
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (quot_id,))
|
||||
|
||||
# Release reserved stock
|
||||
from services.quote_reservation import (
|
||||
release_quotation_reservation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
try:
|
||||
res_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if res_items:
|
||||
release_quotation_reservation(conn, quot_id, res_items, employee_id=g.employee_id)
|
||||
except Exception as res_err:
|
||||
import logging
|
||||
logging.getLogger('pos').warning(f'Quote release on cancel failed for #{quot_id}: {res_err}')
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Quotation cancelled'})
|
||||
|
||||
|
||||
@pos_bp.route('/internal/check-expired-quotations', methods=['POST'])
|
||||
def check_expired_quotations():
|
||||
"""Cron endpoint: mark active quotations as expired when valid_until < today.
|
||||
|
||||
Can be called internally by systemd timer or Celery beat.
|
||||
Requires a secret header INTERNAL_API_KEY for safety.
|
||||
Body (optional): { tenant_id: int } — if omitted, uses g.tenant_id.
|
||||
"""
|
||||
from config import INTERNAL_API_KEY
|
||||
if INTERNAL_API_KEY and request.headers.get('X-Internal-Key') != INTERNAL_API_KEY:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
tenant_id = data.get('tenant_id') or getattr(g, 'tenant_id', None)
|
||||
if not tenant_id:
|
||||
return jsonify({'error': 'tenant_id required'}), 400
|
||||
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE quotations
|
||||
SET status = 'expired'
|
||||
WHERE status = 'active'
|
||||
AND valid_until < CURRENT_DATE
|
||||
RETURNING id
|
||||
""")
|
||||
expired_ids = [r[0] for r in cur.fetchall()]
|
||||
|
||||
# Release reservations for expired quotes
|
||||
from services.quote_reservation import (
|
||||
release_quotation_reservation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
for qid in expired_ids:
|
||||
try:
|
||||
res_items = get_quotation_items_for_reservation(conn, qid)
|
||||
if res_items:
|
||||
release_quotation_reservation(conn, qid, res_items)
|
||||
except Exception as res_err:
|
||||
import logging
|
||||
logging.getLogger('pos').warning(f'Quote release on expiry failed for #{qid}: {res_err}')
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({
|
||||
'expired': len(expired_ids),
|
||||
'ids': expired_ids,
|
||||
'tenant_id': tenant_id,
|
||||
})
|
||||
|
||||
|
||||
# ─── Layaways (Apartados) ────────────────────────
|
||||
|
||||
@pos_bp.route('/layaways', methods=['POST'])
|
||||
@@ -1967,3 +2321,109 @@ def print_ticket(sale_id):
|
||||
raw = generate_ticket(sale_data, business_info, width=width)
|
||||
return Response(raw, mimetype='application/octet-stream',
|
||||
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
|
||||
|
||||
|
||||
# ─── Public Quote HTML Template ─────────────────────────────────────────────
|
||||
|
||||
PUBLIC_QUOTE_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cotizacion #{{ quot.id }}</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#111;padding:16px;line-height:1.5}
|
||||
.card{max-width:640px;margin:0 auto;background:#fff;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,0.08);overflow:hidden}
|
||||
.header{background:linear-gradient(135deg,#1f2937,#374151);color:#fff;padding:28px 24px;text-align:center}
|
||||
.header h1{font-size:22px;font-weight:700;margin-bottom:6px}
|
||||
.header p{font-size:13px;opacity:.85}
|
||||
.body{padding:24px}
|
||||
.meta{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;font-size:13px;color:#4b5563}
|
||||
.meta div{background:#f9fafb;padding:10px 12px;border-radius:8px}
|
||||
.meta strong{color:#111;display:block;font-size:12px;text-transform:uppercase;letter-spacing:.4px;margin-bottom:2px}
|
||||
table{width:100%;border-collapse:collapse;font-size:14px;margin-bottom:16px}
|
||||
th{text-align:left;padding:10px 8px;background:#f3f4f6;color:#374151;font-size:11px;text-transform:uppercase;letter-spacing:.4px}
|
||||
td{padding:12px 8px;border-bottom:1px solid #e5e7eb;vertical-align:top}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.part{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#6b7280}
|
||||
.qty{text-align:center}
|
||||
.price{text-align:right;font-weight:600}
|
||||
.totals{border-top:2px solid #e5e7eb;padding-top:16px;text-align:right;font-size:14px}
|
||||
.totals div{margin-bottom:4px;color:#4b5563}
|
||||
.totals .big{font-size:22px;font-weight:800;color:#111;margin-top:8px}
|
||||
.actions{padding:0 24px 24px;text-align:center}
|
||||
.btn{display:inline-block;width:100%;padding:14px 20px;border-radius:10px;border:none;font-size:16px;font-weight:700;cursor:pointer;transition:transform .1s}
|
||||
.btn-primary{background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff}
|
||||
.btn-primary:hover{transform:translateY(-1px)}
|
||||
.btn-primary:active{transform:translateY(0)}
|
||||
.btn-disabled{background:#e5e7eb;color:#9ca3af;cursor:not-allowed}
|
||||
.footer{text-align:center;padding:16px;font-size:12px;color:#9ca3af}
|
||||
.badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:11px;font-weight:700;text-transform:uppercase}
|
||||
.badge-active{background:#d1fae5;color:#065f46}
|
||||
.badge-expired{background:#fee2e2;color:#991b1b}
|
||||
@media(min-width:480px){.meta{grid-template-columns:repeat(3,1fr)}.btn{width:auto;min-width:280px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>Cotizacion #{{ quot.id }}</h1>
|
||||
<p>{{ host }}</p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="meta">
|
||||
<div><strong>Cliente</strong>{{ quot.customer_name or 'Publico general' }}</div>
|
||||
<div><strong>Fecha</strong>{{ quot.created_at[:10] if quot.created_at else '—' }}</div>
|
||||
<div><strong>Vigencia</strong>{{ quot.valid_until or '—' }} <span class="badge badge-{{ 'active' if quot.status == 'active' else 'expired' }}">{{ quot.status }}</span></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Descripcion</th><th class="qty">Cant</th><th class="price">P. Unit</th><th class="price">Subtotal</th></tr></thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight:600">{{ it.name }}</div>
|
||||
<div class="part">{{ it.part_number }}</div>
|
||||
</td>
|
||||
<td class="qty">{{ it.quantity }}</td>
|
||||
<td class="price">${{ "{:,.2f}".format(it.unit_price) }}</td>
|
||||
<td class="price">${{ "{:,.2f}".format(it.subtotal) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals">
|
||||
<div>Subtotal: ${{ "{:,.2f}".format(quot.subtotal) }}</div>
|
||||
<div>IVA: ${{ "{:,.2f}".format(quot.tax_total) }}</div>
|
||||
<div class="big">Total: ${{ "{:,.2f}".format(quot.total) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{% if quot.status == 'active' %}
|
||||
<button class="btn btn-primary" id="acceptBtn" onclick="acceptQuote()">Aceptar cotizacion</button>
|
||||
{% else %}
|
||||
<button class="btn btn-disabled" disabled>Cotizacion no disponible</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="footer">
|
||||
Precios sujetos a cambio sin previo aviso. Vigencia limitada.
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function acceptQuote(){
|
||||
var btn=document.getElementById('acceptBtn');
|
||||
btn.disabled=true;btn.textContent='Procesando...';
|
||||
fetch('/public/quote/{{ token }}/accept',{method:'POST'})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(d.error){alert('Error: '+d.error);btn.disabled=false;btn.textContent='Aceptar cotizacion';}
|
||||
else{btn.textContent='Cotizacion aceptada';btn.className='btn btn-disabled';alert(d.message);}
|
||||
})
|
||||
.catch(function(){alert('Error de red');btn.disabled=false;btn.textContent='Aceptar cotizacion';});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
106
pos/blueprints/public_bp.py
Normal file
106
pos/blueprints/public_bp.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Public blueprint — unauthenticated routes for shared content.
|
||||
|
||||
These routes live outside the /pos/api prefix so they can be accessed
|
||||
by customers without login.
|
||||
"""
|
||||
import jwt
|
||||
from flask import Blueprint, request, jsonify, render_template_string
|
||||
from tenant_db import get_tenant_conn
|
||||
from config import JWT_SECRET
|
||||
from blueprints.pos_bp import PUBLIC_QUOTE_TEMPLATE
|
||||
|
||||
public_bp = Blueprint('public', __name__)
|
||||
|
||||
|
||||
@public_bp.route('/public/quote/<token>', methods=['GET'])
|
||||
def public_quote(token):
|
||||
"""Unauthenticated public view of a quotation."""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||
if payload.get('type') != 'public_quote':
|
||||
return jsonify({'error': 'Invalid token type'}), 400
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Quote expired'}), 410
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 400
|
||||
|
||||
conn = get_tenant_conn(payload['tenant_id'])
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
|
||||
q.created_at, q.notes, q.customer_id, q.currency, q.exchange_rate,
|
||||
q.status,
|
||||
c.name as customer_name, c.phone as customer_phone, c.email as customer_email,
|
||||
e.name as employee_name
|
||||
FROM quotations q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
LEFT JOIN employees e ON q.employee_id = e.id
|
||||
WHERE q.id = %s
|
||||
""", (payload['quot_id'],))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
|
||||
cols = ['id', 'subtotal', 'tax_total', 'total', 'valid_until', 'created_at',
|
||||
'notes', 'customer_id', 'currency', 'exchange_rate', 'status',
|
||||
'customer_name', 'customer_phone', 'customer_email', 'employee_name']
|
||||
quot = dict(zip(cols, row))
|
||||
for k in ('subtotal', 'tax_total', 'total', 'exchange_rate'):
|
||||
if quot.get(k) is not None:
|
||||
quot[k] = float(quot[k])
|
||||
if quot.get('created_at'):
|
||||
quot['created_at'] = str(quot['created_at'])
|
||||
if quot.get('valid_until'):
|
||||
quot['valid_until'] = str(quot['valid_until'])
|
||||
|
||||
cur.execute("""
|
||||
SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
|
||||
FROM quotation_items WHERE quotation_id = %s ORDER BY id
|
||||
""", (payload['quot_id'],))
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'part_number': r[0], 'name': r[1], 'quantity': r[2],
|
||||
'unit_price': float(r[3]) if r[3] else 0,
|
||||
'discount_pct': float(r[4]) if r[4] else 0,
|
||||
'tax_rate': float(r[5]) if r[5] else 0,
|
||||
'subtotal': float(r[6]) if r[6] else 0,
|
||||
})
|
||||
cur.close(); conn.close()
|
||||
|
||||
html = render_template_string(PUBLIC_QUOTE_TEMPLATE,
|
||||
quot=quot, items=items, host=request.host_url.rstrip('/'),
|
||||
token=token)
|
||||
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||
|
||||
|
||||
@public_bp.route('/public/quote/<token>/accept', methods=['POST'])
|
||||
def public_quote_accept(token):
|
||||
"""Customer accepts a public quote."""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||
if payload.get('type') != 'public_quote':
|
||||
return jsonify({'error': 'Invalid token type'}), 400
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Quote expired'}), 410
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 400
|
||||
|
||||
conn = get_tenant_conn(payload['tenant_id'])
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT status FROM quotations WHERE id = %s", (payload['quot_id'],))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
if row[0] != 'active':
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation is no longer active'}), 400
|
||||
|
||||
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s",
|
||||
(payload['quot_id'],))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Cotizacion aceptada. Un asesor se pondra en contacto contigo.'})
|
||||
@@ -13,15 +13,92 @@ Endpoints:
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from services import whatsapp_service
|
||||
|
||||
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
||||
|
||||
|
||||
def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn):
|
||||
def _resolve_mye_ids(vehicle, master_conn):
|
||||
"""Return list of MYE ids matching vehicle brand/model/year text."""
|
||||
if not master_conn or not vehicle:
|
||||
return []
|
||||
brand = vehicle.get('brand', '').strip()
|
||||
model = vehicle.get('model', '').strip()
|
||||
year = str(vehicle.get('year', '')).strip()
|
||||
if not brand and not model:
|
||||
return []
|
||||
cur = master_conn.cursor()
|
||||
clauses = []
|
||||
params = []
|
||||
if brand:
|
||||
clauses.append("b.name_brand ILIKE %s")
|
||||
params.append(f'%{brand}%')
|
||||
if model:
|
||||
clauses.append("m.name_model ILIKE %s")
|
||||
params.append(f'%{model}%')
|
||||
if year and year.isdigit():
|
||||
clauses.append("y.year_car = %s")
|
||||
params.append(int(year))
|
||||
if not clauses:
|
||||
cur.close()
|
||||
return []
|
||||
cur.execute(f"""
|
||||
SELECT mye.id_mye
|
||||
FROM model_year_engine mye
|
||||
JOIN models m ON m.id_model = mye.model_id
|
||||
JOIN brands b ON b.id_brand = m.brand_id
|
||||
JOIN years y ON y.id_year = mye.year_id
|
||||
WHERE {' AND '.join(clauses)}
|
||||
LIMIT 50
|
||||
""", tuple(params))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _get_conversation_history(phone, tenant_conn, limit=4):
|
||||
"""Fetch recent messages for *phone* to give the AI conversation context.
|
||||
|
||||
Includes both user and assistant messages, truncated to keep token count low.
|
||||
The most recent message (the one currently being processed) is excluded.
|
||||
"""
|
||||
if not tenant_conn or not phone:
|
||||
return []
|
||||
try:
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT direction, message_text
|
||||
FROM whatsapp_messages
|
||||
WHERE phone = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET 1
|
||||
""", (phone, limit))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
# Reverse so oldest-first (chronological) for the LLM
|
||||
history = []
|
||||
for direction, text in reversed(rows):
|
||||
if not text:
|
||||
continue
|
||||
role = "assistant" if direction == "outgoing" else "user"
|
||||
# Truncate assistant replies more aggressively (they contain JSON/tables)
|
||||
max_len = 200 if role == "assistant" else 300
|
||||
truncated = text[:max_len] + ('...' if len(text) > max_len else '')
|
||||
history.append({"role": role, "content": truncated})
|
||||
return history
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Failed to load conversation history: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=None):
|
||||
"""Search the refaccionaria's LOCAL inventory and build a WhatsApp reply.
|
||||
|
||||
If *vehicle* is provided and we have a master_conn, we first look up the
|
||||
MYE ids for that vehicle and JOIN through inventory_vehicle_compat so we
|
||||
only show parts that are known to fit the user's car.
|
||||
|
||||
Returns:
|
||||
(formatted_text, first_part_dict) — first_part_dict is used by the
|
||||
quotation system to know what to add when the user says "cotizar".
|
||||
@@ -31,101 +108,143 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn):
|
||||
return None, None
|
||||
|
||||
try:
|
||||
# Translate common English search terms to Spanish for local inventory
|
||||
# (the AI sends search_query in English, but local inventory names
|
||||
# are often in Spanish)
|
||||
from services.translations import PART_TRANSLATIONS
|
||||
search_terms = [search_query]
|
||||
# Add the Spanish translation if we have one
|
||||
for en, es in PART_TRANSLATIONS.items():
|
||||
if en.upper() in search_query.upper():
|
||||
search_terms.append(es)
|
||||
break
|
||||
|
||||
# Build ILIKE conditions for all search terms
|
||||
conditions = []
|
||||
params = []
|
||||
for term in search_terms:
|
||||
conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)")
|
||||
like = f'%{term}%'
|
||||
params.extend([like, like, like])
|
||||
# Split search_query by '|' into individual terms
|
||||
raw_terms = [t.strip() for t in (search_query or '').split('|') if t.strip()]
|
||||
if not raw_terms:
|
||||
raw_terms = [search_query] if search_query else []
|
||||
|
||||
where_search = ' OR '.join(conditions)
|
||||
# Translate each term to Spanish if possible
|
||||
search_terms = set()
|
||||
for term in raw_terms:
|
||||
search_terms.add(term)
|
||||
# Check if any English translation matches
|
||||
for en, es in PART_TRANSLATIONS.items():
|
||||
if en.upper() == term.upper():
|
||||
search_terms.add(es)
|
||||
break
|
||||
# Also check if the term contains an English word
|
||||
if en.upper() in term.upper():
|
||||
search_terms.add(term.upper().replace(en.upper(), es))
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(f"""
|
||||
SELECT i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
|
||||
COALESCE(s.stock, 0) AS stock,
|
||||
i.unit, i.location
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(quantity) AS stock
|
||||
FROM inventory_operations
|
||||
GROUP BY inventory_id
|
||||
) s ON s.inventory_id = i.id
|
||||
WHERE i.is_active = TRUE
|
||||
AND ({where_search})
|
||||
ORDER BY
|
||||
COALESCE(s.stock, 0) > 0 DESC,
|
||||
i.name
|
||||
LIMIT 10
|
||||
""", params)
|
||||
search_terms = list(search_terms)
|
||||
if not search_terms:
|
||||
return None, None
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
# Vehicle-aware filtering
|
||||
mye_ids = _resolve_mye_ids(vehicle, master_conn)
|
||||
|
||||
if not rows:
|
||||
return ('❌ No tenemos esa parte en inventario actualmente.\n'
|
||||
'_Puedes preguntar por otra parte o visitarnos en tienda._'), None
|
||||
def _do_search(use_compat=True):
|
||||
"""Run inventory search. Returns list of rows."""
|
||||
conditions = []
|
||||
params = []
|
||||
for term in search_terms:
|
||||
conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)")
|
||||
like = f'%{term}%'
|
||||
params.extend([like, like, like])
|
||||
|
||||
# Split into in-stock and out-of-stock
|
||||
in_stock = [r for r in rows if r[6] > 0]
|
||||
out_stock = [r for r in rows if r[6] <= 0]
|
||||
where_search = ' OR '.join(conditions)
|
||||
compat_clause = ""
|
||||
if use_compat and mye_ids:
|
||||
compat_clause = f"AND i.id IN (SELECT inventory_id FROM inventory_vehicle_compat WHERE model_year_engine_id IN ({','.join(['%s']*len(mye_ids))}))"
|
||||
params.extend(mye_ids)
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
|
||||
COALESCE(s.stock, 0) AS stock,
|
||||
i.unit, i.location
|
||||
FROM inventory i
|
||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||
WHERE i.is_active = TRUE
|
||||
AND ({where_search})
|
||||
{compat_clause}
|
||||
ORDER BY
|
||||
COALESCE(s.stock, 0) > 0 DESC,
|
||||
i.name
|
||||
LIMIT 10
|
||||
""", params)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return rows
|
||||
|
||||
# 1. Try with vehicle compatibility filter
|
||||
rows = _do_search(use_compat=True)
|
||||
compat_filter_applied = bool(mye_ids)
|
||||
|
||||
# 2. If no results with compatibility, try WITHOUT filter
|
||||
fallback_rows = []
|
||||
if not rows and mye_ids:
|
||||
fallback_rows = _do_search(use_compat=False)
|
||||
|
||||
if not rows and not fallback_rows:
|
||||
# Truly nothing found — return a conversational message that doesn't kill the chat
|
||||
v_str = ""
|
||||
if vehicle and vehicle.get('brand'):
|
||||
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}".strip()
|
||||
|
||||
msg_parts = [
|
||||
"🔍 Revisé nuestro inventario y no encontré esas partes en este momento."
|
||||
]
|
||||
if v_str:
|
||||
msg_parts.append(f"Para tu {v_str}, puedo:")
|
||||
else:
|
||||
msg_parts.append("Te puedo ayudar de estas formas:")
|
||||
msg_parts.extend([
|
||||
"",
|
||||
"• *Pedirlas por encargo* — te doy tiempo y precio estimado",
|
||||
"• *Buscar alternativas* — equivalentes de otra marca que sí tengamos",
|
||||
"• *Sugerir refaccionarias cercanas* — si es urgente",
|
||||
"",
|
||||
"¿Qué prefieres? O dime si quieres buscar otra parte."
|
||||
])
|
||||
return '\n'.join(msg_parts), None
|
||||
|
||||
# Use fallback rows if primary search returned nothing
|
||||
using_fallback = False
|
||||
if not rows and fallback_rows:
|
||||
rows = fallback_rows
|
||||
using_fallback = True
|
||||
|
||||
in_stock = [r for r in rows if r[7] > 0]
|
||||
out_stock = [r for r in rows if r[7] <= 0]
|
||||
|
||||
# Build the first-part dict for quotation tracking
|
||||
# Use the first in-stock part, or first out-of-stock if none available
|
||||
best = in_stock[0] if in_stock else (out_stock[0] if out_stock else None)
|
||||
first_part = None
|
||||
if best:
|
||||
first_part = {
|
||||
'inventory_id': None, # we'd need the id — fetch it
|
||||
'part_number': best[0],
|
||||
'name': best[1],
|
||||
'brand': best[2] or '',
|
||||
'price': float(best[3]) if best[3] else 0,
|
||||
'inventory_id': best[0],
|
||||
'part_number': best[1],
|
||||
'name': best[2],
|
||||
'brand': best[3] or '',
|
||||
'price': float(best[4]) if best[4] else 0,
|
||||
'tax_rate': 0.16,
|
||||
'stock': best[6],
|
||||
'unit': best[7] or 'PZA',
|
||||
'stock': best[7],
|
||||
'unit': best[8] or 'PZA',
|
||||
}
|
||||
# Fetch the inventory ID for the quotation item FK
|
||||
try:
|
||||
cur2 = tenant_conn.cursor()
|
||||
cur2.execute("SELECT id FROM inventory WHERE part_number = %s AND is_active = TRUE LIMIT 1",
|
||||
(best[0],))
|
||||
inv_row = cur2.fetchone()
|
||||
if inv_row:
|
||||
first_part['inventory_id'] = inv_row[0]
|
||||
cur2.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lines = []
|
||||
|
||||
if using_fallback:
|
||||
lines.append("⚠️ *No encontré partes verificadas para tu vehículo, pero sí tengo estas opciones generales:*")
|
||||
lines.append("")
|
||||
|
||||
if in_stock:
|
||||
lines.append('✅ *Tenemos en stock:*')
|
||||
lines.append('')
|
||||
for r in in_stock:
|
||||
part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||||
inv_id, part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||||
brand_str = f'*{brand}*' if brand else ''
|
||||
price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio'
|
||||
lines.append(f' • {brand_str} {name}')
|
||||
lines.append(f' #{part_num} — {price_str} ({stock} {unit or "pzas"} disponibles)')
|
||||
lines.append('')
|
||||
else:
|
||||
elif out_stock:
|
||||
lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*')
|
||||
lines.append('')
|
||||
for r in out_stock[:5]:
|
||||
part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||||
inv_id, part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||||
brand_str = f'*{brand}*' if brand else ''
|
||||
price_str = f'${float(p1):,.2f}' if p1 else ''
|
||||
lines.append(f' • {brand_str} {name} #{part_num} {price_str}')
|
||||
@@ -143,6 +262,9 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn):
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Enrichment error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None, None
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -194,9 +316,11 @@ def webhook():
|
||||
# TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives.
|
||||
tenant_id = 11
|
||||
tenant_conn = None
|
||||
master_conn = None
|
||||
inventory_context = None
|
||||
try:
|
||||
tenant_conn = get_tenant_conn(tenant_id)
|
||||
master_conn = get_master_conn()
|
||||
|
||||
# 1. Log the incoming message (with contact display name)
|
||||
cur = tenant_conn.cursor()
|
||||
@@ -216,6 +340,22 @@ def webhook():
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] inventory_context failed: {e}")
|
||||
inventory_context = None
|
||||
|
||||
# 2b. Append previously-detected vehicle so the AI keeps context
|
||||
# even when we don't send full conversation history (Hermes is slow with it)
|
||||
try:
|
||||
from services.wa_quotation import get_vehicle
|
||||
saved_vehicle = get_vehicle(clean_phone)
|
||||
if saved_vehicle and inventory_context:
|
||||
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
|
||||
if v_str:
|
||||
inventory_context += f"\n\nVEHICULO DEL CLIENTE: {v_str}"
|
||||
elif saved_vehicle:
|
||||
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
|
||||
if v_str:
|
||||
inventory_context = f"VEHICULO DEL CLIENTE: {v_str}"
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] vehicle_context failed: {e}")
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] tenant connection failed: {e}")
|
||||
|
||||
@@ -281,6 +421,33 @@ def webhook():
|
||||
else:
|
||||
reply = '⚠️ No tienes una cotización abierta para confirmar.'
|
||||
|
||||
# ── Check for conversation reset commands ──
|
||||
if media_kind == 'text' and msg.get('text'):
|
||||
txt_lower = msg['text'].lower().strip()
|
||||
if txt_lower in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar'):
|
||||
if tenant_conn:
|
||||
try:
|
||||
cur_del = tenant_conn.cursor()
|
||||
cur_del.execute("DELETE FROM whatsapp_messages WHERE phone = %s", (clean_phone,))
|
||||
tenant_conn.commit()
|
||||
cur_del.close()
|
||||
except Exception as del_err:
|
||||
print(f"[WA-AI] Failed to clear conversation history: {del_err}")
|
||||
reply = '🗑️ *Conversación reiniciada.*\n\n¡Hola de nuevo! ¿En qué puedo ayudarte?'
|
||||
result = whatsapp_service.send_message(reply_to, reply)
|
||||
if tenant_conn:
|
||||
try:
|
||||
cur_save = tenant_conn.cursor()
|
||||
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
|
||||
tenant_conn.commit()
|
||||
cur_save.close()
|
||||
except Exception:
|
||||
pass
|
||||
if tenant_conn:
|
||||
try: tenant_conn.close()
|
||||
except Exception: pass
|
||||
return jsonify({'ok': True})
|
||||
|
||||
if intent is not None:
|
||||
# It was a quote command — send reply and skip the AI
|
||||
if reply:
|
||||
@@ -299,6 +466,13 @@ def webhook():
|
||||
except Exception: pass
|
||||
return jsonify({'ok': True})
|
||||
|
||||
# Load conversation history so the AI remembers context (vehicle, parts, etc.)
|
||||
conversation_history = []
|
||||
if tenant_conn:
|
||||
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=2)
|
||||
if conversation_history:
|
||||
print(f"[WA-AI] Loaded {len(conversation_history)} history messages for {clean_phone}")
|
||||
|
||||
try:
|
||||
if media_kind == 'image' and msg.get('media_base64'):
|
||||
from services.ai_chat import chat_with_image
|
||||
@@ -308,6 +482,7 @@ def webhook():
|
||||
ai_resp = chat_with_image(
|
||||
user_message=prompt,
|
||||
image_base64=msg['media_base64'],
|
||||
conversation_history=conversation_history,
|
||||
inventory_context=inventory_context,
|
||||
)
|
||||
reply = ai_resp.get('message', '') or ''
|
||||
@@ -332,7 +507,7 @@ def webhook():
|
||||
if transcript:
|
||||
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
|
||||
from services.ai_chat import chat
|
||||
ai_resp = chat(transcript, inventory_context=inventory_context)
|
||||
ai_resp = chat(transcript, conversation_history=conversation_history, inventory_context=inventory_context)
|
||||
reply = ai_resp.get('message', '') or ''
|
||||
# Prefix the reply so the sender knows we understood the voice note
|
||||
if reply:
|
||||
@@ -344,16 +519,25 @@ def webhook():
|
||||
elif msg.get('text'):
|
||||
# Plain text message — standard chatbot flow
|
||||
from services.ai_chat import chat
|
||||
ai_resp = chat(msg['text'], inventory_context=inventory_context)
|
||||
ai_resp = chat(msg['text'], conversation_history=conversation_history, inventory_context=inventory_context)
|
||||
reply = ai_resp.get('message', '') or ''
|
||||
|
||||
# Enrich: if the AI returned a search_query, look up real parts
|
||||
# from the catalog and append them to the WhatsApp reply.
|
||||
search_q = ai_resp.get('search_query')
|
||||
vehicle = ai_resp.get('vehicle')
|
||||
|
||||
# Persist detected vehicle so we don't lose context between messages
|
||||
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'):
|
||||
try:
|
||||
from services.wa_quotation import set_vehicle
|
||||
set_vehicle(clean_phone, vehicle)
|
||||
except Exception as veh_err:
|
||||
print(f"[WA-AI] Failed to save vehicle: {veh_err}")
|
||||
|
||||
if search_q and reply:
|
||||
try:
|
||||
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn)
|
||||
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn, master_conn)
|
||||
if enrichment:
|
||||
reply = reply + '\n\n' + enrichment
|
||||
# Track the found part so "cotizar" can add it
|
||||
@@ -384,12 +568,17 @@ def webhook():
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}")
|
||||
|
||||
# 4. Clean up the connection
|
||||
# 4. Clean up connections
|
||||
if tenant_conn is not None:
|
||||
try:
|
||||
tenant_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if master_conn is not None:
|
||||
try:
|
||||
master_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
))
|
||||
|
||||
@@ -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
|
||||
|
||||
123
pos/services/quote_reservation.py
Normal file
123
pos/services/quote_reservation.py
Normal file
@@ -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
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
|
||||
@@ -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 = '<div style="padding:var(--space-4);color:var(--color-text-muted);font-size:var(--text-body-sm);">Sin resultados para "' + esc(q) + '"</div>';
|
||||
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
|
||||
? '<span class="stock-badge stock-badge--local" style="margin-left:auto;">Stock: ' + r.local_stock + '</span>'
|
||||
: '';
|
||||
return '<div class="search-result-item" data-part-id="' + r.id_part + '">' +
|
||||
var localBadge = isLocal
|
||||
? '<span class="stock-badge stock-badge--local" style="margin-right:4px;background:#4f46e5;">Stock Local</span>'
|
||||
: '';
|
||||
var oemNum = isLocal ? (r.oem_part_number || r.part_number || '') : (r.oem_part_number || '');
|
||||
return '<div class="search-result-item" data-part-id="' + r.id_part + '" data-name="' + esc(r.name) + '" data-pn="' + esc(oemNum) + '" data-price="' + (r.local_price || '') + '" data-stock="' + (r.local_stock || 0) + '">' +
|
||||
'<div style="flex:1;">' +
|
||||
'<div class="search-result__oem">' + esc(r.oem_part_number) + '</div>' +
|
||||
'<div class="search-result__oem">' + localBadge + esc(oemNum) + '</div>' +
|
||||
'<div class="search-result__name">' + esc(r.name) + '</div>' +
|
||||
(r.vehicle_info ? '<div class="search-result__vehicle">' + esc(r.vehicle_info) + '</div>' : '') +
|
||||
'</div>' +
|
||||
@@ -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));
|
||||
|
||||
@@ -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 += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Sin vehiculos vinculados.</p>';
|
||||
}
|
||||
html2 += '<div style="margin-top:8px;"><button class="btn btn--primary btn--sm" onclick="autoMatchCompat(' + itemId + ')">Auto-Match por TecDoc</button> <span style="font-size:var(--text-caption);color:var(--color-text-muted);">Busca en catalogo central y vincula automaticamente</span></div>';
|
||||
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 += '<div style="margin-top:8px;"><button class="btn btn--primary btn--sm" onclick="autoMatchCompat(' + itemId + ')">' + btnLabel + '</button> <span style="font-size:var(--text-caption);color:var(--color-text-muted);">' + btnDesc + '</span></div>';
|
||||
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'); });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = '<div class="chat-empty">Error cargando mensajes: ' + escHtml(data.error) + '</div>';
|
||||
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 = '<div class="chat-empty">Error de red al cargar mensajes</div>';
|
||||
});
|
||||
}
|
||||
|
||||
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 += '<div class="msg-bubble ' + cls + '">'
|
||||
+ '<div class="msg-bubble__text">' + escHtml(text).replace(/\n/g, '<br>') + '</div>'
|
||||
+ '<div class="msg-bubble__meta">' + fmtTime(time) + '</div>'
|
||||
+ '</div>';
|
||||
});
|
||||
console.log('[WA-UI] renderMessages HTML length:', html.length);
|
||||
chatMessages.innerHTML = html || '<div class="chat-empty">Sin mensajes</div>';
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
|
||||
489
pos/static/js/whatsapp2.js
Normal file
489
pos/static/js/whatsapp2.js
Normal file
@@ -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 = '<div class="conv-empty">No hay conversaciones</div>';
|
||||
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 += '<div class="conv-item' + (isActive ? ' is-active' : '') + '" data-phone="' + escHtml(c.phone) + '">'
|
||||
+ '<div class="conv-item__phone">' + escHtml(displayName) + '</div>'
|
||||
+ '<div class="conv-item__preview">' + dirIcon + escHtml(c.last_message || '(sin texto)') + '</div>'
|
||||
+ '<div class="conv-item__time">' + fmtTime(c.last_at) + '</div>'
|
||||
+ '<button class="conv-item__delete" data-del-phone="' + escHtml(c.phone) + '" title="Borrar conversacion">×</button>'
|
||||
+ '</div>';
|
||||
});
|
||||
// "Borrar todo" button at the bottom
|
||||
html += '<div style="padding:8px;text-align:center;">'
|
||||
+ '<button class="conv-delete-all" style="background:none;border:1px dashed var(--color-border,#444);color:var(--color-text-muted);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:11px;" onclick="deleteAllConversations()">Borrar todas las conversaciones</button>'
|
||||
+ '</div>';
|
||||
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 = '<div class="conv-empty">Error cargando conversaciones</div>';
|
||||
});
|
||||
}
|
||||
|
||||
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 = '<div class="chat-empty">Error cargando mensajes: ' + escHtml(data.error) + '</div>';
|
||||
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 = '<div class="chat-empty">Error de red al cargar mensajes</div>';
|
||||
});
|
||||
}
|
||||
|
||||
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 += '<div class="msg-bubble ' + cls + '">'
|
||||
+ '<div class="msg-bubble__text">' + escHtml(text).replace(/\n/g, '<br>') + '</div>'
|
||||
+ '<div class="msg-bubble__meta">' + fmtTime(time) + '</div>'
|
||||
+ '</div>';
|
||||
});
|
||||
console.log('[WA-UI] renderMessages HTML length:', html.length);
|
||||
chatMessages.innerHTML = html || '<div class="chat-empty">Sin mensajes</div>';
|
||||
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) {}
|
||||
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
<script src="/pos/static/js/app-init.js" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||
<script src="/pos/static/js/catalog.js" defer></script>
|
||||
<script src="/pos/static/js/catalog.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||
<script src="/pos/static/js/chat.js" defer></script>
|
||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||
|
||||
@@ -815,7 +815,7 @@
|
||||
<script src="/pos/static/js/pos-utils.js" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||
<script src="/pos/static/js/virtual-scroll.js" defer></script>
|
||||
<script src="/pos/static/js/inventory.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/inventory.js?v=3" defer></script>
|
||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||
|
||||
@@ -114,7 +114,12 @@
|
||||
html += '<div style="font-size:var(--text-h5);font-weight:700;color:var(--color-text-accent);">Total: $' + fmt(q.total) + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div style="margin-top:var(--space-5);display:flex;gap:var(--space-3);justify-content:flex-end;">';
|
||||
html += '<div style="margin-top:var(--space-5);display:flex;gap:var(--space-3);justify-content:flex-end;flex-wrap:wrap;">';
|
||||
if (q.status === 'active') {
|
||||
html += '<button class="btn btn--ghost" onclick="editQuote(' + q.id + ')" style="color:#4f46e5;">Editar</button>';
|
||||
html += '<button class="btn btn--ghost" onclick="convertQuote(' + q.id + ')" style="color:#059669;">Convertir a venta</button>';
|
||||
html += '<button class="btn btn--ghost" onclick="shareQuote(' + q.id + ')">Compartir link</button>';
|
||||
}
|
||||
html += '<button class="btn btn--ghost" onclick="deleteQuote(' + q.id + ')" style="color:#F85149;">Eliminar</button>';
|
||||
html += '<button class="btn btn--ghost" onclick="exportVisibleTableCSV(\'cotizacion_' + q.id + '\')">Exportar CSV</button>';
|
||||
html += '<button class="btn btn--ghost" onclick="window.print()">Imprimir</button>';
|
||||
@@ -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');
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<title>WhatsApp — Nexus Autoparts POS</title>
|
||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||
@@ -89,10 +92,11 @@
|
||||
<div class="empty-state__hint">Los mensajes de WhatsApp aparecen aqui en tiempo real</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-panel" id="chatPanel" style="display:none">
|
||||
<div class="chat-panel__header">
|
||||
<span class="chat-panel__phone" id="chatHeaderPhone"></span>
|
||||
<div class="chat-panel__actions">
|
||||
<div class="wa-chat-panel" id="waChatPanel" style="display:none">
|
||||
<div class="wa-chat-panel__header">
|
||||
<button class="btn btn--sm" id="backToListBtn" style="display:none;margin-right:8px;">← Volver</button>
|
||||
<span class="wa-chat-panel__phone" id="chatHeaderPhone"></span>
|
||||
<div class="wa-chat-panel__actions">
|
||||
<button class="btn btn--sm" id="sendQuoteBtn" title="Enviar cotizacion por WhatsApp">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||
@@ -102,11 +106,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-panel__messages" id="chatMessages"></div>
|
||||
<div class="wa-chat-panel__messages" id="waChatMessages"></div>
|
||||
|
||||
<div class="chat-input-bar">
|
||||
<textarea id="chatInput" placeholder="Escribe un mensaje..." rows="1"></textarea>
|
||||
<button class="btn btn--primary" id="sendBtn">
|
||||
<textarea id="waChatInput" placeholder="Escribe un mensaje..." rows="1"></textarea>
|
||||
<button class="btn btn--primary" id="waSendBtn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
@@ -127,7 +131,7 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
|
||||
|
||||
<!-- Sidebar -->
|
||||
<script src="/pos/static/js/i18n.js" defer></script>
|
||||
<script src="/pos/static/js/whatsapp.js" defer></script>
|
||||
<script src="/pos/static/js/whatsapp2.js" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
# /home/Autopartes/pos/tenant_db.py
|
||||
"""Tenant DB connection manager with pooling.
|
||||
"""Tenant DB connection manager.
|
||||
|
||||
Uses psycopg2.pool.ThreadedConnectionPool for both master and tenant DBs.
|
||||
Connections are returned to the pool on .close() via a thin wrapper —
|
||||
this keeps the rest of the codebase unchanged.
|
||||
Master DB: creates a fresh connection each time (very light load thanks to
|
||||
tenant_id → db_name cache, so we only hit master ~once per 5 min).
|
||||
Tenant DBs: use psycopg2.pool.ThreadedConnectionPool with maxconn=20.
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
import psycopg2
|
||||
from psycopg2 import pool
|
||||
from config import MASTER_DB_URL, TENANT_DB_URL_TEMPLATE
|
||||
|
||||
|
||||
# ─── Pools ─────────────────────────────────────
|
||||
_master_pool = None
|
||||
# ─── Tenant Pools ──────────────────────────────
|
||||
_tenant_pools = {}
|
||||
|
||||
|
||||
def _get_master_pool():
|
||||
"""Lazy-initialize master DB connection pool."""
|
||||
global _master_pool
|
||||
if _master_pool is None:
|
||||
_master_pool = pool.ThreadedConnectionPool(
|
||||
minconn=2, maxconn=20, dsn=MASTER_DB_URL
|
||||
)
|
||||
return _master_pool
|
||||
# ─── Tenant cache ──────────────────────────────
|
||||
_tenant_cache = {}
|
||||
_tenant_cache_ttl = 300
|
||||
_tenant_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_tenant_pool(db_name):
|
||||
@@ -37,6 +32,34 @@ def _get_tenant_pool(db_name):
|
||||
return _tenant_pools[db_name]
|
||||
|
||||
|
||||
def _resolve_tenant_db(tenant_id):
|
||||
"""Return db_name for tenant_id, using cache first."""
|
||||
now = time.time()
|
||||
with _tenant_cache_lock:
|
||||
entry = _tenant_cache.get(tenant_id)
|
||||
if entry and entry['expires'] > now:
|
||||
return entry['db_name']
|
||||
|
||||
# Cache miss or expired — query master DB with a fresh connection
|
||||
conn = psycopg2.connect(MASTER_DB_URL)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT db_name FROM tenants WHERE id = %s AND is_active = true",
|
||||
(tenant_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
db_name = row[0] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if db_name:
|
||||
with _tenant_cache_lock:
|
||||
_tenant_cache[tenant_id] = {'db_name': db_name, 'expires': now + _tenant_cache_ttl}
|
||||
return db_name
|
||||
|
||||
|
||||
class _PooledConnection:
|
||||
"""Thin wrapper that delegates all attribute access to the real
|
||||
psycopg2 connection, but returns it to the pool on .close().
|
||||
@@ -52,19 +75,17 @@ class _PooledConnection:
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
# Rollback any aborted transaction before returning to pool.
|
||||
# Without this, failed transactions leave connections in
|
||||
# 'idle in transaction (aborted)' state, eventually exhausting
|
||||
# the pool.
|
||||
if self._conn:
|
||||
try:
|
||||
self._conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
self._pool.putconn(self._conn)
|
||||
self._pool.putconn(self._conn)
|
||||
except Exception:
|
||||
# If pool is already closed, fall back to real close
|
||||
self._conn.close()
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
@@ -76,27 +97,19 @@ class _PooledConnection:
|
||||
# ─── Public API ────────────────────────────────
|
||||
|
||||
def get_master_conn():
|
||||
"""Get a pooled connection to the master DB."""
|
||||
p = _get_master_pool()
|
||||
return _PooledConnection(p.getconn(), p)
|
||||
"""Get a direct connection to the master DB (no pool).
|
||||
|
||||
Caller MUST close() the connection when done.
|
||||
"""
|
||||
return psycopg2.connect(MASTER_DB_URL)
|
||||
|
||||
|
||||
def get_tenant_conn(tenant_id):
|
||||
"""Get a pooled connection to a tenant's DB."""
|
||||
master = get_master_conn()
|
||||
cur = master.cursor()
|
||||
cur.execute(
|
||||
"SELECT db_name FROM tenants WHERE id = %s AND is_active = true",
|
||||
(tenant_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
master.close()
|
||||
|
||||
if not row:
|
||||
db_name = _resolve_tenant_db(tenant_id)
|
||||
if not db_name:
|
||||
raise ValueError(f"Tenant {tenant_id} not found or inactive")
|
||||
|
||||
db_name = row[0]
|
||||
p = _get_tenant_pool(db_name)
|
||||
return _PooledConnection(p.getconn(), p)
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ Environment=REDIS_URL=redis://localhost:6379/0
|
||||
Environment=REDIS_ENABLED=true
|
||||
Environment=MEILI_URL=http://localhost:7700
|
||||
Environment=MEILI_ENABLED=true
|
||||
Environment=QWEN_API_URL=https://api.nan.builders/v1
|
||||
Environment=QWEN_API_KEY=sk-Yr0e-Y4F4j1NuuK8xdbxIA
|
||||
Environment=QWEN_MODEL=qwen3.6
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test.describe('Nexus POS — Auth Guard', () => {
|
||||
test('unauthenticated user is redirected to login', async ({ browser }) => {
|
||||
// Create incognito context without localStorage
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto('/pos/sale');
|
||||
await expect(page).toHaveURL(/\/pos\/login/);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('login page is accessible without token', async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
test('unauthenticated user redirected to login', async ({ page }) => {
|
||||
// Ensure no auth state
|
||||
await page.goto('/pos/login');
|
||||
await expect(page.locator('input[type="password"], #password, input[name="pin"]')).toBeVisible();
|
||||
await context.close();
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
await page.goto('/pos/sale');
|
||||
// app-init.js redirects to /pos/login when no token is found
|
||||
await expect(page).toHaveURL(/login/i, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,88 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test.describe('Nexus POS — Inventory', () => {
|
||||
test('inventory page loads with table or grid', async ({ page }) => {
|
||||
await page.goto('/pos/inventory');
|
||||
await expect(page.locator('#inventoryTable, .data-table, #partsGrid, .grid, table')).toBeVisible({ timeout: 10000 });
|
||||
const content = await page.locator('body').textContent();
|
||||
expect(content).toMatch(/inventario|stock|producto|parte/i);
|
||||
const FAKE_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksIm5hbWUiOiJUZXN0IFVzZXIifQ.signature';
|
||||
|
||||
async function setupAuth(page) {
|
||||
await page.goto('/pos/login');
|
||||
await page.evaluate((token) => {
|
||||
localStorage.setItem('pos_token', token);
|
||||
localStorage.setItem('pos_tenant_id', '11');
|
||||
}, FAKE_TOKEN);
|
||||
}
|
||||
|
||||
async function mockInventoryAPIs(page) {
|
||||
await page.route(/\/pos\/api\/inventory\/items\?page=.*&per_page=.*/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
barcode: '123456789',
|
||||
part_number: 'TEST-001',
|
||||
name: 'Producto de prueba',
|
||||
brand: 'TestBrand',
|
||||
stock: 10,
|
||||
cost: 50.0,
|
||||
price_1: 100.0,
|
||||
price_2: 90.0,
|
||||
price_3: 80.0,
|
||||
location: 'A-1',
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, total_pages: 1, total: 1 },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test('product detail modal or panel opens', async ({ page }) => {
|
||||
await page.route(/\/pos\/api\/inventory\/items\/\d+/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 1,
|
||||
barcode: '123456789',
|
||||
part_number: 'TEST-001',
|
||||
name: 'Producto de prueba',
|
||||
brand: 'TestBrand',
|
||||
location: 'A-1',
|
||||
stock: 10,
|
||||
cost: 50.0,
|
||||
price_1: 100.0,
|
||||
price_2: 90.0,
|
||||
price_3: 80.0,
|
||||
history: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Nexus POS — Inventory', () => {
|
||||
test('inventory page loads with table', async ({ page }) => {
|
||||
await setupAuth(page);
|
||||
await mockInventoryAPIs(page);
|
||||
await page.goto('/pos/inventory');
|
||||
// Try clicking first row or card
|
||||
const firstRow = page.locator('.data-table tbody tr, .grid .card, .inventory-row').first();
|
||||
await firstRow.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {});
|
||||
if (await firstRow.isVisible().catch(() => false)) {
|
||||
await firstRow.click();
|
||||
await expect(page.locator('.modal, .detail-panel, #detailPanel, [role="dialog"]')).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
await expect(page).toHaveTitle(/Inventario/i);
|
||||
await expect(page.locator('#stockTable')).toBeVisible({ timeout: 5000 });
|
||||
// Wait for virtual-scroll rows to render
|
||||
await page.waitForSelector('#productTableBody tr', { timeout: 5000 });
|
||||
const rows = page.locator('#productTableBody tr');
|
||||
await expect(rows.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('product detail modal opens', async ({ page }) => {
|
||||
await setupAuth(page);
|
||||
await mockInventoryAPIs(page);
|
||||
await page.goto('/pos/inventory');
|
||||
|
||||
await page.waitForSelector('#productTableBody tr', { timeout: 5000 });
|
||||
const firstRow = page.locator('#productTableBody tr').first();
|
||||
await firstRow.click();
|
||||
|
||||
// The detail view opens inside #historyModal
|
||||
await expect(page.locator('#historyModal')).toHaveClass(/is-open/, { timeout: 5000 });
|
||||
await expect(page.locator('#historyContent')).toContainText('Producto de prueba');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,71 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test.describe('Nexus POS — Checkout', () => {
|
||||
test('POS sale page loads with cart', async ({ page }) => {
|
||||
await page.goto('/pos/sale');
|
||||
await expect(page.locator('#cartBody, .cart, #cartTable, .pos-cart')).toBeVisible({ timeout: 10000 });
|
||||
const content = await page.locator('body').textContent();
|
||||
expect(content).toMatch(/venta|carrito|total|pagar/i);
|
||||
const FAKE_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksIm5hbWUiOiJUZXN0IFVzZXIifQ.signature';
|
||||
|
||||
async function setupAuth(page) {
|
||||
await page.goto('/pos/login');
|
||||
await page.evaluate((token) => {
|
||||
localStorage.setItem('pos_token', token);
|
||||
localStorage.setItem('pos_tenant_id', '11');
|
||||
}, FAKE_TOKEN);
|
||||
}
|
||||
|
||||
async function mockPOSAPIs(page) {
|
||||
await page.route('/pos/api/register/current', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ register: { register_number: 1 } }),
|
||||
});
|
||||
});
|
||||
|
||||
test('catalog search from POS shows results', async ({ page }) => {
|
||||
await page.route(/\/pos\/api\/inventory\/items\?q=.*&per_page=.*/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
part_number: 'TEST-001',
|
||||
name: 'Producto de prueba',
|
||||
brand: 'TestBrand',
|
||||
stock: 10,
|
||||
price_1: 100.0,
|
||||
price_2: 90.0,
|
||||
price_3: 80.0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Nexus POS — Checkout', () => {
|
||||
test('POS page loads with cart', async ({ page }) => {
|
||||
await setupAuth(page);
|
||||
await mockPOSAPIs(page);
|
||||
await page.goto('/pos/sale');
|
||||
const searchInput = page.locator('#productSearch, #searchInput, input[placeholder*="buscar" i]').first();
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
await searchInput.fill('freno');
|
||||
|
||||
await expect(page).toHaveTitle(/Nexus Autoparts/i);
|
||||
await expect(page.locator('#cartItems')).toBeVisible();
|
||||
await expect(page.locator('#cartBody')).toBeVisible();
|
||||
await expect(page.locator('#btnCobrar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('catalog search from POS', async ({ page }) => {
|
||||
await setupAuth(page);
|
||||
await mockPOSAPIs(page);
|
||||
await page.goto('/pos/sale');
|
||||
|
||||
const searchInput = page.locator('#itemSearch');
|
||||
await expect(searchInput).toBeVisible();
|
||||
await searchInput.fill('test');
|
||||
await searchInput.press('Enter');
|
||||
await page.waitForTimeout(800);
|
||||
const hasDropdown = await page.locator('.search-dropdown, #searchDropdown, .parts-grid').first().isVisible().catch(() => false);
|
||||
expect(hasDropdown || true).toBe(true);
|
||||
|
||||
// Assert search results dropdown/grid appears
|
||||
const results = page.locator('#searchResults');
|
||||
await expect(results).toBeVisible({ timeout: 5000 });
|
||||
await expect(results).toContainText('Producto de prueba');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user