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;
|
listen 80;
|
||||||
server_name nexusautoparts.com www.nexusautoparts.com;
|
server_name nexusautoparts.com www.nexusautoparts.com;
|
||||||
|
|
||||||
# Static asset caching
|
# POS static assets — served directly by nginx (not proxied)
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
# ^~ prevents regex locations from intercepting these requests
|
||||||
expires 6M;
|
location ^~ /pos/static/ {
|
||||||
add_header Cache-Control "public, immutable";
|
alias /home/Autopartes/pos/static/;
|
||||||
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;
|
|
||||||
expires 6M;
|
expires 6M;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
access_log off;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -75,11 +63,14 @@ server {
|
|||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header X-Frame-Options SAMEORIGIN always;
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
|
|
||||||
# Static asset caching
|
# POS static assets — served directly by nginx (not proxied)
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
# ^~ prevents regex locations from intercepting these requests
|
||||||
|
location ^~ /pos/static/ {
|
||||||
|
alias /home/Autopartes/pos/static/;
|
||||||
expires 6M;
|
expires 6M;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
access_log off;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ def create_app():
|
|||||||
from blueprints.pos_bp import pos_bp
|
from blueprints.pos_bp import pos_bp
|
||||||
app.register_blueprint(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
|
from blueprints.customers_bp import customers_bp
|
||||||
app.register_blueprint(customers_bp)
|
app.register_blueprint(customers_bp)
|
||||||
|
|
||||||
|
|||||||
@@ -356,8 +356,9 @@ def search():
|
|||||||
if not q or len(q) < 2:
|
if not q or len(q) < 2:
|
||||||
return jsonify({'data': []})
|
return jsonify({'data': []})
|
||||||
limit = request.args.get('limit', 50, type=int)
|
limit = request.args.get('limit', 50, type=int)
|
||||||
|
mye_id = request.args.get('mye_id', type=int)
|
||||||
def _do(master, tenant, branch_id):
|
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 jsonify({'data': data})
|
||||||
return _with_conns(_do)
|
return _with_conns(_do)
|
||||||
|
|
||||||
|
|||||||
@@ -1360,36 +1360,55 @@ def auto_match_item_vehicles(item_id):
|
|||||||
part_number, brand, name = row
|
part_number, brand, name = row
|
||||||
compat_source = get_compat_source(g.tenant_id)
|
compat_source = get_compat_source(g.tenant_id)
|
||||||
|
|
||||||
|
tecdoc_result = None
|
||||||
|
qwen_result = None
|
||||||
|
|
||||||
# TecDoc auto-match
|
# TecDoc auto-match
|
||||||
if compat_source in ('tecdoc', 'both'):
|
if compat_source in ('tecdoc', 'both'):
|
||||||
master = get_master_conn()
|
master = get_master_conn()
|
||||||
try:
|
try:
|
||||||
result = auto_match_vehicle_compatibility(master, conn, item_id, part_number,
|
tecdoc_result = auto_match_vehicle_compatibility(master, conn, item_id, part_number,
|
||||||
brand=brand, name=name)
|
brand=brand, name=name)
|
||||||
return jsonify(result)
|
|
||||||
finally:
|
finally:
|
||||||
master.close()
|
master.close()
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# QWEN AI auto-match
|
# QWEN AI auto-match
|
||||||
if compat_source == 'qwen':
|
if compat_source in ('qwen', 'both'):
|
||||||
try:
|
try:
|
||||||
from services.qwen_fitment import get_vehicle_fitment
|
from services.qwen_fitment import get_vehicle_fitment
|
||||||
from services.inventory_vehicle_compat import save_qwen_fitment
|
from services.inventory_vehicle_compat import save_qwen_fitment
|
||||||
fitment = get_vehicle_fitment(part_number, name, brand)
|
fitment = get_vehicle_fitment(part_number, name, brand)
|
||||||
inserted = save_qwen_fitment(conn, item_id, fitment)
|
inserted = save_qwen_fitment(conn, item_id, fitment)
|
||||||
conn.close()
|
qwen_myes = [v['mye_id'] for v in fitment.get('vehicles', []) if v.get('mye_id')]
|
||||||
return jsonify({
|
qwen_result = {
|
||||||
'matched': inserted > 0,
|
'matched': len(qwen_myes) > 0,
|
||||||
'matches': [],
|
'matches': [],
|
||||||
'myes': [v['mye_id'] for v in fitment.get('vehicles', []) if v.get('mye_id')],
|
'myes': qwen_myes,
|
||||||
'inserted': inserted,
|
'inserted': inserted,
|
||||||
})
|
'total_qwen': len(qwen_myes),
|
||||||
|
'confidence': fitment.get('confidence', 0),
|
||||||
|
'notes': fitment.get('notes', ''),
|
||||||
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.close()
|
qwen_result = {'error': str(e)}
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
|
|
||||||
conn.close()
|
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
|
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 json
|
||||||
|
import jwt
|
||||||
from datetime import datetime, date, timedelta
|
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 middleware import require_auth, has_permission
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
from services.pos_engine import (
|
from services.pos_engine import (
|
||||||
@@ -15,6 +16,7 @@ from services.pos_engine import (
|
|||||||
get_price_for_customer, get_margin_info
|
get_price_for_customer, get_margin_info
|
||||||
)
|
)
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
|
from config import JWT_SECRET
|
||||||
|
|
||||||
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
||||||
|
|
||||||
@@ -485,6 +487,16 @@ def create_quotation():
|
|||||||
currency, exchange_rate
|
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,
|
log_action(conn, 'QUOTATION_CREATE', 'quotation', quot_id,
|
||||||
new_value={'total': totals['total'], 'items_count': len(items)})
|
new_value={'total': totals['total'], 'items_count': len(items)})
|
||||||
|
|
||||||
@@ -766,6 +778,270 @@ def get_quotation(quot_id):
|
|||||||
return jsonify(quot)
|
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'])
|
@pos_bp.route('/quotations/<int:quot_id>/pdf', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth('pos.view')
|
||||||
def get_quotation_pdf(quot_id):
|
def get_quotation_pdf(quot_id):
|
||||||
@@ -1004,6 +1280,19 @@ def convert_quotation(quot_id):
|
|||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""", (sale['id'], quot_id))
|
""", (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()
|
conn.commit()
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify(sale), 201
|
return jsonify(sale), 201
|
||||||
@@ -1034,11 +1323,76 @@ def cancel_quotation(quot_id):
|
|||||||
return jsonify({'error': f'Quotation is already {quot[1]}'}), 400
|
return jsonify({'error': f'Quotation is already {quot[1]}'}), 400
|
||||||
|
|
||||||
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (quot_id,))
|
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()
|
conn.commit()
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify({'message': 'Quotation cancelled'})
|
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) ────────────────────────
|
# ─── Layaways (Apartados) ────────────────────────
|
||||||
|
|
||||||
@pos_bp.route('/layaways', methods=['POST'])
|
@pos_bp.route('/layaways', methods=['POST'])
|
||||||
@@ -1967,3 +2321,109 @@ def print_ticket(sale_id):
|
|||||||
raw = generate_ticket(sale_data, business_info, width=width)
|
raw = generate_ticket(sale_data, business_info, width=width)
|
||||||
return Response(raw, mimetype='application/octet-stream',
|
return Response(raw, mimetype='application/octet-stream',
|
||||||
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
|
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 flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth
|
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
|
from services import whatsapp_service
|
||||||
|
|
||||||
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
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.
|
"""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:
|
Returns:
|
||||||
(formatted_text, first_part_dict) — first_part_dict is used by the
|
(formatted_text, first_part_dict) — first_part_dict is used by the
|
||||||
quotation system to know what to add when the user says "cotizar".
|
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
|
return None, None
|
||||||
|
|
||||||
try:
|
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
|
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
|
# Split search_query by '|' into individual terms
|
||||||
conditions = []
|
raw_terms = [t.strip() for t in (search_query or '').split('|') if t.strip()]
|
||||||
params = []
|
if not raw_terms:
|
||||||
for term in search_terms:
|
raw_terms = [search_query] if search_query else []
|
||||||
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])
|
|
||||||
|
|
||||||
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()
|
search_terms = list(search_terms)
|
||||||
cur.execute(f"""
|
if not search_terms:
|
||||||
SELECT i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
|
return None, None
|
||||||
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)
|
|
||||||
|
|
||||||
rows = cur.fetchall()
|
# Vehicle-aware filtering
|
||||||
cur.close()
|
mye_ids = _resolve_mye_ids(vehicle, master_conn)
|
||||||
|
|
||||||
if not rows:
|
def _do_search(use_compat=True):
|
||||||
return ('❌ No tenemos esa parte en inventario actualmente.\n'
|
"""Run inventory search. Returns list of rows."""
|
||||||
'_Puedes preguntar por otra parte o visitarnos en tienda._'), None
|
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
|
where_search = ' OR '.join(conditions)
|
||||||
in_stock = [r for r in rows if r[6] > 0]
|
compat_clause = ""
|
||||||
out_stock = [r for r in rows if r[6] <= 0]
|
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)
|
best = in_stock[0] if in_stock else (out_stock[0] if out_stock else None)
|
||||||
first_part = None
|
first_part = None
|
||||||
if best:
|
if best:
|
||||||
first_part = {
|
first_part = {
|
||||||
'inventory_id': None, # we'd need the id — fetch it
|
'inventory_id': best[0],
|
||||||
'part_number': best[0],
|
'part_number': best[1],
|
||||||
'name': best[1],
|
'name': best[2],
|
||||||
'brand': best[2] or '',
|
'brand': best[3] or '',
|
||||||
'price': float(best[3]) if best[3] else 0,
|
'price': float(best[4]) if best[4] else 0,
|
||||||
'tax_rate': 0.16,
|
'tax_rate': 0.16,
|
||||||
'stock': best[6],
|
'stock': best[7],
|
||||||
'unit': best[7] or 'PZA',
|
'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 = []
|
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:
|
if in_stock:
|
||||||
lines.append('✅ *Tenemos en stock:*')
|
lines.append('✅ *Tenemos en stock:*')
|
||||||
lines.append('')
|
lines.append('')
|
||||||
for r in in_stock:
|
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 ''
|
brand_str = f'*{brand}*' if brand else ''
|
||||||
price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio'
|
price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio'
|
||||||
lines.append(f' • {brand_str} {name}')
|
lines.append(f' • {brand_str} {name}')
|
||||||
lines.append(f' #{part_num} — {price_str} ({stock} {unit or "pzas"} disponibles)')
|
lines.append(f' #{part_num} — {price_str} ({stock} {unit or "pzas"} disponibles)')
|
||||||
lines.append('')
|
lines.append('')
|
||||||
else:
|
elif out_stock:
|
||||||
lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*')
|
lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*')
|
||||||
lines.append('')
|
lines.append('')
|
||||||
for r in out_stock[:5]:
|
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 ''
|
brand_str = f'*{brand}*' if brand else ''
|
||||||
price_str = f'${float(p1):,.2f}' if p1 else ''
|
price_str = f'${float(p1):,.2f}' if p1 else ''
|
||||||
lines.append(f' • {brand_str} {name} #{part_num} {price_str}')
|
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:
|
except Exception as e:
|
||||||
print(f"[WA-AI] Enrichment error: {e}")
|
print(f"[WA-AI] Enrichment error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return None, None
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
@@ -194,9 +316,11 @@ def webhook():
|
|||||||
# TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives.
|
# TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives.
|
||||||
tenant_id = 11
|
tenant_id = 11
|
||||||
tenant_conn = None
|
tenant_conn = None
|
||||||
|
master_conn = None
|
||||||
inventory_context = None
|
inventory_context = None
|
||||||
try:
|
try:
|
||||||
tenant_conn = get_tenant_conn(tenant_id)
|
tenant_conn = get_tenant_conn(tenant_id)
|
||||||
|
master_conn = get_master_conn()
|
||||||
|
|
||||||
# 1. Log the incoming message (with contact display name)
|
# 1. Log the incoming message (with contact display name)
|
||||||
cur = tenant_conn.cursor()
|
cur = tenant_conn.cursor()
|
||||||
@@ -216,6 +340,22 @@ def webhook():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WA-AI] inventory_context failed: {e}")
|
print(f"[WA-AI] inventory_context failed: {e}")
|
||||||
inventory_context = None
|
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:
|
except Exception as e:
|
||||||
print(f"[WA-AI] tenant connection failed: {e}")
|
print(f"[WA-AI] tenant connection failed: {e}")
|
||||||
|
|
||||||
@@ -281,6 +421,33 @@ def webhook():
|
|||||||
else:
|
else:
|
||||||
reply = '⚠️ No tienes una cotización abierta para confirmar.'
|
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:
|
if intent is not None:
|
||||||
# It was a quote command — send reply and skip the AI
|
# It was a quote command — send reply and skip the AI
|
||||||
if reply:
|
if reply:
|
||||||
@@ -299,6 +466,13 @@ def webhook():
|
|||||||
except Exception: pass
|
except Exception: pass
|
||||||
return jsonify({'ok': True})
|
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:
|
try:
|
||||||
if media_kind == 'image' and msg.get('media_base64'):
|
if media_kind == 'image' and msg.get('media_base64'):
|
||||||
from services.ai_chat import chat_with_image
|
from services.ai_chat import chat_with_image
|
||||||
@@ -308,6 +482,7 @@ def webhook():
|
|||||||
ai_resp = chat_with_image(
|
ai_resp = chat_with_image(
|
||||||
user_message=prompt,
|
user_message=prompt,
|
||||||
image_base64=msg['media_base64'],
|
image_base64=msg['media_base64'],
|
||||||
|
conversation_history=conversation_history,
|
||||||
inventory_context=inventory_context,
|
inventory_context=inventory_context,
|
||||||
)
|
)
|
||||||
reply = ai_resp.get('message', '') or ''
|
reply = ai_resp.get('message', '') or ''
|
||||||
@@ -332,7 +507,7 @@ def webhook():
|
|||||||
if transcript:
|
if transcript:
|
||||||
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
|
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
|
||||||
from services.ai_chat import chat
|
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 ''
|
reply = ai_resp.get('message', '') or ''
|
||||||
# Prefix the reply so the sender knows we understood the voice note
|
# Prefix the reply so the sender knows we understood the voice note
|
||||||
if reply:
|
if reply:
|
||||||
@@ -344,16 +519,25 @@ def webhook():
|
|||||||
elif msg.get('text'):
|
elif msg.get('text'):
|
||||||
# Plain text message — standard chatbot flow
|
# Plain text message — standard chatbot flow
|
||||||
from services.ai_chat import chat
|
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 ''
|
reply = ai_resp.get('message', '') or ''
|
||||||
|
|
||||||
# Enrich: if the AI returned a search_query, look up real parts
|
# Enrich: if the AI returned a search_query, look up real parts
|
||||||
# from the catalog and append them to the WhatsApp reply.
|
# from the catalog and append them to the WhatsApp reply.
|
||||||
search_q = ai_resp.get('search_query')
|
search_q = ai_resp.get('search_query')
|
||||||
vehicle = ai_resp.get('vehicle')
|
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:
|
if search_q and reply:
|
||||||
try:
|
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:
|
if enrichment:
|
||||||
reply = reply + '\n\n' + enrichment
|
reply = reply + '\n\n' + enrichment
|
||||||
# Track the found part so "cotizar" can add it
|
# Track the found part so "cotizar" can add it
|
||||||
@@ -384,12 +568,17 @@ def webhook():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {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:
|
if tenant_conn is not None:
|
||||||
try:
|
try:
|
||||||
tenant_conn.close()
|
tenant_conn.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if master_conn is not None:
|
||||||
|
try:
|
||||||
|
master_conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ if not OPENROUTER_API_KEY:
|
|||||||
RuntimeWarning
|
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 ──────────────────────────────────────────────────────────────────
|
||||||
SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com')
|
SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com')
|
||||||
SMTP_PORT = int(os.environ.get('SMTP_PORT', '587'))
|
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 Access ────────────────────────────────────────────────────
|
||||||
CATALOG_OEM_ENABLED = os.environ.get('CATALOG_OEM_ENABLED', 'false').lower() == 'true'
|
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 requests
|
||||||
import json
|
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"
|
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.
|
# ⚠️ SOLO MODELOS GRATUITOS — No cambiar a modelos de pago.
|
||||||
# El modelo DEBE terminar en ":free" para garantizar costo $0.
|
# 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
|
"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):
|
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'):
|
if not model_id.endswith(':free'):
|
||||||
raise ValueError(f"BLOQUEADO: Solo se permiten modelos gratuitos (:free). Modelo '{model_id}' no es gratuito.")
|
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.
|
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:
|
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"
|
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.
|
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:
|
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})
|
messages.append({"role": "user", "content": user_content})
|
||||||
|
|
||||||
import time
|
# Try Hermes first for vision (if enabled), fallback to OpenRouter
|
||||||
max_retries = 3
|
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))
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
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:
|
try:
|
||||||
resp = requests.post(
|
stripped = content.strip()
|
||||||
OPENROUTER_URL,
|
if stripped.startswith("```"):
|
||||||
headers={
|
lines = stripped.split("\n")
|
||||||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
json_str = "\n".join(lines[1:-1])
|
||||||
"Content-Type": "application/json",
|
parsed = json.loads(json_str)
|
||||||
},
|
|
||||||
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)
|
|
||||||
return parsed
|
return parsed
|
||||||
except (json.JSONDecodeError, IndexError):
|
else:
|
||||||
return {"message": content, "search_query": None, "vehicle": None}
|
parsed = json.loads(stripped)
|
||||||
except Exception as e:
|
return parsed
|
||||||
if attempt < max_retries - 1:
|
except (json.JSONDecodeError, IndexError):
|
||||||
continue
|
return {"message": content, "search_query": None, "vehicle": None}
|
||||||
return {
|
|
||||||
"message": f"Error al analizar imagen: {str(e)}",
|
if last_error == "api_error":
|
||||||
"search_query": None,
|
return {"message": "El asistente esta ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
|
||||||
"vehicle": None,
|
return {
|
||||||
}
|
"message": f"Error al analizar imagen: {last_error}",
|
||||||
|
"search_query": None,
|
||||||
|
"vehicle": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def classify_part(part_number):
|
def classify_part(part_number):
|
||||||
@@ -287,47 +339,32 @@ def classify_part(part_number):
|
|||||||
{"role": "user", "content": prompt}
|
{"role": "user", "content": prompt}
|
||||||
]
|
]
|
||||||
|
|
||||||
import time
|
# Try Hermes first (if enabled), fallback to OpenRouter
|
||||||
max_retries = 3
|
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 attempt in range(max_retries):
|
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:
|
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()
|
stripped = content.strip()
|
||||||
if stripped.startswith("```"):
|
if stripped.startswith("```"):
|
||||||
lines = stripped.split("\n")
|
lines = stripped.split("\n")
|
||||||
json_str = "\n".join(lines[1:-1])
|
json_str = "\n".join(lines[1:-1])
|
||||||
parsed = json.loads(json_str)
|
parsed = json.loads(json_str)
|
||||||
|
return parsed
|
||||||
else:
|
else:
|
||||||
parsed = json.loads(stripped)
|
parsed = json.loads(stripped)
|
||||||
return parsed
|
return parsed
|
||||||
except Exception:
|
except Exception:
|
||||||
if attempt < max_retries - 1:
|
continue
|
||||||
continue
|
return {"name": None, "brand": None, "vehicle": None, "category": None}
|
||||||
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
|
last_error = None
|
||||||
|
|
||||||
# Try each model in the fallback chain on 429 (rate limit)
|
# Build backend list: QWEN first (fast, ~1s), then Hermes (specialized, ~30s), then OpenRouter
|
||||||
for model_id in FALLBACK_MODELS:
|
backends = []
|
||||||
_validate_model(model_id) # Block paid models
|
if QWEN_ENABLED:
|
||||||
try:
|
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 35, SYSTEM_PROMPT_SHORT, 4000))
|
||||||
resp = requests.post(
|
if HERMES_ENABLED:
|
||||||
OPENROUTER_URL,
|
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL, 45, SYSTEM_PROMPT, 800))
|
||||||
headers={
|
if OPENROUTER_API_KEY:
|
||||||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
for m in FALLBACK_MODELS:
|
||||||
"Content-Type": "application/json",
|
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, m, 25, SYSTEM_PROMPT, 800))
|
||||||
},
|
|
||||||
json={
|
for url, key, model_id, timeout_sec, sys_prompt, max_tok in backends:
|
||||||
"model": model_id,
|
_validate_model(model_id)
|
||||||
"messages": messages,
|
# Use backend-specific system prompt and max_tokens
|
||||||
"max_tokens": 800,
|
sys_content = sys_prompt
|
||||||
"temperature": 0.3,
|
if inventory_context:
|
||||||
},
|
sys_content = sys_prompt + "\n\n" + inventory_context
|
||||||
timeout=25,
|
msgs = [{"role": "system", "content": sys_content}]
|
||||||
)
|
if conversation_history:
|
||||||
if resp.status_code == 429:
|
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...")
|
print(f"[AI] Rate limited on {model_id}, trying next model...")
|
||||||
last_error = "rate_limit"
|
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
|
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
|
# All models exhausted — DON'T cache errors, we want retries next time
|
||||||
if last_error == "rate_limit":
|
if last_error == "rate_limit":
|
||||||
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
|
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 {
|
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,
|
"search_query": None,
|
||||||
"vehicle": None,
|
"vehicle": None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import re
|
|||||||
import redis
|
import redis
|
||||||
|
|
||||||
from services.na_models import is_na_model
|
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
|
# Lazy Redis client for catalog caches
|
||||||
_redis_client = None
|
_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,
|
def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
|
||||||
part_type_slug, tenant_conn, branch_id,
|
part_type_slug, tenant_conn, branch_id,
|
||||||
page=1, per_page=30):
|
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
|
# Inject local inventory items linked to this vehicle
|
||||||
# (get_parts_local with oem_part_ids skips mye_id, so we call it separately)
|
# (get_parts_local with oem_part_ids skips mye_id, so we call it separately)
|
||||||
|
local_injected = 0
|
||||||
if tenant_conn and mye_id:
|
if tenant_conn and mye_id:
|
||||||
from services.inventory_vehicle_compat import get_inventory_by_vehicle
|
from services.inventory_vehicle_compat import get_inventory_by_vehicle
|
||||||
local_rows = get_inventory_by_vehicle(tenant_conn, master_conn, mye_id, branch_id)
|
local_rows = get_inventory_by_vehicle(tenant_conn, master_conn, mye_id, branch_id)
|
||||||
for lr in local_rows:
|
for lr in local_rows:
|
||||||
inv_id, pn, name, brand, p1, p2, p3, img, desc, stock = lr
|
inv_id, pn, name, brand, p1, p2, p3, img, desc, stock = lr
|
||||||
# Only include if name roughly matches the Nexpart part_type
|
# 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
|
continue
|
||||||
result['data'].append({
|
result['data'].append({
|
||||||
'id_part': f'inv:{inv_id}',
|
'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,
|
'price_usd': None,
|
||||||
'source': 'local_inventory',
|
'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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -1299,13 +1422,14 @@ def _search_meili_fallback(master_conn, q, limit):
|
|||||||
return None
|
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.
|
"""Search parts by part number or text. Enriches with local stock.
|
||||||
|
|
||||||
Strategy:
|
Strategy:
|
||||||
1. Try Meilisearch first (sub-100ms full-text + typo tolerance)
|
1. Try Meilisearch first (sub-100ms full-text + typo tolerance)
|
||||||
2. Fallback to PostgreSQL tsvector / ILIKE if Meilisearch is down
|
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()
|
q = q.strip()
|
||||||
if not q or len(q) < 2:
|
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))
|
""", (tsquery, f'%{q}%', f'%{q}%', tsquery, limit))
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|
||||||
if not rows:
|
|
||||||
cur.close()
|
|
||||||
return []
|
|
||||||
|
|
||||||
part_ids = [r[0] for r in rows]
|
part_ids = [r[0] for r in rows]
|
||||||
oem_numbers = [r[1] 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)
|
local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, part_ids)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
seen_local_ids = set()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
part_id = r[0]
|
part_id = r[0]
|
||||||
oem = r[1]
|
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,
|
'local_price': local['price_1'] if local else None,
|
||||||
'vehicle_info': vehicle_info_map.get(part_id, ''),
|
'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
|
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
|
# 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,
|
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.
|
"""Record a single inventory operation. Does NOT commit — caller controls transaction.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
quantity: positive for entries (PURCHASE, RETURN, INITIAL), negative for exits (SALE)
|
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 = conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -113,7 +114,7 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
|
|||||||
""", (
|
""", (
|
||||||
inventory_id, branch_id, operation_type, quantity,
|
inventory_id, branch_id, operation_type, quantity,
|
||||||
reference_id, reference_type, cost_at_time,
|
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'),
|
_safe_g('device_id'),
|
||||||
notes
|
notes
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -403,3 +403,56 @@ def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
|
|||||||
tenant_conn.commit()
|
tenant_conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
return inserted
|
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={
|
json={
|
||||||
'model': QWEN_MODEL,
|
'model': QWEN_MODEL,
|
||||||
'messages': [
|
'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}
|
{'role': 'user', 'content': prompt}
|
||||||
],
|
],
|
||||||
'temperature': 0.2,
|
'temperature': 0.2,
|
||||||
'max_tokens': 2048,
|
'max_tokens': 4096,
|
||||||
},
|
},
|
||||||
timeout=45,
|
timeout=45,
|
||||||
)
|
)
|
||||||
@@ -86,29 +86,37 @@ def get_vehicle_fitment(part_number, name, brand):
|
|||||||
|
|
||||||
def _build_prompt(part_number, name, brand):
|
def _build_prompt(part_number, name, brand):
|
||||||
brand_str = brand or 'desconocida'
|
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}
|
- Numero de parte: {part_number}
|
||||||
- Nombre: {name}
|
- Nombre/descripcion: {name}
|
||||||
- Marca del vehiculo: {brand_str}
|
- 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": [
|
"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,
|
"confidence": 0.92,
|
||||||
"notes": "Compatible con motor 2ZR-FE"
|
"notes": "Compatible con plataforma E170. Verificar traccion delantera."
|
||||||
}}
|
}}
|
||||||
|
|
||||||
Reglas:
|
Reglas obligatorias:
|
||||||
1. "make" es la marca del vehiculo (ej: Toyota, Nissan, Ford, Volkswagen).
|
1. "make" = marca del vehiculo (ej: Toyota, Nissan, Ford, Volkswagen, Chevrolet, Honda, Hyundai, Kia, Mazda, Subaru).
|
||||||
2. "model" es el modelo exacto.
|
2. "model" = modelo exacto. Si hay variantes (ej: Civic Sedan vs Civic Coupe), incluye la variante.
|
||||||
3. "year" es el ano numerico (int). Si hay rango de anos, usa el ano inicial.
|
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" es la descripcion del motor (ej: "1.8L", "2.0L TDI", "V6 3.5L").
|
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. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 30.
|
5. "engine_code" = codigo exacto del motor SI LO CONOCES (ej: "2ZR-FE", "K24Z7", "EA888"). Si no lo conoces, usa "" (string vacio).
|
||||||
6. Si no conoces el motor exacto, usa "desconocido".
|
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.
|
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 ''
|
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 ''
|
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 = 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")
|
# Parse year (may be int, string, or range like "2003-2008")
|
||||||
years = []
|
years = []
|
||||||
@@ -167,11 +176,31 @@ def _normalize_vehicle(v):
|
|||||||
if m2:
|
if m2:
|
||||||
years = [int(m2.group(1))]
|
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):
|
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
|
from tenant_db import get_master_conn
|
||||||
try:
|
try:
|
||||||
master = get_master_conn()
|
master = get_master_conn()
|
||||||
@@ -183,30 +212,66 @@ def _validate_vehicles(vehicles):
|
|||||||
seen_mye = set()
|
seen_mye = set()
|
||||||
|
|
||||||
for v in vehicles:
|
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:
|
if not make or not model or not years:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for year in years:
|
for year in years:
|
||||||
# First try with exact engine match; if no result, fall back to
|
matched_myes = []
|
||||||
# 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()
|
|
||||||
|
|
||||||
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("""
|
cur.execute("""
|
||||||
SELECT mye.id_mye
|
SELECT mye.id_mye
|
||||||
FROM model_year_engine mye
|
FROM model_year_engine mye
|
||||||
@@ -216,19 +281,21 @@ def _validate_vehicles(vehicles):
|
|||||||
WHERE b.name_brand ILIKE %s
|
WHERE b.name_brand ILIKE %s
|
||||||
AND m.name_model ILIKE %s
|
AND m.name_model ILIKE %s
|
||||||
AND y.year_car = %s
|
AND y.year_car = %s
|
||||||
LIMIT 1
|
|
||||||
""", (make, f'%{model}%', year))
|
""", (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:
|
# Deduplicate and add to results
|
||||||
seen_mye.add(row[0])
|
for mye_id in matched_myes:
|
||||||
validated.append({
|
if mye_id not in seen_mye:
|
||||||
'make': make,
|
seen_mye.add(mye_id)
|
||||||
'model': model,
|
validated.append({
|
||||||
'year': year,
|
'make': make,
|
||||||
'engine': engine,
|
'model': model,
|
||||||
'mye_id': row[0],
|
'year': year,
|
||||||
})
|
'engine': engine,
|
||||||
|
'engine_code': engine_code,
|
||||||
|
'mye_id': mye_id,
|
||||||
|
})
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
master.close()
|
master.close()
|
||||||
|
|||||||
@@ -109,11 +109,30 @@ def confirm_quotation(tenant_conn, phone):
|
|||||||
return qid
|
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.
|
# 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):
|
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,
|
part_info: dict with keys inventory_id, part_number, name, brand,
|
||||||
price, stock, unit
|
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):
|
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):
|
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 ─────────────────────────────────────────────────
|
# ─── Quotation CRUD ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -217,14 +217,14 @@
|
|||||||
|
|
||||||
/* ─── Right panel: chat view ─────────────────────────────────────── */
|
/* ─── Right panel: chat view ─────────────────────────────────────── */
|
||||||
|
|
||||||
.chat-panel {
|
.wa-chat-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--color-bg-base);
|
background: var(--color-bg-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-panel__header {
|
.wa-chat-panel__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -234,18 +234,18 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-panel__phone {
|
.wa-chat-panel__phone {
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
font-size: var(--text-body);
|
font-size: var(--text-body);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-panel__actions {
|
.wa-chat-panel__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-panel__messages {
|
.wa-chat-panel__messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
@@ -500,7 +500,7 @@
|
|||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.conv-panel { width: 100%; min-width: 0; }
|
.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 .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) {
|
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) {
|
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.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');
|
searchDropdown.classList.add('is-visible');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
searchDropdown.innerHTML = data.data.map(function (r) {
|
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
|
var stockLabel = r.local_stock > 0
|
||||||
? '<span class="stock-badge stock-badge--local" style="margin-left:auto;">Stock: ' + r.local_stock + '</span>'
|
? '<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 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>' +
|
'<div class="search-result__name">' + esc(r.name) + '</div>' +
|
||||||
(r.vehicle_info ? '<div class="search-result__vehicle">' + esc(r.vehicle_info) + '</div>' : '') +
|
(r.vehicle_info ? '<div class="search-result__vehicle">' + esc(r.vehicle_info) + '</div>' : '') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -1408,6 +1417,12 @@
|
|||||||
searchDropdown.classList.remove('is-visible');
|
searchDropdown.classList.remove('is-visible');
|
||||||
var pid = this.dataset.partId;
|
var pid = this.dataset.partId;
|
||||||
if (typeof pid === 'string' && pid.indexOf('inv:') === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
openPartDetail(parseInt(pid));
|
openPartDetail(parseInt(pid));
|
||||||
|
|||||||
@@ -14,6 +14,16 @@
|
|||||||
var currentSearch = '';
|
var currentSearch = '';
|
||||||
var draftCountId = null;
|
var draftCountId = null;
|
||||||
var inventoryVS = 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 ---
|
// --- API helper ---
|
||||||
function apiFetch(url, opts) {
|
function apiFetch(url, opts) {
|
||||||
@@ -695,7 +705,9 @@
|
|||||||
} else {
|
} else {
|
||||||
html2 += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Sin vehiculos vinculados.</p>';
|
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;
|
el.innerHTML = html2;
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
@@ -735,7 +747,18 @@
|
|||||||
headers: { 'Authorization': 'Bearer ' + token }
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
}).then(function(r) { return r.json(); })
|
}).then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.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);
|
viewProductDetail(itemId);
|
||||||
}).catch(function() { alert('Error en auto-match'); });
|
}).catch(function() { alert('Error en auto-match'); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,8 +82,10 @@ const POS = (() => {
|
|||||||
console.warn('Could not parse token:', e);
|
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 catalogCart = localStorage.getItem('pos_cart');
|
||||||
|
const editQuoteId = localStorage.getItem('pos_edit_quote_id');
|
||||||
|
const convertQuoteId = localStorage.getItem('pos_convert_quote_id');
|
||||||
if (catalogCart) {
|
if (catalogCart) {
|
||||||
try {
|
try {
|
||||||
const items = JSON.parse(catalogCart);
|
const items = JSON.parse(catalogCart);
|
||||||
@@ -93,6 +95,12 @@ const POS = (() => {
|
|||||||
localStorage.removeItem('pos_cart');
|
localStorage.removeItem('pos_cart');
|
||||||
} catch (e) { console.warn('Could not load catalog cart:', e); }
|
} 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
|
// Load current register
|
||||||
await loadRegister();
|
await loadRegister();
|
||||||
@@ -702,10 +710,29 @@ const POS = (() => {
|
|||||||
confirmBtn.textContent = 'Procesando...';
|
confirmBtn.textContent = 'Procesando...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sale = await api('/pos/api/sales', {
|
const convertQuoteId = localStorage.getItem('pos_convert_quote_id');
|
||||||
method: 'POST',
|
let sale;
|
||||||
body: JSON.stringify(saleData),
|
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;
|
lastSaleId = sale.id;
|
||||||
lastSaleData = sale;
|
lastSaleData = sale;
|
||||||
@@ -717,8 +744,6 @@ const POS = (() => {
|
|||||||
selectedRow = -1;
|
selectedRow = -1;
|
||||||
clearCustomer();
|
clearCustomer();
|
||||||
renderCart();
|
renderCart();
|
||||||
|
|
||||||
showToast(`Venta #${sale.id} completada`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Error al procesar venta: ' + e.message);
|
alert('Error al procesar venta: ' + e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -790,12 +815,25 @@ const POS = (() => {
|
|||||||
customer_id: currentCustomer ? currentCustomer.id : null,
|
customer_id: currentCustomer ? currentCustomer.id : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editQuoteId = localStorage.getItem('pos_edit_quote_id');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api('/pos/api/quotations', {
|
if (editQuoteId) {
|
||||||
method: 'POST',
|
const result = await api('/pos/api/quotations/' + editQuoteId, {
|
||||||
body: JSON.stringify(body),
|
method: 'PUT',
|
||||||
});
|
body: JSON.stringify(body),
|
||||||
showToast(`Cotizacion #${result.id} guardada. Total: ${fmt(result.total)}`);
|
});
|
||||||
|
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) {
|
} catch (e) {
|
||||||
alert('Error: ' + e.message);
|
alert('Error: ' + e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,13 +65,13 @@
|
|||||||
// -- DOM refs --------------------------------------------------------------
|
// -- DOM refs --------------------------------------------------------------
|
||||||
|
|
||||||
var convList = document.getElementById('convList');
|
var convList = document.getElementById('convList');
|
||||||
var chatMessages = document.getElementById('chatMessages');
|
var chatMessages = document.getElementById('waChatMessages') || document.getElementById('chatMessages');
|
||||||
var chatHeader = document.getElementById('chatHeaderPhone');
|
var chatHeader = document.getElementById('chatHeaderPhone');
|
||||||
var chatInput = document.getElementById('chatInput');
|
var chatInput = document.getElementById('waChatInput') || document.getElementById('chatInput');
|
||||||
var sendBtn = document.getElementById('sendBtn');
|
var sendBtn = document.getElementById('waSendBtn') || document.getElementById('sendBtn');
|
||||||
var newChatBtn = document.getElementById('newChatBtn');
|
var newChatBtn = document.getElementById('newChatBtn');
|
||||||
var emptyState = document.getElementById('emptyState');
|
var emptyState = document.getElementById('emptyState');
|
||||||
var chatPanel = document.getElementById('chatPanel');
|
var chatPanel = document.getElementById('waChatPanel') || document.getElementById('chatPanel');
|
||||||
var statusDot = document.getElementById('statusDot');
|
var statusDot = document.getElementById('statusDot');
|
||||||
var statusText = document.getElementById('statusText');
|
var statusText = document.getElementById('statusText');
|
||||||
var connectSection = document.getElementById('connectSection');
|
var connectSection = document.getElementById('connectSection');
|
||||||
@@ -275,6 +275,7 @@
|
|||||||
activePhone = null;
|
activePhone = null;
|
||||||
chatPanel.style.display = 'none';
|
chatPanel.style.display = 'none';
|
||||||
emptyState.style.display = '';
|
emptyState.style.display = '';
|
||||||
|
if (messengerArea) messengerArea.classList.remove('has-active-chat');
|
||||||
}
|
}
|
||||||
loadConversations();
|
loadConversations();
|
||||||
} else {
|
} else {
|
||||||
@@ -300,42 +301,65 @@
|
|||||||
var activeContactName = '';
|
var activeContactName = '';
|
||||||
|
|
||||||
function openConversation(phone, contactName) {
|
function openConversation(phone, contactName) {
|
||||||
activePhone = phone;
|
try {
|
||||||
// Use contact name if available; fall back to formatted phone
|
console.log('[WA-UI] Opening conversation:', phone, contactName);
|
||||||
var isLid = phone.length > 13 || !/^(52|1|44|34)/.test(phone);
|
activePhone = phone;
|
||||||
activeContactName = contactName || '';
|
// Use contact name if available; fall back to formatted phone
|
||||||
chatHeader.textContent = activeContactName || (isLid ? 'Contacto WhatsApp' : fmtPhone(phone));
|
var isLid = phone.length > 13 || !/^(52|1|44|34)/.test(phone);
|
||||||
emptyState.style.display = 'none';
|
activeContactName = contactName || '';
|
||||||
chatPanel.style.display = 'flex';
|
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) {
|
convList.querySelectorAll('.conv-item').forEach(function (el) {
|
||||||
el.classList.toggle('is-active', el.getAttribute('data-phone') === phone);
|
el.classList.toggle('is-active', el.getAttribute('data-phone') === phone);
|
||||||
});
|
});
|
||||||
|
|
||||||
loadMessages(phone);
|
loadMessages(phone);
|
||||||
startPolling();
|
startPolling();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[WA-UI] openConversation error:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMessages(phone) {
|
function loadMessages(phone) {
|
||||||
|
console.log('[WA-UI] loadMessages start:', phone);
|
||||||
api('GET', '/conversations/' + encodeURIComponent(phone)).then(function (data) {
|
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 || [];
|
var msgs = data.messages || [];
|
||||||
|
console.log('[WA-UI] loadMessages messages count:', msgs.length);
|
||||||
renderMessages(msgs);
|
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) {
|
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 = '';
|
var html = '';
|
||||||
msgs.forEach(function (m) {
|
msgs.forEach(function (m) {
|
||||||
var cls = m.direction === 'outgoing' ? 'msg-bubble--out' : 'msg-bubble--in';
|
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 || '';
|
var text = m.message_text || m.text || '';
|
||||||
// Support both 'created_at' and 'date' keys
|
|
||||||
var time = m.created_at || m.date || '';
|
var time = m.created_at || m.date || '';
|
||||||
html += '<div class="msg-bubble ' + cls + '">'
|
html += '<div class="msg-bubble ' + cls + '">'
|
||||||
+ '<div class="msg-bubble__text">' + escHtml(text).replace(/\n/g, '<br>') + '</div>'
|
+ '<div class="msg-bubble__text">' + escHtml(text).replace(/\n/g, '<br>') + '</div>'
|
||||||
+ '<div class="msg-bubble__meta">' + fmtTime(time) + '</div>'
|
+ '<div class="msg-bubble__meta">' + fmtTime(time) + '</div>'
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
});
|
});
|
||||||
|
console.log('[WA-UI] renderMessages HTML length:', html.length);
|
||||||
chatMessages.innerHTML = html || '<div class="chat-empty">Sin mensajes</div>';
|
chatMessages.innerHTML = html || '<div class="chat-empty">Sin mensajes</div>';
|
||||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
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
|
// Nexus POS — Service Worker v3
|
||||||
// Self-contained vanilla JS. No external imports.
|
// Self-contained vanilla JS. No external imports.
|
||||||
|
|
||||||
const CACHE_NAME = 'nexus-pos-v3';
|
const CACHE_NAME = 'nexus-pos-v4';
|
||||||
|
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
'/pos/login',
|
'/pos/login',
|
||||||
@@ -176,7 +176,11 @@ self.addEventListener('fetch', function (event) {
|
|||||||
return;
|
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) {
|
if (url.pathname.indexOf('/pos/api/') !== -1) {
|
||||||
event.respondWith(networkFirst(req));
|
event.respondWith(networkFirst(req));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -273,7 +273,7 @@
|
|||||||
<script src="/pos/static/js/app-init.js" defer></script>
|
<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/pos-utils.js" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.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/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/chat.js" defer></script>
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.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/pos-utils.js" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.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/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/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.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>
|
<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 style="font-size:var(--text-h5);font-weight:700;color:var(--color-text-accent);">Total: $' + fmt(q.total) + '</div>';
|
||||||
html += '</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="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="exportVisibleTableCSV(\'cotizacion_' + q.id + '\')">Exportar CSV</button>';
|
||||||
html += '<button class="btn btn--ghost" onclick="window.print()">Imprimir</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
|
// Close modal on outside click
|
||||||
document.getElementById('quoteModal').addEventListener('click', function(e) {
|
document.getElementById('quoteModal').addEventListener('click', function(e) {
|
||||||
if (e.target === this) this.classList.remove('open');
|
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>
|
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>WhatsApp — Nexus Autoparts POS</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.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 class="empty-state__hint">Los mensajes de WhatsApp aparecen aqui en tiempo real</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-panel" id="chatPanel" style="display:none">
|
<div class="wa-chat-panel" id="waChatPanel" style="display:none">
|
||||||
<div class="chat-panel__header">
|
<div class="wa-chat-panel__header">
|
||||||
<span class="chat-panel__phone" id="chatHeaderPhone"></span>
|
<button class="btn btn--sm" id="backToListBtn" style="display:none;margin-right:8px;">← Volver</button>
|
||||||
<div class="chat-panel__actions">
|
<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">
|
<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">
|
<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"/>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="chat-panel__messages" id="chatMessages"></div>
|
<div class="wa-chat-panel__messages" id="waChatMessages"></div>
|
||||||
|
|
||||||
<div class="chat-input-bar">
|
<div class="chat-input-bar">
|
||||||
<textarea id="chatInput" placeholder="Escribe un mensaje..." rows="1"></textarea>
|
<textarea id="waChatInput" placeholder="Escribe un mensaje..." rows="1"></textarea>
|
||||||
<button class="btn btn--primary" id="sendBtn">
|
<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">
|
<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"/>
|
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -127,7 +131,7 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
|
|||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<script src="/pos/static/js/i18n.js" defer></script>
|
<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/pos-utils.js" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.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.
|
||||||
"""Tenant DB connection manager with pooling.
|
|
||||||
|
|
||||||
Uses psycopg2.pool.ThreadedConnectionPool for both master and tenant DBs.
|
Master DB: creates a fresh connection each time (very light load thanks to
|
||||||
Connections are returned to the pool on .close() via a thin wrapper —
|
tenant_id → db_name cache, so we only hit master ~once per 5 min).
|
||||||
this keeps the rest of the codebase unchanged.
|
Tenant DBs: use psycopg2.pool.ThreadedConnectionPool with maxconn=20.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2 import pool
|
from psycopg2 import pool
|
||||||
from config import MASTER_DB_URL, TENANT_DB_URL_TEMPLATE
|
from config import MASTER_DB_URL, TENANT_DB_URL_TEMPLATE
|
||||||
|
|
||||||
|
|
||||||
# ─── Pools ─────────────────────────────────────
|
# ─── Tenant Pools ──────────────────────────────
|
||||||
_master_pool = None
|
|
||||||
_tenant_pools = {}
|
_tenant_pools = {}
|
||||||
|
|
||||||
|
# ─── Tenant cache ──────────────────────────────
|
||||||
def _get_master_pool():
|
_tenant_cache = {}
|
||||||
"""Lazy-initialize master DB connection pool."""
|
_tenant_cache_ttl = 300
|
||||||
global _master_pool
|
_tenant_cache_lock = threading.Lock()
|
||||||
if _master_pool is None:
|
|
||||||
_master_pool = pool.ThreadedConnectionPool(
|
|
||||||
minconn=2, maxconn=20, dsn=MASTER_DB_URL
|
|
||||||
)
|
|
||||||
return _master_pool
|
|
||||||
|
|
||||||
|
|
||||||
def _get_tenant_pool(db_name):
|
def _get_tenant_pool(db_name):
|
||||||
@@ -37,6 +32,34 @@ def _get_tenant_pool(db_name):
|
|||||||
return _tenant_pools[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:
|
class _PooledConnection:
|
||||||
"""Thin wrapper that delegates all attribute access to the real
|
"""Thin wrapper that delegates all attribute access to the real
|
||||||
psycopg2 connection, but returns it to the pool on .close().
|
psycopg2 connection, but returns it to the pool on .close().
|
||||||
@@ -52,19 +75,17 @@ class _PooledConnection:
|
|||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
try:
|
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:
|
if self._conn:
|
||||||
try:
|
try:
|
||||||
self._conn.rollback()
|
self._conn.rollback()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._pool.putconn(self._conn)
|
self._pool.putconn(self._conn)
|
||||||
except Exception:
|
except Exception:
|
||||||
# If pool is already closed, fall back to real close
|
try:
|
||||||
self._conn.close()
|
self._conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
return self
|
return self
|
||||||
@@ -76,27 +97,19 @@ class _PooledConnection:
|
|||||||
# ─── Public API ────────────────────────────────
|
# ─── Public API ────────────────────────────────
|
||||||
|
|
||||||
def get_master_conn():
|
def get_master_conn():
|
||||||
"""Get a pooled connection to the master DB."""
|
"""Get a direct connection to the master DB (no pool).
|
||||||
p = _get_master_pool()
|
|
||||||
return _PooledConnection(p.getconn(), p)
|
Caller MUST close() the connection when done.
|
||||||
|
"""
|
||||||
|
return psycopg2.connect(MASTER_DB_URL)
|
||||||
|
|
||||||
|
|
||||||
def get_tenant_conn(tenant_id):
|
def get_tenant_conn(tenant_id):
|
||||||
"""Get a pooled connection to a tenant's DB."""
|
"""Get a pooled connection to a tenant's DB."""
|
||||||
master = get_master_conn()
|
db_name = _resolve_tenant_db(tenant_id)
|
||||||
cur = master.cursor()
|
if not db_name:
|
||||||
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:
|
|
||||||
raise ValueError(f"Tenant {tenant_id} not found or inactive")
|
raise ValueError(f"Tenant {tenant_id} not found or inactive")
|
||||||
|
|
||||||
db_name = row[0]
|
|
||||||
p = _get_tenant_pool(db_name)
|
p = _get_tenant_pool(db_name)
|
||||||
return _PooledConnection(p.getconn(), p)
|
return _PooledConnection(p.getconn(), p)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ Environment=REDIS_URL=redis://localhost:6379/0
|
|||||||
Environment=REDIS_ENABLED=true
|
Environment=REDIS_ENABLED=true
|
||||||
Environment=MEILI_URL=http://localhost:7700
|
Environment=MEILI_URL=http://localhost:7700
|
||||||
Environment=MEILI_ENABLED=true
|
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]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
test.describe('Nexus POS — Auth Guard', () => {
|
test.describe('Nexus POS — Auth Guard', () => {
|
||||||
test('unauthenticated user is redirected to login', async ({ browser }) => {
|
test('unauthenticated user redirected to login', async ({ page }) => {
|
||||||
// Create incognito context without localStorage
|
// Ensure no auth state
|
||||||
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();
|
|
||||||
await page.goto('/pos/login');
|
await page.goto('/pos/login');
|
||||||
await expect(page.locator('input[type="password"], #password, input[name="pin"]')).toBeVisible();
|
await page.evaluate(() => {
|
||||||
await context.close();
|
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');
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
test.describe('Nexus POS — Inventory', () => {
|
const FAKE_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksIm5hbWUiOiJUZXN0IFVzZXIifQ.signature';
|
||||||
test('inventory page loads with table or grid', async ({ page }) => {
|
|
||||||
await page.goto('/pos/inventory');
|
async function setupAuth(page) {
|
||||||
await expect(page.locator('#inventoryTable, .data-table, #partsGrid, .grid, table')).toBeVisible({ timeout: 10000 });
|
await page.goto('/pos/login');
|
||||||
const content = await page.locator('body').textContent();
|
await page.evaluate((token) => {
|
||||||
expect(content).toMatch(/inventario|stock|producto|parte/i);
|
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');
|
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 expect(page).toHaveTitle(/Inventario/i);
|
||||||
await firstRow.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {});
|
await expect(page.locator('#stockTable')).toBeVisible({ timeout: 5000 });
|
||||||
if (await firstRow.isVisible().catch(() => false)) {
|
// Wait for virtual-scroll rows to render
|
||||||
await firstRow.click();
|
await page.waitForSelector('#productTableBody tr', { timeout: 5000 });
|
||||||
await expect(page.locator('.modal, .detail-panel, #detailPanel, [role="dialog"]')).toBeVisible({ 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');
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
test.describe('Nexus POS — Checkout', () => {
|
const FAKE_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksIm5hbWUiOiJUZXN0IFVzZXIifQ.signature';
|
||||||
test('POS sale page loads with cart', async ({ page }) => {
|
|
||||||
await page.goto('/pos/sale');
|
async function setupAuth(page) {
|
||||||
await expect(page.locator('#cartBody, .cart, #cartTable, .pos-cart')).toBeVisible({ timeout: 10000 });
|
await page.goto('/pos/login');
|
||||||
const content = await page.locator('body').textContent();
|
await page.evaluate((token) => {
|
||||||
expect(content).toMatch(/venta|carrito|total|pagar/i);
|
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');
|
await page.goto('/pos/sale');
|
||||||
const searchInput = page.locator('#productSearch, #searchInput, input[placeholder*="buscar" i]').first();
|
|
||||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
await expect(page).toHaveTitle(/Nexus Autoparts/i);
|
||||||
await searchInput.fill('freno');
|
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 searchInput.press('Enter');
|
||||||
await page.waitForTimeout(800);
|
|
||||||
const hasDropdown = await page.locator('.search-dropdown, #searchDropdown, .parts-grid').first().isVisible().catch(() => false);
|
// Assert search results dropdown/grid appears
|
||||||
expect(hasDropdown || true).toBe(true);
|
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