Compare commits
131 Commits
1f909f4c42
...
desarrollo
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b80add102 | |||
| ad04572305 | |||
| ee7e1d49e5 | |||
| 49bbc37117 | |||
| 6aff32f93b | |||
| 7d21d21200 | |||
| 0eb5984263 | |||
| b78523102d | |||
| 27358312dc | |||
| 5e9ac57f08 | |||
| 8796cadb56 | |||
| 3378d26a31 | |||
| a9052e63c2 | |||
| c1e93ed52a | |||
| 70233671a6 | |||
| 33df6e9280 | |||
| 1967ad1073 | |||
| 917ff00310 | |||
| 913e507adc | |||
| 383799ff3d | |||
| 203960fff3 | |||
| 0419f8285a | |||
| 3d70c3fcc9 | |||
| 041efd5c5c | |||
| 2cbd69d5fa | |||
| 98b3b1c8c1 | |||
| efbfadd17a | |||
| 43691ce83b | |||
| 7a4a676890 | |||
| 08362c5677 | |||
| 2b73c2c6db | |||
| ea29cc31c0 | |||
| 5ea667b80e | |||
| 77541e4c52 | |||
| 9f04bfe0bb | |||
| 718fa06888 | |||
| 999591e248 | |||
| 3d0d52c60b | |||
| c5fc8c5ec6 | |||
| 5c815bc2f5 | |||
| b6a327c98c | |||
| 68d6f81671 | |||
| 61bf84b2dc | |||
| 3009ffa1b0 | |||
| 7cef8db6af | |||
| 03b32f3b17 | |||
| eb107e2778 | |||
| 031c190635 | |||
| 7020890b0e | |||
| 23dbf54f3f | |||
| 3060dab471 | |||
| 716e19d079 | |||
| 51f64921a5 | |||
| 91caf91b79 | |||
| 584cc385b9 | |||
| 314075021e | |||
| f742cdaa42 | |||
| 79d3368041 | |||
| bfb4921ac0 | |||
| b314a781a1 | |||
| 4866823ba9 | |||
| a236187f3a | |||
| 71f3b1cdec | |||
| 159d0ed625 | |||
| 50c0dbe7d4 | |||
| 0b1dc89faf | |||
| dbf45e374b | |||
| 07b9b9130a | |||
| ae2273f864 | |||
| d9741b21f6 | |||
| e38148e8d5 | |||
| 912fe4cef5 | |||
| a7334513ac | |||
| 2f8b9dd5aa | |||
| 60dd8162f7 | |||
| bfa7bc2997 | |||
| 6196234d8b | |||
| e8db3e926c | |||
| d725ed2e0c | |||
| 36dd6634e3 | |||
| 24cdd71262 | |||
| 9ad624d26c | |||
| 2af2389294 | |||
| be4bb8d9ad | |||
| da362e32a6 | |||
| 79fa7984a1 | |||
| 30abecc07d | |||
| 521455f156 | |||
| 24db5eff43 | |||
| 4d6a7d9f32 | |||
| c6b3ca9bdf | |||
| 9da14e40da | |||
| e61063bdd7 | |||
| 6734993508 | |||
| 2b0215d6b8 | |||
| ee9eea58c1 | |||
| ff45905b49 | |||
| 371d72887e | |||
| af7b010e55 | |||
| 5421c47ffc | |||
| 2e80ba7400 | |||
| 0e549e7746 | |||
| 2b418701b6 | |||
| 91826487f9 | |||
| b27dd720aa | |||
| b94b194217 | |||
| 623c57bb08 | |||
| 3cd2874ed7 | |||
| cf46790ed8 | |||
| 45b69bcae8 | |||
| 3792e4053c | |||
| 5a913dcac1 | |||
| cc9a0cf57c | |||
| f78d4c9b44 | |||
| ca239a458b | |||
| fb591c7de6 | |||
| b803950fae | |||
| bd2cf307f7 | |||
| 9b02005116 | |||
| 2cfe4b3913 | |||
| 12989e30be | |||
| c4db5e7550 | |||
| 3b8224d15e | |||
| 4b3b0f8313 | |||
| c766571b7d | |||
| 44c3a6c910 | |||
| f24f25e74e | |||
| b829e4f026 | |||
| c75e2a75c9 | |||
| 27cb4ee683 | |||
| afb3b2405c |
71
.env.example
Normal file
71
.env.example
Normal file
@@ -0,0 +1,71 @@
|
||||
# Nexus Autoparts — Environment Variables
|
||||
# Copy this file to .env and fill in your values.
|
||||
# NEVER commit .env to git.
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# DATABASE (REQUIRED)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
DATABASE_URL=postgresql://nexus:YOUR_DB_PASSWORD@localhost/nexus_autoparts
|
||||
MASTER_DB_URL=postgresql://nexus:YOUR_DB_PASSWORD@localhost/nexus_autoparts
|
||||
TENANT_DB_URL_TEMPLATE=postgresql://nexus:YOUR_DB_PASSWORD@localhost/{db_name}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# SECURITY (REQUIRED)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
JWT_SECRET=change-me-to-a-random-64-char-hex-string
|
||||
POS_JWT_SECRET=change-me-to-a-different-random-64-char-hex-string
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# AI / OpenRouter (OPTIONAL — enables chatbot)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
OPENROUTER_API_KEY=sk-or-v1-your-openrouter-key
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# WHATSAPP BRIDGE (OPTIONAL — enables WhatsApp integration)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
WHATSAPP_BRIDGE_URL=http://localhost:21465
|
||||
WHATSAPP_BRIDGE_KEY=your-whatsapp-bridge-secret
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# SMTP (OPTIONAL — enables email quotations)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=noreply@yourdomain.com
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# REDIS CACHE (OPTIONAL — enables sub-millisecond stock lookups)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
REDIS_ENABLED=true
|
||||
REDIS_STOCK_TTL=300
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# MEILISEARCH (OPTIONAL — enables sub-100ms catalog search)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
MEILI_URL=http://localhost:7700
|
||||
MEILI_API_KEY=nexus-master-key-change-me
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# METABASE KPIs (OPTIONAL — Business Intelligence dashboards)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
METABASE_URL=http://localhost:3000
|
||||
METABASE_ADMIN_EMAIL=admin@nexus.local
|
||||
METABASE_ADMIN_PASS=change-me-to-a-strong-password
|
||||
METABASE_DB_PASS=metabase_secret
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# FACTURAPI (OPTIONAL — auto-organization mode for new tenants)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# If set, new tenants can create Facturapi organizations automatically.
|
||||
# Otherwise each tenant must store its secret key in tenant_config.cfdi_facturapi_key.
|
||||
FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CURRENCY
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
DEFAULT_CURRENCY=MXN
|
||||
EXCHANGE_RATE_USD_MXN=17.5
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -80,3 +80,14 @@ node_modules/
|
||||
|
||||
# Diagram images (served from static, too large for git)
|
||||
dashboard/static/diagrams/
|
||||
|
||||
# Playwright / Node
|
||||
package-lock.json
|
||||
|
||||
# Backups
|
||||
backups/
|
||||
|
||||
|
||||
# Local tools (AWS CLI)
|
||||
tools/
|
||||
|
||||
|
||||
39
.kimi/plan.md
Normal file
39
.kimi/plan.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Plan: Catálogo por Marca de Vehículo
|
||||
|
||||
## Resumen
|
||||
Reorganizar el catálogo para que la navegación principal sea:
|
||||
**Marca de vehículo → Categoría/Sistema → Partes compatibles**
|
||||
|
||||
Ejemplo: Toyota → Frenos → [balatas Bosch, discos Brembo, pastillas NGK...]
|
||||
|
||||
## Opción recomendada: Materialized View
|
||||
|
||||
No tocamos la tabla masiva `vehicle_parts` (billones de rows). Creamos una materialized view que agregue por marca + categoría.
|
||||
|
||||
### Cambios DB (Master)
|
||||
1. Crear `brand_catalog_parts` MV desde `vehicle_parts → MYE → models → brands`
|
||||
2. Agregar índices: `(brand_id, category_id)`, `(brand_id, part_id)`
|
||||
3. Crear función `refresh_brand_catalog()` para refrescar
|
||||
|
||||
### Cambios Backend
|
||||
1. Nuevos endpoints:
|
||||
- `GET /catalog/vehicle-brands` → lista marcas con conteo de partes
|
||||
- `GET /catalog/brand-categories?brand_id=` → categorías disponibles para esa marca
|
||||
- `GET /catalog/brand-parts?brand_id=&category_id=` → partes compatibles
|
||||
2. Modificar `catalog_service.py` con filtros por marca
|
||||
|
||||
### Cambios Frontend
|
||||
1. Nueva vista inicial: grid de marcas de vehículo (tarjetas con logo/contador)
|
||||
2. Click en marca → lista de categorías/sistemas (frenos, motor, suspensión...)
|
||||
3. Click en categoría → grid de partes compatibles con esa marca
|
||||
4. Filtro opcional: modelo/año/motor para refinar resultados
|
||||
|
||||
### Datos
|
||||
- `vehicle_parts` ya tiene todo. La MV solo agrega/distinct.
|
||||
- Las marcas fabricantes (Bosch, NGK) se muestran como badges en cada parte.
|
||||
|
||||
## Tiempo estimado
|
||||
- DB + Backend: 2-3 horas
|
||||
- Frontend: 2-3 horas
|
||||
- Testing: 1 hora
|
||||
- Total: ~6 horas
|
||||
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
|
||||
25
README.md
25
README.md
@@ -27,7 +27,7 @@ Sistema integral que combina un catalogo publico de autopartes con un Punto de V
|
||||
- **Multi-tenant**: base de datos aislada por cliente
|
||||
- **PWA**: instalable en tablets/celulares, modo offline
|
||||
- **10 pantallas**: Login, Catalogo, Inventario, POS, Clientes, Facturacion, Contabilidad, Dashboard, Reportes, Configuracion
|
||||
- **81+ endpoints API** organizados en 9 blueprints
|
||||
- **100+ endpoints API** organizados en 15+ blueprints
|
||||
- **2 temas**: Industrial oscuro + Moderno claro (toggle en sidebar)
|
||||
- **Auth por PIN** con JWT + rate limiting + bloqueo por dispositivo
|
||||
- **5 roles**: Dueno, Admin, Cajero, Almacenista, Contador
|
||||
@@ -43,7 +43,7 @@ Sistema integral que combina un catalogo publico de autopartes con un Punto de V
|
||||
| Facturacion | CFDI 4.0 (Ingreso, Egreso, Pago), cola de timbrado, cancelacion SAT |
|
||||
| Contabilidad | Polizas automaticas, catalogo SAT, balanza, estado de resultados, balance general, antiguedad |
|
||||
| Caja Registradora | Apertura, movimientos, corte X, corte Z, multi-caja |
|
||||
| Dashboard | Ventas del dia vs meta, cajas activas, alertas |
|
||||
| Dashboard | Ventas del dia vs meta, cajas activas, alertas, graficos en tiempo real |
|
||||
| Reportes | Financieros y operativos |
|
||||
| Configuracion | Negocio, sucursales, empleados, roles, temas |
|
||||
|
||||
@@ -60,8 +60,13 @@ Sistema integral que combina un catalogo publico de autopartes con un Punto de V
|
||||
| CFDI | lxml (XML builder CFDI 4.0) |
|
||||
| Frontend | HTML/CSS/JS vanilla (sin framework) |
|
||||
| Estilos | CSS custom properties (design tokens) |
|
||||
| PWA | Service Worker + manifest |
|
||||
| PWA | Service Worker + manifest + install prompt |
|
||||
| Data import | TecDoc via Apify, NHTSA VIN API |
|
||||
| Monitoreo | Prometheus + Grafana (Docker) |
|
||||
| Tests E2E | Playwright |
|
||||
| BNPL | APLAZO / Kueski / Clip (stub) |
|
||||
| ERP Sync | Aspel / CONTPAQi / SAP / Odoo (stub) |
|
||||
| WhatsApp | Baileys webhook + Meta Cloud API (stub) |
|
||||
|
||||
---
|
||||
|
||||
@@ -77,6 +82,18 @@ Sistema integral que combina un catalogo publico de autopartes con un Punto de V
|
||||
|
||||
---
|
||||
|
||||
## Infraestructura Desplegada
|
||||
|
||||
| Servicio | Puerto | Estado |
|
||||
|----------|--------|--------|
|
||||
| POS (Gunicorn) | 5001 | Production |
|
||||
| Dashboard (Flask) | 5000 | Production |
|
||||
| Quart Async Catalog | 5002 | Production |
|
||||
| Prometheus | 9090 | Docker |
|
||||
| Grafana | 3001 | Docker |
|
||||
| Meilisearch | 7700 | Docker |
|
||||
| Metabase | 3000 | Docker |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Requisitos
|
||||
@@ -178,7 +195,9 @@ Ver [docs/INSTALACION.md](docs/INSTALACION.md) para instrucciones detalladas.
|
||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Arquitectura del sistema |
|
||||
| [docs/DATABASE.md](docs/DATABASE.md) | Esquema de base de datos |
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Nexus Autoparts** -- Tu conexion directa con las partes que necesitas
|
||||
|
||||
cloudflared tunnel run --token eyJhIjoiZDRjYzMwN2MzOTM2ODFlMGJiNTIwODZlZmNkZDFiM2MiLCJ0IjoiNDA3OTgwNDItNmMyZC00ZmY4LTgwNzgtMDYwZDA0ZDdhZTY0IiwicyI6Ik5qSXdPVGN4TXpBdE5HWTVOeTAwTldOaExUazFZV1l0WWpobU9XVXdORGc1WTJJMyJ9
|
||||
@@ -11,6 +11,9 @@ if not DB_URL:
|
||||
"Example: postgresql://user:pass@localhost/nexus_autoparts"
|
||||
)
|
||||
|
||||
MASTER_DB_URL = os.environ.get("MASTER_DB_URL") or DB_URL
|
||||
TENANT_DB_URL_TEMPLATE = os.environ.get("TENANT_DB_URL_TEMPLATE") or DB_URL.replace("nexus_autoparts", "{db_name}")
|
||||
|
||||
# Legacy SQLite path (used only by migration script)
|
||||
SQLITE_PATH = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
|
||||
@@ -92,6 +92,14 @@
|
||||
<span class="badge" id="pendingUsersBadge" style="display:none; background:var(--warning); color:#000; font-size:0.7rem; padding:2px 6px; border-radius:10px; margin-left:auto;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h3>Tenants</h3>
|
||||
<div class="sidebar-item" data-section="tenants">
|
||||
<span class="icon">🏢</span>
|
||||
<span>Módulos</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
@@ -660,6 +668,35 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tenants / Modules Section -->
|
||||
<section id="section-tenants" class="admin-section">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Configuración de Módulos por Tenant</h1>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Tenants Activos</h2>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nombre</th>
|
||||
<th>WhatsApp</th>
|
||||
<th>Marketplace</th>
|
||||
<th>MercadoLibre</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tenantsTable">
|
||||
<tr><td colspan="6" class="loading"><div class="spinner"></div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -121,6 +121,9 @@ function showSection(sectionId) {
|
||||
case 'users':
|
||||
loadUsers();
|
||||
break;
|
||||
case 'tenants':
|
||||
loadTenants();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2074,3 +2077,99 @@ async function toggleUserActive(userId, currentActive) {
|
||||
showAlert(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tenants / Modules ─────────────────────────────────────────────────────
|
||||
|
||||
async function loadTenants() {
|
||||
var token = localStorage.getItem('access_token');
|
||||
var tbody = document.getElementById('tenantsTable');
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="loading"><div class="spinner"></div></td></tr>';
|
||||
|
||||
try {
|
||||
var res = await fetch('/api/admin/tenants', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!res.ok) throw new Error('Error al cargar tenants (' + res.status + ')');
|
||||
var data = await res.json();
|
||||
var tenants = data.tenants || [];
|
||||
|
||||
if (tenants.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:var(--text-secondary); padding:2rem;">No hay tenants activos</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Load modules for each tenant
|
||||
var modulesMap = {};
|
||||
await Promise.all(tenants.map(async function(t) {
|
||||
try {
|
||||
var mres = await fetch('/api/admin/tenants/' + t.id + '/modules', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (mres.ok) {
|
||||
modulesMap[t.id] = await mres.json();
|
||||
} else {
|
||||
modulesMap[t.id] = {};
|
||||
}
|
||||
} catch (e) {
|
||||
modulesMap[t.id] = {};
|
||||
}
|
||||
}));
|
||||
|
||||
renderTenantsTable(tenants, modulesMap);
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:#ef4444; padding:2rem;">' + e.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTenantsTable(tenants, modulesMap) {
|
||||
var tbody = document.getElementById('tenantsTable');
|
||||
tbody.innerHTML = tenants.map(function(t) {
|
||||
var mods = modulesMap[t.id] || {};
|
||||
function toggleBtn(tenantId, key, enabled) {
|
||||
var label = enabled ? 'Activado' : 'Desactivado';
|
||||
var cls = enabled ? 'btn-primary' : 'btn-secondary';
|
||||
return '<button class="btn ' + cls + '" style="font-size:0.75rem; padding:3px 10px;" ' +
|
||||
'onclick="toggleTenantModule(' + tenantId + ', \'' + key + '\', ' + enabled + ')">' + label + '</button>';
|
||||
}
|
||||
return '<tr>' +
|
||||
'<td>' + t.id + '</td>' +
|
||||
'<td>' + (t.name || '-') + '</td>' +
|
||||
'<td>' + toggleBtn(t.id, 'whatsapp_enabled', !!mods.whatsapp_enabled) + '</td>' +
|
||||
'<td>' + toggleBtn(t.id, 'marketplace_enabled', !!mods.marketplace_enabled) + '</td>' +
|
||||
'<td>' + toggleBtn(t.id, 'meli_enabled', !!mods.meli_enabled) + '</td>' +
|
||||
'<td><button class="btn btn-primary" style="font-size:0.75rem; padding:3px 10px;" onclick="loadTenants()">🔄 Recargar</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function toggleTenantModule(tenantId, key, currentValue) {
|
||||
var token = localStorage.getItem('access_token');
|
||||
var moduleNames = {
|
||||
'whatsapp_enabled': 'WhatsApp',
|
||||
'marketplace_enabled': 'Marketplace',
|
||||
'meli_enabled': 'MercadoLibre'
|
||||
};
|
||||
var action = currentValue ? 'desactivar' : 'activar';
|
||||
if (!confirm('¿Seguro que deseas ' + action + ' ' + moduleNames[key] + ' para el tenant #' + tenantId + '?')) return;
|
||||
|
||||
try {
|
||||
var payload = {};
|
||||
payload[key] = !currentValue;
|
||||
var res = await fetch('/api/admin/tenants/' + tenantId + '/modules', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + token
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
var err = await res.json();
|
||||
throw new Error(err.error || 'Error al actualizar módulo');
|
||||
}
|
||||
showAlert(moduleNames[key] + ' ' + (currentValue ? 'desactivado' : 'activado') + ' para tenant #' + tenantId);
|
||||
loadTenants();
|
||||
} catch (e) {
|
||||
showAlert(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ==========================================================================
|
||||
NEXUS — Public Catalog Chat Widget
|
||||
NEXUS — Public Catalog Chat Widget (Voice + TTS enabled)
|
||||
Reuses design tokens from tokens.css
|
||||
========================================================================== */
|
||||
|
||||
@@ -228,6 +228,99 @@
|
||||
.chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); }
|
||||
.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ─── Header Actions (TTS toggle + close) ─── */
|
||||
.chat-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-tts-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-tts-toggle:hover { opacity: 1; }
|
||||
.chat-tts-toggle.off { opacity: 0.4; }
|
||||
|
||||
/* ─── Mic Button (Voice Input) ─── */
|
||||
.chat-mic-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, #333);
|
||||
background: var(--color-bg-base, #111);
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-mic-btn:hover {
|
||||
border-color: var(--color-accent, #F5A623);
|
||||
color: var(--color-accent, #F5A623);
|
||||
}
|
||||
|
||||
.chat-mic-btn.listening {
|
||||
background: #f85149;
|
||||
border-color: #f85149;
|
||||
color: #fff;
|
||||
animation: micPulse 1.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes micPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 81, 73, 0.4); }
|
||||
50% { box-shadow: 0 0 0 10px rgba(248, 81, 73, 0); }
|
||||
}
|
||||
|
||||
/* ─── TTS Button ─── */
|
||||
.chat-tts-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8b949e;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
margin-left: 6px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
.chat-tts-btn:hover { color: #58a6ff; background: rgba(88,166,255,0.1); }
|
||||
.chat-tts-btn.tts-active { color: #58a6ff; }
|
||||
|
||||
/* ─── Voice Toast ─── */
|
||||
.chat-voice-toast {
|
||||
position: fixed;
|
||||
bottom: 160px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(10px);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-voice-toast.visible {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chat-panel {
|
||||
width: calc(100vw - 16px);
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
// /home/Autopartes/dashboard/chat-public.js
|
||||
// Public catalog chatbot — no auth required, calls /api/chat
|
||||
// Public catalog chatbot — voice + TTS enabled
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var isOpen = false;
|
||||
var isSending = false;
|
||||
var isListening = false;
|
||||
var recognition = null;
|
||||
var history = [];
|
||||
var ttsEnabled = true;
|
||||
var ttsUtterance = null;
|
||||
var hasSpeechAPI = ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);
|
||||
var hasTTS = ('speechSynthesis' in window);
|
||||
|
||||
function init() {
|
||||
// FAB button
|
||||
var fab = document.createElement('button');
|
||||
fab.className = 'chat-fab';
|
||||
fab.id = 'chatFab';
|
||||
@@ -17,14 +22,16 @@
|
||||
fab.innerHTML = '💬';
|
||||
fab.setAttribute('aria-label', 'Abrir asistente IA');
|
||||
|
||||
// Chat panel
|
||||
var panel = document.createElement('div');
|
||||
panel.className = 'chat-panel';
|
||||
panel.id = 'chatPanel';
|
||||
panel.innerHTML =
|
||||
'<div class="chat-header">' +
|
||||
'<h3>Asistente — Buscar partes</h3>' +
|
||||
'<button class="chat-header-close" id="chatClose" aria-label="Cerrar">×</button>' +
|
||||
'<div class="chat-header-actions">' +
|
||||
(hasTTS ? '<button class="chat-tts-toggle" id="chatTtsToggle" aria-label="Activar lectura de respuestas" title="Activar lectura de respuestas">🔊</button>' : '') +
|
||||
'<button class="chat-header-close" id="chatClose" aria-label="Cerrar">×</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="chat-messages" id="chatMessages">' +
|
||||
'<div class="chat-msg ai">Hola, soy el asistente de Nexus Autoparts. Dime que refaccion buscas y te ayudo a encontrarla en el catalogo.</div>' +
|
||||
@@ -32,6 +39,7 @@
|
||||
'</div>' +
|
||||
'<div class="chat-input-area">' +
|
||||
'<textarea class="chat-input" id="chatInput" placeholder="Ej: Balatas para Tsuru 2015..." rows="1"></textarea>' +
|
||||
(hasSpeechAPI ? '<button class="chat-mic-btn" id="chatMic" aria-label="Entrada por voz" title="Entrada por voz">🎤</button>' : '') +
|
||||
'<button class="chat-send-btn" id="chatSend" aria-label="Enviar">▶</button>' +
|
||||
'</div>';
|
||||
|
||||
@@ -52,8 +60,139 @@
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 80) + 'px';
|
||||
});
|
||||
|
||||
if (hasSpeechAPI) {
|
||||
document.getElementById('chatMic').addEventListener('click', toggleVoice);
|
||||
}
|
||||
|
||||
if (hasTTS) {
|
||||
document.getElementById('chatTtsToggle').addEventListener('click', toggleTTS);
|
||||
}
|
||||
|
||||
// Stop TTS when closing chat
|
||||
document.getElementById('chatClose').addEventListener('click', function () {
|
||||
if (hasTTS) stopSpeaking();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── TTS ───
|
||||
function toggleTTS() {
|
||||
ttsEnabled = !ttsEnabled;
|
||||
var btn = document.getElementById('chatTtsToggle');
|
||||
if (btn) {
|
||||
btn.classList.toggle('off', !ttsEnabled);
|
||||
btn.setAttribute('title', ttsEnabled ? 'Desactivar lectura de respuestas' : 'Activar lectura de respuestas');
|
||||
}
|
||||
if (!ttsEnabled) stopSpeaking();
|
||||
}
|
||||
|
||||
function speak(text) {
|
||||
if (!hasTTS || !ttsEnabled || !text) return;
|
||||
stopSpeaking();
|
||||
ttsUtterance = new SpeechSynthesisUtterance(text);
|
||||
ttsUtterance.lang = 'es-MX';
|
||||
ttsUtterance.rate = 1.1;
|
||||
ttsUtterance.pitch = 1;
|
||||
window.speechSynthesis.speak(ttsUtterance);
|
||||
}
|
||||
|
||||
function stopSpeaking() {
|
||||
if (hasTTS && window.speechSynthesis.speaking) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
ttsUtterance = null;
|
||||
}
|
||||
|
||||
// ─── Voice Input ───
|
||||
function toggleVoice() {
|
||||
if (isListening) { stopVoice(); return; }
|
||||
startVoice();
|
||||
}
|
||||
|
||||
function startVoice() {
|
||||
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) return;
|
||||
|
||||
recognition = new SpeechRecognition();
|
||||
recognition.lang = 'es-MX';
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = true;
|
||||
|
||||
var input = document.getElementById('chatInput');
|
||||
var micBtn = document.getElementById('chatMic');
|
||||
var savedPlaceholder = input.placeholder;
|
||||
|
||||
recognition.onstart = function () {
|
||||
isListening = true;
|
||||
micBtn.classList.add('listening');
|
||||
input.placeholder = 'Escuchando...';
|
||||
input.value = '';
|
||||
stopSpeaking();
|
||||
};
|
||||
|
||||
recognition.onresult = function (e) {
|
||||
var interim = '';
|
||||
var finalTranscript = '';
|
||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||
if (e.results[i].isFinal) {
|
||||
finalTranscript += e.results[i][0].transcript;
|
||||
} else {
|
||||
interim += e.results[i][0].transcript;
|
||||
}
|
||||
}
|
||||
if (finalTranscript) {
|
||||
input.value = finalTranscript;
|
||||
} else {
|
||||
input.value = interim;
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onend = function () {
|
||||
isListening = false;
|
||||
micBtn.classList.remove('listening');
|
||||
input.placeholder = savedPlaceholder;
|
||||
recognition = null;
|
||||
if (input.value.trim()) {
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = function (e) {
|
||||
isListening = false;
|
||||
micBtn.classList.remove('listening');
|
||||
input.placeholder = savedPlaceholder;
|
||||
recognition = null;
|
||||
if (e.error === 'no-speech' || e.error === 'audio-capture' || e.error === 'not-allowed') {
|
||||
showVoiceToast('No se detecto voz');
|
||||
}
|
||||
};
|
||||
|
||||
recognition.start();
|
||||
}
|
||||
|
||||
function stopVoice() {
|
||||
if (recognition) {
|
||||
recognition.abort();
|
||||
recognition = null;
|
||||
}
|
||||
isListening = false;
|
||||
var micBtn = document.getElementById('chatMic');
|
||||
if (micBtn) micBtn.classList.remove('listening');
|
||||
}
|
||||
|
||||
function showVoiceToast(msg) {
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'chat-voice-toast';
|
||||
toast.textContent = msg;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(function () { toast.classList.add('visible'); }, 10);
|
||||
setTimeout(function () {
|
||||
toast.classList.remove('visible');
|
||||
setTimeout(function () { toast.remove(); }, 300);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ─── Chat UI ───
|
||||
function toggleChat() {
|
||||
isOpen = !isOpen;
|
||||
var panel = document.getElementById('chatPanel');
|
||||
@@ -104,6 +243,8 @@
|
||||
addBubble(aiMsg, 'ai');
|
||||
history.push({ role: 'assistant', content: aiMsg });
|
||||
|
||||
if (ttsEnabled) speak(aiMsg);
|
||||
|
||||
if (data.search_results && data.search_results.length > 0) {
|
||||
addPartResults(data.search_results);
|
||||
}
|
||||
@@ -149,7 +290,6 @@
|
||||
|
||||
card.style.cursor = 'pointer';
|
||||
card.addEventListener('click', function () {
|
||||
// Search in catalog
|
||||
var searchInput = document.getElementById('searchInput');
|
||||
if (searchInput && partNum) {
|
||||
searchInput.value = partNum;
|
||||
|
||||
95
dashboard/chat-public.min.css
vendored
95
dashboard/chat-public.min.css
vendored
@@ -1,5 +1,5 @@
|
||||
/* ==========================================================================
|
||||
NEXUS — Public Catalog Chat Widget
|
||||
NEXUS — Public Catalog Chat Widget (Voice + TTS enabled)
|
||||
Reuses design tokens from tokens.css
|
||||
========================================================================== */
|
||||
|
||||
@@ -228,6 +228,99 @@
|
||||
.chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); }
|
||||
.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ─── Header Actions (TTS toggle + close) ─── */
|
||||
.chat-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-tts-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-tts-toggle:hover { opacity: 1; }
|
||||
.chat-tts-toggle.off { opacity: 0.4; }
|
||||
|
||||
/* ─── Mic Button (Voice Input) ─── */
|
||||
.chat-mic-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, #333);
|
||||
background: var(--color-bg-base, #111);
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-mic-btn:hover {
|
||||
border-color: var(--color-accent, #F5A623);
|
||||
color: var(--color-accent, #F5A623);
|
||||
}
|
||||
|
||||
.chat-mic-btn.listening {
|
||||
background: #f85149;
|
||||
border-color: #f85149;
|
||||
color: #fff;
|
||||
animation: micPulse 1.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes micPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 81, 73, 0.4); }
|
||||
50% { box-shadow: 0 0 0 10px rgba(248, 81, 73, 0); }
|
||||
}
|
||||
|
||||
/* ─── TTS Button ─── */
|
||||
.chat-tts-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8b949e;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
margin-left: 6px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
.chat-tts-btn:hover { color: #58a6ff; background: rgba(88,166,255,0.1); }
|
||||
.chat-tts-btn.tts-active { color: #58a6ff; }
|
||||
|
||||
/* ─── Voice Toast ─── */
|
||||
.chat-voice-toast {
|
||||
position: fixed;
|
||||
bottom: 160px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(10px);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-voice-toast.visible {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chat-panel {
|
||||
width: calc(100vw - 16px);
|
||||
|
||||
2
dashboard/chat-public.min.js
vendored
2
dashboard/chat-public.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nexus Autoparts — Sistema completo para refaccionarias</title>
|
||||
<meta name="description" content="POS + Catalogo TecDoc 1.5M+ partes + Marketplace B2B + IA. Todo lo que necesita una refaccionaria en una sola plataforma.">
|
||||
<meta name="description" content="POS + Catalogo 1.5M+ partes + IA + Venta en linea. Todo lo que necesita una refaccionaria en una sola plataforma.">
|
||||
<script>
|
||||
(function(){
|
||||
var t = localStorage.getItem('nexus-theme') || 'industrial';
|
||||
@@ -32,7 +32,6 @@
|
||||
<span id="themeIcon">☾</span>
|
||||
</button>
|
||||
<a href="/catalog" class="btn btn-primary">Ver Catalogo</a>
|
||||
<a href="/pos/login" class="btn btn-secondary">Acceder POS</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -42,13 +41,12 @@
|
||||
<canvas id="heroCanvas"></canvas>
|
||||
<div class="hero-content">
|
||||
<h1 class="nx-reveal">Nexus Autoparts</h1>
|
||||
<p class="subtitle nx-reveal">Todo lo que necesita una refaccionaria en una sola plataforma. POS, inventario, catalogo TecDoc, facturacion, marketplace B2B e inteligencia artificial.</p>
|
||||
<p class="subtitle nx-reveal">Todo lo que necesita una refaccionaria en una sola plataforma. POS, inventario, catalogo de partes, facturacion, venta en linea e inteligencia artificial.</p>
|
||||
<div class="typewriter-line nx-reveal">
|
||||
<span id="typewriterText"></span><span class="typewriter-cursor"></span>
|
||||
</div>
|
||||
<div class="hero-buttons nx-reveal">
|
||||
<a href="/catalog" class="btn btn-primary btn-lg">Explorar Catalogo</a>
|
||||
<a href="/pos/login" class="btn btn-secondary btn-lg">Probar el POS</a>
|
||||
</div>
|
||||
<div class="hero-stats nx-stagger">
|
||||
<div class="stat-card nx-reveal">
|
||||
@@ -80,59 +78,46 @@
|
||||
<section class="product">
|
||||
<div class="container">
|
||||
<h2 class="section-title nx-reveal">El Producto</h2>
|
||||
<p class="section-subtitle nx-reveal">El unico sistema que combina POS + Inventario + CFDI + Catalogo + Marketplace + IA en una sola plataforma</p>
|
||||
<p class="section-subtitle nx-reveal">Las 3 funcionalidades principales que hacen crecer tu refaccionaria</p>
|
||||
<div class="product-grid nx-stagger">
|
||||
|
||||
<div class="product-card product-card--orange nx-reveal">
|
||||
<h3>Ventas & POS</h3>
|
||||
<h3>Catalogo Completo + POS + Inventario</h3>
|
||||
<ul>
|
||||
<li>Punto de venta completo con F-keys y escaner</li>
|
||||
<li>Caja registradora multi-caja, cortes X/Z</li>
|
||||
<li>Cotizaciones, apartados, devoluciones</li>
|
||||
<li>Clientes con credito y 3 niveles de precio</li>
|
||||
<li>Facturacion CFDI 4.0 (Ingreso, Egreso, Pago)</li>
|
||||
<li>Impresion termica ESC/POS</li>
|
||||
<li>Contabilidad con polizas automaticas</li>
|
||||
<li>Reportes: ventas, ABC, cortes, utilidad</li>
|
||||
<li>Catalogo completo: 1.5M+ partes OEM y 304K+ aftermarket</li>
|
||||
<li>Punto de venta completo con escaner y teclas rapidas</li>
|
||||
<li>Inventario append-only con toma fisica y alertas de stock</li>
|
||||
<li>Navegacion por vehiculo: Marca > Modelo > Ano > Motor</li>
|
||||
<li>Decodificador VIN + busqueda por placas MX</li>
|
||||
<li>Facturacion CFDI 4.0 integrada</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="product-card product-card--cyan nx-reveal">
|
||||
<h3>Catalogo & Inventario</h3>
|
||||
<h3>Agente AI para WhatsApp</h3>
|
||||
<ul>
|
||||
<li>Catalogo TecDoc: 1.5M+ partes OEM</li>
|
||||
<li>304K+ partes aftermarket con cross-refs</li>
|
||||
<li>Navegacion: Ano > Marca > Modelo > Motor</li>
|
||||
<li>VIN decoder + busqueda por placas MX</li>
|
||||
<li>Inventario append-only, toma fisica</li>
|
||||
<li>Imagenes de productos con upload masivo</li>
|
||||
<li>Traduccion automatica EN > ES (326 partes)</li>
|
||||
<li>Marketplace B2B: bodegas ↔ talleres</li>
|
||||
<li>Atiende consultas de autopartes 24/7 automaticamente</li>
|
||||
<li>Genera cotizaciones inteligentes desde la conversacion</li>
|
||||
<li>Reconoce piezas por foto con Vision AI</li>
|
||||
<li>Transcripcion de notas de voz a texto</li>
|
||||
<li>Envia catalogos y cotizaciones directo al cliente</li>
|
||||
<li>Reduce llamadas y aumenta conversiones</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="product-card product-card--green nx-reveal">
|
||||
<h3>IA & Plataforma</h3>
|
||||
<h3>Vinculacion con Mercado Libre</h3>
|
||||
<ul>
|
||||
<li>Chatbot IA: diagnostico, cotizacion inteligente</li>
|
||||
<li>Entrada por voz (Web Speech API)</li>
|
||||
<li>Reconocimiento de partes por foto (Vision AI)</li>
|
||||
<li>WhatsApp Business integrado (envio de cotizaciones)</li>
|
||||
<li>Gestion de flotillas y mantenimiento</li>
|
||||
<li>PWA + App Android, modo kiosko</li>
|
||||
<li>Offline-first con sync automatico</li>
|
||||
<li>2 temas, 2 idiomas (ES/EN), 2 monedas (MXN/USD)</li>
|
||||
<li>Publica tu inventario en Mercado Libre en minutos</li>
|
||||
<li>Sincronizacion automatica de stock y precios</li>
|
||||
<li>Descarga ordenes y conviertelas en ventas del POS</li>
|
||||
<li>Gestiona listados, preguntas y ventas desde un solo lugar</li>
|
||||
<li>Empieza a vender en linea sin complicaciones</li>
|
||||
<li>Mas canales, mas ventas, mismo inventario</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="hw-banner nx-reveal">
|
||||
<div class="hw-banner-inner">
|
||||
<span>🖥</span>
|
||||
<div class="hw-text">A partir del plan <strong>Pro</strong>: servidor en <strong>rack 3D personalizado</strong> — Mini PC + switch + AP + UPS.<br>Todo incluido por <strong>$2,000 MXN/mes</strong>. Solo conectar y empezar a vender.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -154,12 +139,12 @@
|
||||
<div class="step nx-reveal">
|
||||
<div class="step-number">2</div>
|
||||
<h3>Catalogo + Inventario</h3>
|
||||
<p>Tu inventario conectado al catalogo TecDoc. Busca por vehiculo, parte o VIN.</p>
|
||||
<p>Tu inventario conectado al catalogo de partes. Busca por vehiculo, parte o VIN.</p>
|
||||
</div>
|
||||
<div class="step nx-reveal">
|
||||
<div class="step-number">3</div>
|
||||
<h3>Vende y Crece</h3>
|
||||
<p>POS, facturacion, marketplace B2B, WhatsApp e IA — todo desde un solo lugar.</p>
|
||||
<p>POS, facturacion, venta en linea, WhatsApp e IA — todo desde un solo lugar.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,7 +163,7 @@
|
||||
<div class="diff-grid nx-stagger">
|
||||
<div class="diff-card nx-reveal">
|
||||
<div class="diff-icon">🔍</div>
|
||||
<h4>Catalogo TecDoc</h4>
|
||||
<h4>Catalogo Completo</h4>
|
||||
<p>1.5M+ partes con cross-references. Nadie mas lo tiene en MX.</p>
|
||||
</div>
|
||||
<div class="diff-card nx-reveal">
|
||||
@@ -193,13 +178,13 @@
|
||||
</div>
|
||||
<div class="diff-card nx-reveal">
|
||||
<div class="diff-icon">🚀</div>
|
||||
<h4>Marketplace B2B</h4>
|
||||
<p>Conecta bodegas con talleres. Mas ventas, menos llamadas.</p>
|
||||
<h4>Venta en Linea</h4>
|
||||
<p>Conecta tu inventario con Mercado Libre y vende 24/7.</p>
|
||||
</div>
|
||||
<div class="diff-card nx-reveal">
|
||||
<div class="diff-icon">🖥</div>
|
||||
<h4>Hardware incluido</h4>
|
||||
<p>Rack 3D con servidor. Renta todo por $2,000/mes.</p>
|
||||
<h4>Hardware opcional</h4>
|
||||
<p>Mini rack 3D con servidor. Disponible como add-on.</p>
|
||||
</div>
|
||||
<div class="diff-card nx-reveal">
|
||||
<div class="diff-icon">🌐</div>
|
||||
@@ -229,41 +214,46 @@
|
||||
<section class="pricing">
|
||||
<div class="container">
|
||||
<h2 class="section-title nx-reveal">Planes</h2>
|
||||
<p class="section-subtitle nx-reveal">Software desde $999/mes. Hardware incluido a partir del plan Pro.</p>
|
||||
<p class="section-subtitle nx-reveal">Elige el plan que se ajuste a tu refaccionaria. Paga anual y ahorra 2 meses.</p>
|
||||
<div class="pricing-grid nx-stagger">
|
||||
<div class="pricing-card nx-reveal">
|
||||
<h4>Basico</h4>
|
||||
<div class="pricing-price">$999</div>
|
||||
<div class="pricing-period">MXN / mes — solo software</div>
|
||||
<h4>POS Basico</h4>
|
||||
<div class="pricing-price">$650</div>
|
||||
<div class="pricing-period">MXN / mes</div>
|
||||
<ul>
|
||||
<li>POS + Inventario</li>
|
||||
<li>Catalogo TecDoc</li>
|
||||
<li>CFDI 4.0</li>
|
||||
<li>Punto de venta completo</li>
|
||||
<li>Inventario y catalogo de partes</li>
|
||||
<li>Facturacion CFDI 4.0</li>
|
||||
<li>Reportes basicos</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pricing-card featured nx-reveal">
|
||||
<h4>Pro</h4>
|
||||
<div class="pricing-price">$2,000</div>
|
||||
<div class="pricing-period">MXN / mes — hardware incluido</div>
|
||||
<h4>Sistema Completo</h4>
|
||||
<div class="pricing-price">$1,660</div>
|
||||
<div class="pricing-period">MXN / mes</div>
|
||||
<ul>
|
||||
<li>Todo Basico +</li>
|
||||
<li>Todo lo del POS Basico +</li>
|
||||
<li>Agente AI para WhatsApp</li>
|
||||
<li>Vinculacion con Mercado Libre</li>
|
||||
<li>Sync automatico de stock y ordenes</li>
|
||||
<li>Contabilidad automatica</li>
|
||||
<li>Chatbot IA + WhatsApp</li>
|
||||
<li>Marketplace B2B</li>
|
||||
<li>🖥 Mini PC + rack 3D + red incluidos</li>
|
||||
<li>Multi-sucursal y flotillas</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pricing-card nx-reveal">
|
||||
<h4>Enterprise</h4>
|
||||
<div class="pricing-price">$3,999</div>
|
||||
<div class="pricing-period">MXN / mes — hardware incluido</div>
|
||||
</div>
|
||||
<div class="pricing-note nx-reveal" style="text-align:center; margin-top:var(--space-6); font-size:var(--text-body-sm); color:var(--color-text-secondary);">
|
||||
<p><strong>Paga anual y ahorra 2 meses.</strong> Aplica a meses sin intereses (MSI).</p>
|
||||
</div>
|
||||
<div class="pricing-grid nx-stagger" style="margin-top:var(--space-8);">
|
||||
<div class="pricing-card nx-reveal" style="grid-column: 1 / -1; max-width: 600px; margin: 0 auto;">
|
||||
<h4>Add-on: Mini Rack con Servidor</h4>
|
||||
<div class="pricing-price">$3,000</div>
|
||||
<div class="pricing-period">MXN / mes</div>
|
||||
<ul>
|
||||
<li>Todo Pro +</li>
|
||||
<li>Flotillas + Multi-bodega</li>
|
||||
<li>API dedicada</li>
|
||||
<li>Soporte prioritario</li>
|
||||
<li>🖥 Hardware dedicado por sucursal</li>
|
||||
<li>Mini PC con POS preinstalado</li>
|
||||
<li>Switch + Access Point + UPS</li>
|
||||
<li>Rack 3D personalizado</li>
|
||||
<li>Solo conectar y empezar a vender</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,12 +273,12 @@
|
||||
<div class="contact-card nx-reveal">
|
||||
<div class="contact-icon">✉</div>
|
||||
<h4>Email</h4>
|
||||
<a href="mailto:ialcarazsalazar@consultoria-as.com">ialcarazsalazar@consultoria-as.com</a>
|
||||
<a href="mailto:ivan@nexusautoparts.com.mx">ivan@nexusautoparts.com.mx</a>
|
||||
</div>
|
||||
<div class="contact-card nx-reveal">
|
||||
<div class="contact-icon">📱</div>
|
||||
<h4>WhatsApp</h4>
|
||||
<a href="https://wa.me/526641234567" class="btn-whatsapp" target="_blank" rel="noopener">Enviar Mensaje</a>
|
||||
<a href="https://wa.me/526642170990" class="btn-whatsapp" target="_blank" rel="noopener">Enviar Mensaje</a>
|
||||
</div>
|
||||
<div class="contact-card nx-reveal">
|
||||
<div class="contact-icon">📍</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Flask, jsonify, request, send_from_directory, redirect, g
|
||||
from flask import Flask, jsonify, request, send_from_directory, redirect, g, abort
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -16,6 +16,7 @@ sys.path.insert(0, os.path.join(_base, '..', 'pos')) # pos/ for auth, services
|
||||
sys.path.insert(0, os.path.join(_base, '..')) # root config.py (has DB_URL)
|
||||
from config import DB_URL
|
||||
from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.translations import translate_part_name, translate_category
|
||||
|
||||
sys.path.insert(0, os.path.join(_base, '..', 'pos'))
|
||||
@@ -4628,6 +4629,87 @@ def part_aftermarket(part_id):
|
||||
session.close()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tenant Module Config Endpoints
|
||||
# ============================================================================
|
||||
|
||||
MODULE_CONFIG_KEYS = [
|
||||
'whatsapp_enabled',
|
||||
'marketplace_enabled',
|
||||
'meli_enabled',
|
||||
]
|
||||
|
||||
|
||||
@app.route('/api/admin/tenants')
|
||||
def api_admin_tenants():
|
||||
session = Session()
|
||||
try:
|
||||
rows = session.execute(text(
|
||||
"SELECT id, name, db_name, is_active, is_seller FROM tenants WHERE is_active = true ORDER BY id"
|
||||
)).mappings().all()
|
||||
return jsonify({'tenants': [dict(r) for r in rows]})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.route('/api/admin/tenants/<int:tenant_id>/modules')
|
||||
def api_admin_tenant_modules(tenant_id):
|
||||
try:
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
result = {}
|
||||
for key in MODULE_CONFIG_KEYS:
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
|
||||
row = cur.fetchone()
|
||||
result[key] = (row[0] or '').lower() == 'true' if row else False
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/admin/tenants/<int:tenant_id>/modules', methods=['PUT'])
|
||||
def api_admin_tenant_modules_update(tenant_id):
|
||||
data = request.get_json() or {}
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
try:
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
for key, value in data.items():
|
||||
if key not in MODULE_CONFIG_KEYS:
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenant_config (key, value, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
""",
|
||||
(key, 'true' if value else 'false'),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Static files from dashboard root (CSS/JS/HTML)
|
||||
# ============================================================================
|
||||
|
||||
@app.route('/<filename>')
|
||||
def serve_root_static(filename):
|
||||
if filename.endswith(('.css', '.js', '.html')) and os.path.isfile(filename):
|
||||
return send_from_directory('.', filename)
|
||||
abort(404)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Block
|
||||
# ============================================================================
|
||||
|
||||
34
docker/alertmanager/alertmanager.yml
Normal file
34
docker/alertmanager/alertmanager.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
global:
|
||||
smtp_smarthost: 'localhost:587'
|
||||
smtp_from: 'alerts@nexus.local'
|
||||
smtp_require_tls: false
|
||||
|
||||
route:
|
||||
group_by: ['alertname', 'severity']
|
||||
group_wait: 10s
|
||||
group_interval: 10s
|
||||
repeat_interval: 1h
|
||||
receiver: 'default'
|
||||
|
||||
receivers:
|
||||
- name: 'default'
|
||||
email_configs:
|
||||
- to: 'admin@nexus.local'
|
||||
subject: 'Nexus Alert: {{ .GroupLabels.alertname }}'
|
||||
body: |
|
||||
{{ range .Alerts }}
|
||||
Alert: {{ .Annotations.summary }}
|
||||
Description: {{ .Annotations.description }}
|
||||
Severity: {{ .Labels.severity }}
|
||||
Time: {{ .StartsAt }}
|
||||
{{ end }}
|
||||
webhook_configs:
|
||||
- url: 'http://localhost:5001/pos/api/notifications/webhook'
|
||||
send_resolved: true
|
||||
|
||||
inhibit_rules:
|
||||
- source_match:
|
||||
severity: 'critical'
|
||||
target_match:
|
||||
severity: 'warning'
|
||||
equal: ['alertname']
|
||||
74
docker/docker-compose.monitoring.yml
Normal file
74
docker/docker-compose.monitoring.yml
Normal file
@@ -0,0 +1,74 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.51.0
|
||||
container_name: nexus-prometheus
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./prometheus:/etc/prometheus
|
||||
- prometheus-data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
|
||||
- '--web.console.templates=/usr/share/prometheus/consoles'
|
||||
- '--web.enable-lifecycle'
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:10.4.1
|
||||
container_name: nexus-grafana
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=nexus2026
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.7.0
|
||||
container_name: nexus-node-exporter
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
|
||||
postgres-exporter:
|
||||
image: prometheuscommunity/postgres-exporter:v0.15.0
|
||||
container_name: nexus-postgres-exporter
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATA_SOURCE_NAME: "postgresql://postgres@172.17.0.1:5432/nexus_autoparts?sslmode=disable"
|
||||
ports:
|
||||
- "9187:9187"
|
||||
|
||||
redis-exporter:
|
||||
image: oliver006/redis_exporter:v1.58.0
|
||||
container_name: nexus-redis-exporter
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REDIS_ADDR: "redis://172.17.0.1:6379"
|
||||
ports:
|
||||
- "9121:9121"
|
||||
|
||||
alertmanager:
|
||||
image: prom/alertmanager:v0.27.0
|
||||
container_name: nexus-alertmanager
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9093:9093"
|
||||
volumes:
|
||||
- ./alertmanager:/etc/alertmanager
|
||||
- alertmanager-data:/alertmanager
|
||||
command:
|
||||
- '--config.file=/etc/alertmanager/alertmanager.yml'
|
||||
- '--storage.path=/alertmanager'
|
||||
|
||||
volumes:
|
||||
prometheus-data:
|
||||
grafana-data:
|
||||
alertmanager-data:
|
||||
11
docker/grafana/provisioning/dashboards/dashboards.yml
Normal file
11
docker/grafana/provisioning/dashboards/dashboards.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
apiVersion: 1
|
||||
providers:
|
||||
- name: 'nexus-dashboards'
|
||||
orgId: 1
|
||||
folder: 'Nexus'
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 10
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards
|
||||
149
docker/grafana/provisioning/dashboards/nexus-gunicorn.json
Normal file
149
docker/grafana/provisioning/dashboards/nexus-gunicorn.json
Normal file
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"uid": "nexus-app",
|
||||
"title": "Nexus — Application",
|
||||
"tags": ["gunicorn", "flask"],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 36,
|
||||
"refresh": "30s",
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "datasource",
|
||||
"type": "datasource",
|
||||
"query": "prometheus",
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "Prometheus",
|
||||
"value": "Prometheus"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Request Rate (nginx)",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(nginx_http_requests_total[5m])",
|
||||
"legendFormat": "Requests/sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "reqps",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Response Time (nginx)",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "histogram_quantile(0.95, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "histogram_quantile(0.99, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p99",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "s",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Active Workers",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "count by (instance) (node_processes_state{state=\"S\", cmdline=~\".*gunicorn.*\"})",
|
||||
"legendFormat": "Workers {{instance}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "short",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "5xx Errors",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(nginx_http_requests_total{status=~\"5..\"}[5m])",
|
||||
"legendFormat": "5xx/sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "reqps",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Memory per Worker",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 16},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "process_resident_memory_bytes{cmdline=~\".*gunicorn.*\"} / 1024 / 1024",
|
||||
"legendFormat": "{{cmdline}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "mbytes",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
185
docker/grafana/provisioning/dashboards/nexus-postgresql.json
Normal file
185
docker/grafana/provisioning/dashboards/nexus-postgresql.json
Normal file
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"uid": "nexus-postgresql",
|
||||
"title": "Nexus — PostgreSQL",
|
||||
"tags": ["postgres", "database"],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 36,
|
||||
"refresh": "30s",
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "datasource",
|
||||
"type": "datasource",
|
||||
"query": "prometheus",
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "Prometheus",
|
||||
"value": "Prometheus"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Active Connections",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "pg_stat_activity_count",
|
||||
"legendFormat": "Connections",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "short",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Transactions / sec",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(pg_stat_database_xact_commit[5m])",
|
||||
"legendFormat": "Commits",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(pg_stat_database_xact_rollback[5m])",
|
||||
"legendFormat": "Rollbacks",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "tps",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Cache Hit Ratio",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "pg_stat_database_blks_hit / (pg_stat_database_blks_hit + pg_stat_database_blks_read)",
|
||||
"legendFormat": "Hit Ratio",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "percentunit",
|
||||
"min": 0,
|
||||
"max": 1
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "WAL Generation",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(pg_stat_bgwriter_buffers_backend_fsync[5m])",
|
||||
"legendFormat": "Backend fsync",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(pg_stat_bgwriter_buffers_backend[5m])",
|
||||
"legendFormat": "Backend buffers",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "ops",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Slow Queries",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "pg_stat_activity_count{state=\"active\"}",
|
||||
"legendFormat": "Active queries",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "short",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Table Bloat",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 16},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "pg_stat_user_tables_n_live_tup",
|
||||
"legendFormat": "Live tuples",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "pg_stat_user_tables_n_dead_tup",
|
||||
"legendFormat": "Dead tuples",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "short",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
173
docker/grafana/provisioning/dashboards/nexus-redis.json
Normal file
173
docker/grafana/provisioning/dashboards/nexus-redis.json
Normal file
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"uid": "nexus-redis",
|
||||
"title": "Nexus — Redis",
|
||||
"tags": ["redis", "cache"],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 36,
|
||||
"refresh": "30s",
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "datasource",
|
||||
"type": "datasource",
|
||||
"query": "prometheus",
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "Prometheus",
|
||||
"value": "Prometheus"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Memory Usage",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "redis_memory_used_bytes",
|
||||
"legendFormat": "Used memory",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "bytes",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Commands / sec",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(redis_commands_processed_total[5m])",
|
||||
"legendFormat": "Commands/sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "cps",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Connected Clients",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "redis_connected_clients",
|
||||
"legendFormat": "Clients",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "short",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Cache Hit Ratio",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total)",
|
||||
"legendFormat": "Hit Ratio",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "percentunit",
|
||||
"min": 0,
|
||||
"max": 1
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Keyspace Hits / Misses",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(redis_keyspace_hits_total[5m])",
|
||||
"legendFormat": "Hits/sec",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(redis_keyspace_misses_total[5m])",
|
||||
"legendFormat": "Misses/sec",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "cps",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Evicted Keys",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 16},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(redis_evicted_keys_total[5m])",
|
||||
"legendFormat": "Evicted/sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "cps",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
164
docker/grafana/provisioning/dashboards/nexus-system.json
Normal file
164
docker/grafana/provisioning/dashboards/nexus-system.json
Normal file
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"uid": "nexus-system",
|
||||
"title": "Nexus — System",
|
||||
"tags": ["node", "system"],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 36,
|
||||
"refresh": "30s",
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "datasource",
|
||||
"type": "datasource",
|
||||
"query": "prometheus",
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "Prometheus",
|
||||
"value": "Prometheus"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "CPU Usage %",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "100 - (avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"legendFormat": "CPU %",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Memory Usage %",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)",
|
||||
"legendFormat": "Memory %",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Disk Usage %",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "100 * (1 - node_filesystem_avail_bytes{fstype!~\"tmpfs|ramfs\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|ramfs\"})",
|
||||
"legendFormat": "{{mountpoint}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Network I/O",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(node_network_receive_bytes_total[5m])",
|
||||
"legendFormat": "Receive {{device}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "rate(node_network_transmit_bytes_total[5m])",
|
||||
"legendFormat": "Transmit {{device}}",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "Bps",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Load Average",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 16},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "node_load1",
|
||||
"legendFormat": "1m load",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "node_load5",
|
||||
"legendFormat": "5m load",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "node_load15",
|
||||
"legendFormat": "15m load",
|
||||
"refId": "C"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "short",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
9
docker/grafana/provisioning/datasources/prometheus.yml
Normal file
9
docker/grafana/provisioning/datasources/prometheus.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
47
docker/prometheus/alerts.yml
Normal file
47
docker/prometheus/alerts.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
groups:
|
||||
- name: nexus-alerts
|
||||
rules:
|
||||
- alert: PostgreSQLDown
|
||||
expr: pg_up == 0
|
||||
for: 1m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "PostgreSQL is down"
|
||||
description: "PostgreSQL has been down for more than 1 minute."
|
||||
|
||||
- alert: RedisDown
|
||||
expr: redis_up == 0
|
||||
for: 1m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Redis is down"
|
||||
description: "Redis has been down for more than 1 minute."
|
||||
|
||||
- alert: HighDiskUsage
|
||||
expr: (node_filesystem_avail_bytes / node_filesystem_size_bytes) * 100 < 10
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Disk usage is high"
|
||||
description: "Disk usage is above 90% on {{ $labels.device }}."
|
||||
|
||||
- alert: HighMemoryUsage
|
||||
expr: (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes * 100 > 85
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Memory usage is high"
|
||||
description: "Memory usage is above 85%."
|
||||
|
||||
- alert: NodeDown
|
||||
expr: up{job="node"} == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Node exporter is down"
|
||||
description: "Node exporter has been down for more than 2 minutes."
|
||||
40
docker/prometheus/prometheus.yml
Normal file
40
docker/prometheus/prometheus.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
# Alertmanager configuration
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: ['alertmanager:9093']
|
||||
|
||||
# Load rules once and periodically evaluate them
|
||||
rule_files:
|
||||
- 'alerts.yml'
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'prometheus'
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
|
||||
- job_name: 'node'
|
||||
static_configs:
|
||||
- targets: ['node-exporter:9100']
|
||||
|
||||
- job_name: 'postgres'
|
||||
static_configs:
|
||||
- targets: ['postgres-exporter:9187']
|
||||
|
||||
- job_name: 'redis'
|
||||
static_configs:
|
||||
- targets: ['redis-exporter:9121']
|
||||
|
||||
- job_name: 'nexus-pos'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:5001']
|
||||
metrics_path: /metrics
|
||||
|
||||
- job_name: 'nexus-quart'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:5002']
|
||||
metrics_path: /metrics
|
||||
150
docs/API-POS.md
150
docs/API-POS.md
@@ -1430,3 +1430,153 @@ Todos los errores retornan:
|
||||
```json
|
||||
{"error": "Descripcion del error"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. BNPL (Buy Now Pay Later) — Stub
|
||||
|
||||
Prefix: `/pos/api/bnpl`
|
||||
|
||||
Stubs ready for APLAZO, Kueski, Clip integration.
|
||||
|
||||
### GET /providers
|
||||
|
||||
List configured BNPL providers.
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{"id": "ap lazo", "name": "APLAZO", "enabled": false, "config_needed": ["api_key", "merchant_id"]},
|
||||
{"id": "kueski", "name": "Kueski Pay", "enabled": false, "config_needed": ["api_key", "secret"]},
|
||||
{"id": "clip", "name": "Clip Pagos", "enabled": false, "config_needed": ["api_key"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /applications
|
||||
|
||||
Create a BNPL application for a sale.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"sale_id": 123,
|
||||
"amount": 1500.00,
|
||||
"provider": "ap lazo",
|
||||
"customer": {"name": "Juan Perez", "phone": "5512345678"}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /applications/:id
|
||||
|
||||
Get application status.
|
||||
|
||||
### POST /webhook/:provider
|
||||
|
||||
Receive provider webhooks.
|
||||
|
||||
---
|
||||
|
||||
## 12. ERP Sync — Stub
|
||||
|
||||
Prefix: `/pos/api/erp`
|
||||
|
||||
Stubs ready for Aspel SAE, CONTPAQi, SAP B1, Odoo.
|
||||
|
||||
### GET /providers
|
||||
|
||||
List supported ERP systems.
|
||||
|
||||
### POST /sync
|
||||
|
||||
Start a sync job.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"provider": "aspel_sae",
|
||||
"sync_type": "sales"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /sync/:job_id
|
||||
|
||||
Get sync job status.
|
||||
|
||||
### POST /sync/:job_id/run
|
||||
|
||||
Mock execute sync job.
|
||||
|
||||
---
|
||||
|
||||
## 13. WhatsApp Business API (Meta Cloud) — Stub
|
||||
|
||||
Prefix: `/pos/api/whatsapp-cloud`
|
||||
|
||||
### GET/POST /webhook
|
||||
|
||||
Meta Cloud API webhook verification and message reception.
|
||||
|
||||
### GET /status
|
||||
|
||||
Check Meta Cloud API connection status.
|
||||
|
||||
### GET /templates
|
||||
|
||||
List approved message templates.
|
||||
|
||||
### POST /messages
|
||||
|
||||
Send a message.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"to": "5215512345678",
|
||||
"body": "Su orden esta lista",
|
||||
"template": "order_ready"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Supplier Portal
|
||||
|
||||
Prefix: `/pos/api/supplier-portal`
|
||||
|
||||
### GET /demand
|
||||
|
||||
Aggregated demand by zone, part group, and time range.
|
||||
|
||||
**Query params:** `days` (default 30), `group_id`, `branch_id`
|
||||
|
||||
### GET /top-parts
|
||||
|
||||
Top 50 moving parts with current stock.
|
||||
|
||||
**Query params:** `days` (default 30)
|
||||
|
||||
---
|
||||
|
||||
## 15. Dashboard Stats
|
||||
|
||||
Prefix: `/pos/api/dashboard`
|
||||
|
||||
### GET /stats
|
||||
|
||||
Summary stats for today and this month.
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"today": {"sales_count": 42, "sales_total": 12500.00},
|
||||
"month": {"sales_count": 1200, "sales_total": 450000.00},
|
||||
"top_products": [...],
|
||||
"hourly_sales": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /stats/employees
|
||||
|
||||
Sales per employee today.
|
||||
|
||||
@@ -447,3 +447,42 @@ const data = await res.json();
|
||||
### Nota sobre NUMERIC de PostgreSQL
|
||||
|
||||
PostgreSQL retorna valores `NUMERIC` como strings. Todas las funciones de formato en JS usan `parseFloat()` para convertir antes de mostrar.
|
||||
|
||||
---
|
||||
|
||||
## Infraestructura y Monitoreo (2026-04)
|
||||
|
||||
### Servicios Systemd
|
||||
|
||||
| Servicio | Descripcion | Puerto |
|
||||
|----------|-------------|--------|
|
||||
| `nexus-pos.service` | Gunicorn POS (Flask) | 5001 |
|
||||
| `nexus.service` | Dashboard (Flask) | 5000 |
|
||||
| `nexus-quart.service` | Hypercorn async catalog | 5002 |
|
||||
| `nexus-celery.service` | Celery workers | — |
|
||||
| `nexus-mv-refresh.timer` | Refresh MV diario 03:00 UTC | — |
|
||||
| `nexus-cache-warm.timer` | Cache warming diario 04:00 UTC | — |
|
||||
|
||||
### Monitoreo (Prometheus + Grafana)
|
||||
|
||||
- **Prometheus** :9090 — métricas de sistema, PostgreSQL, Redis
|
||||
- **Grafana** :3001 — dashboards visuales (login admin/nexus2026)
|
||||
- Exporters: node-exporter, postgres-exporter, redis-exporter
|
||||
|
||||
### Stubs de Integraciones de Negocio
|
||||
|
||||
- **BNPL** (`bnpl_bp.py`): APLAZO, Kueski, Clip
|
||||
- **ERP Sync** (`erp_bp.py`): Aspel SAE, CONTPAQi, SAP B1, Odoo
|
||||
- **WhatsApp Meta Cloud** (`whatsapp_cloud_bp.py`): reemplazo escalable de Baileys
|
||||
- **Supplier Portal** (`supplier_portal_bp.py`): demanda por zona y top partes
|
||||
|
||||
### PWA
|
||||
|
||||
- Service Worker (`pos/static/pwa/sw.js`) con cache-first para assets
|
||||
- Install prompt (`pos/static/js/pwa-install.js`) captura `beforeinstallprompt`
|
||||
- Manifest registrado en 14 templates POS
|
||||
|
||||
### Tests E2E
|
||||
|
||||
- Playwright + Chromium
|
||||
- Primer smoke test: login page loads and rejects invalid credentials
|
||||
|
||||
46
docs/CADDY_CONFIG.md
Normal file
46
docs/CADDY_CONFIG.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Caddy Config for nexusautoparts.com.mx
|
||||
|
||||
## VM with Caddy: 192.168.10.74
|
||||
## VM with POS/Dashboard: 192.168.10.91
|
||||
|
||||
Add this to `/etc/caddy/Caddyfile` on the Caddy VM (192.168.10.74):
|
||||
|
||||
```caddyfile
|
||||
# Landing page / Dashboard
|
||||
nexusautoparts.com.mx, www.nexusautoparts.com.mx {
|
||||
reverse_proxy 192.168.10.91:80
|
||||
}
|
||||
|
||||
# POS (point of sale app)
|
||||
pos.nexusautoparts.com.mx {
|
||||
reverse_proxy 192.168.10.91:80
|
||||
}
|
||||
|
||||
# Dashboard admin (optional alternative access)
|
||||
admin.nexusautoparts.com.mx {
|
||||
reverse_proxy 192.168.10.91:80
|
||||
}
|
||||
|
||||
# Legacy domain (optional, redirect or keep)
|
||||
nexus.consultoria-as.com {
|
||||
reverse_proxy 192.168.10.91:80
|
||||
}
|
||||
```
|
||||
|
||||
Then reload Caddy:
|
||||
```bash
|
||||
sudo systemctl reload caddy
|
||||
# or
|
||||
sudo caddy reload --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
Caddy will automatically obtain Let's Encrypt certificates for all domains.
|
||||
|
||||
## DNS Records needed in Hostinger
|
||||
|
||||
| Record | Name | Target |
|
||||
|---|---|---|
|
||||
| A | @ | IP of Caddy VM |
|
||||
| CNAME | www | nexusautoparts.com.mx |
|
||||
| CNAME | pos | nexusautoparts.com.mx |
|
||||
| CNAME | admin | nexusautoparts.com.mx |
|
||||
@@ -1,8 +1,9 @@
|
||||
# Nexus POS — Resumen de Fases Implementadas
|
||||
|
||||
**Fecha:** 2026-04-27
|
||||
**Versión DB:** v3.2
|
||||
**Tests:** 108/108 pasando (pytest) + 207 checks (scripts standalone)
|
||||
**Fecha:** 2026-06-11
|
||||
**Versión DB:** v4.1
|
||||
**Tests:** 73/73 pasando (pytest)
|
||||
**Commit:** `2b73c2c`
|
||||
|
||||
---
|
||||
|
||||
@@ -17,8 +18,6 @@
|
||||
|
||||
## FASE 3: Multi-sucursal + Alertas + Garantías
|
||||
|
||||
### Migración: v2.2
|
||||
|
||||
| Feature | Archivos | Endpoints |
|
||||
|---------|----------|-----------|
|
||||
| **Multi-sucursal** | `inventory_engine.py`, `inventory_bp.py` | `GET /pos/api/inventory/stock-by-branch`, `POST /pos/api/inventory/transfers`, `POST /pos/api/inventory/sync-prices` |
|
||||
@@ -27,8 +26,6 @@
|
||||
|
||||
## FASE 4: Infraestructura + Escalabilidad
|
||||
|
||||
### Migraciones: v1.9, v2.0, v2.1, v2.3
|
||||
|
||||
| Feature | Archivos | Infra |
|
||||
|---------|----------|-------|
|
||||
| **Redis Cache** | `redis_stock_cache.py`, `inventory_engine.py` | Redis 8.0.2, TTL 300s, fallback a PostgreSQL |
|
||||
@@ -39,8 +36,6 @@
|
||||
|
||||
## FASE 5: CRM + Service Orders + Imágenes
|
||||
|
||||
### Migraciones: v2.4, v2.5, v2.6
|
||||
|
||||
| Feature | Archivos | Capacidades |
|
||||
|---------|----------|-------------|
|
||||
| **CRM Mejorado** | `crm_engine.py`, `crm_bp.py` | Activities timeline, tags de segmentación, loyalty program (bronze/silver/gold/platinum), analytics (LTV, churn risk, categorías favoritas) |
|
||||
@@ -53,8 +48,6 @@
|
||||
|
||||
## FASE 6: Notificaciones + Ahorro + Logística + API Pública
|
||||
|
||||
### Migraciones: v2.7, v2.8, v2.9, v3.0
|
||||
|
||||
| Feature | Archivos | Capacidades |
|
||||
|---------|----------|-------------|
|
||||
| **Notificaciones** | `notification_engine.py`, `notification_bp.py` | Templates por evento+canal, dispatch automático (push/WhatsApp/email/in-app), logs con estados, eventos: low_stock, order_ready, maintenance_due, new_sale, po_received, reorder_alert, warranty_expiring |
|
||||
@@ -64,8 +57,6 @@
|
||||
|
||||
## FASE 7: Performance Optimización
|
||||
|
||||
### Migración: v3.2
|
||||
|
||||
| Sub-fase | Archivos | Optimizaciones |
|
||||
|----------|----------|----------------|
|
||||
| **7a — Quick Wins Frontend** | `nginx/nexus-pos.conf`, `pos/templates/*.html`, `pos/static/js/catalog.js` | gzip nginx, `defer` en scripts, fix `innerHTML +=` (8 lugares), event delegation cart, AbortController, sessionStorage cache years/brands |
|
||||
@@ -81,18 +72,71 @@
|
||||
- Ventas 20 ítems: 21 queries → 1 query
|
||||
- Cache hit rate: 6% → 80%+
|
||||
|
||||
## Opción C — Consolidación Técnica (COMPLETADA)
|
||||
|
||||
| Item | Estado | Commit |
|
||||
|------|--------|--------|
|
||||
| **C1: MV `part_vehicle_preview`** | ✅ En producción, refresh automático vía systemd timer (03:00 UTC) | `f893391` |
|
||||
| **C2: Cache warming script** | ✅ Autónomo con auto-sudo fallback, args CLI | `f893391` |
|
||||
| **C3: CSS dinámico residual** | ✅ `sidebar.js` → `sidebar.css`, `pos-utils.js` → `common.css` | `042acd6` |
|
||||
| **C4: Load testing script** | ✅ `scripts/load_test.py` con `locust` | `042acd6` |
|
||||
| **C5: Docs audit** | ✅ `FASES_IMPLEMENTADAS.md`, `performance_audit_2026.md` | `042acd6` |
|
||||
|
||||
## Opción A — Arquitectura Avanzada (COMPLETADA)
|
||||
|
||||
| Item | Estado | Commit |
|
||||
|------|--------|--------|
|
||||
| **A1: `orjson` como JSON provider** | ✅ Hereda `DefaultJSONProvider`, fix indent en `pos_bp.py` | `a1be8dd` |
|
||||
| **A2: Virtual scroll** | ✅ `inventory.js`, `customers.js`, `fleet.js` | `a1be8dd` |
|
||||
| **A3: Celery worker queue** | ✅ `celery_app.py`, `tasks.py`, `tasks_bp.py`, systemd service activo | `a1be8dd` |
|
||||
| **A4: Quart + asyncpg PoC** | ✅ `async_catalog.py` en puerto 5002, benchmark script | `a1be8dd` |
|
||||
| **A5: Particionamiento `vehicle_parts`** | ✅ Script `partition_vehicle_parts.py` listo (HASH 16 particiones, dry-run) | `a1be8dd` |
|
||||
|
||||
## IA por Voz — Chalán de Nexus (COMPLETADA)
|
||||
|
||||
| Componente | Estado |
|
||||
|------------|--------|
|
||||
| **STT (Speech-to-Text)** | ✅ POS + Dashboard público, `es-MX`, auto-send, animación micrófono |
|
||||
| **TTS (Text-to-Speech)** | ✅ Botón 🔊 en burbujas de IA, `speechSynthesis`, preferencia guardada en `localStorage` |
|
||||
| **Cobertura templates POS** | ✅ 14/14 templates tienen chat widget |
|
||||
| **Dashboard público** | ✅ Chat público con voz completa (sin cámara) |
|
||||
|
||||
## QWEN 3.6 AI Vehicle Fitment (COMPLETADA)
|
||||
|
||||
| Componente | Archivo | Descripción |
|
||||
|------------|---------|-------------|
|
||||
| **Servicio QWEN** | `pos/services/qwen_fitment.py` | Consulta API OpenAI-compatible (`qwen3.6`) con part_number + name + brand; parsea JSON robusto (vehicles/confidence/notes); valida contra `model_year_engine` |
|
||||
| **Integración Inventario** | `pos/blueprints/inventory_bp.py` | `create_item()` llama QWEN después de TecDoc auto-match; inserta en `inventory_vehicle_compat` con `source='qwen_ai'` |
|
||||
| **UI** | `pos/static/js/inventory.js`, `pos/templates/inventory.html` | Toast muestra count de vehículos asignados por IA; tabla de compatibilidad muestra columna "Origen" |
|
||||
| **Retry / Fallback** | `qwen_fitment.py` | 3 reintentos ante respuesta vacía; fallback sin filtro de motor (descripciones de motor rara vez coinciden entre QWEN y TecDB); búsqueda parcial de modelo (`%Corolla%`) para nombres TecDoc |
|
||||
|
||||
**Flujo:**
|
||||
1. Usuario crea ítem de inventario (part_number, name, brand)
|
||||
2. TecDoc auto-match ejecuta primero (si hay coincidencia exacta)
|
||||
3. QWEN 3.6 recibe los datos y devuelve lista de vehículos compatibles en JSON
|
||||
4. Cada vehículo se busca en la DB maestra (fuzzy match por modelo, fallback sin motor)
|
||||
5. Los `model_year_engine_id` válidos se insertan en `inventory_vehicle_compat` con `source='qwen_ai'`
|
||||
6. Frontend muestra toast: "27 vehículo(s) asignado(s) por IA"
|
||||
|
||||
**Fail-safe:** Si QWEN no está configurado o la API falla, el ítem se crea normalmente; la asignación de vehículos se omite silenciosamente.
|
||||
|
||||
---
|
||||
|
||||
## Infraestructura Desplegada
|
||||
|
||||
| Servicio | Versión | Puerto | Estado |
|
||||
|----------|---------|--------|--------|
|
||||
| PostgreSQL | 17 | 5432 | ✅ Master + 2 tenants |
|
||||
| PostgreSQL | 17 | 5432 | ✅ Optimizado (8GB shared_buffers, 64MB work_mem, 8GB max_wal_size) |
|
||||
| Redis | 8.0.2 | 6379 | ✅ Stock cache + classify cache |
|
||||
| Meilisearch | v1.12 | 7700 | ✅ 1,546,976 documentos |
|
||||
| Metabase | v0.53 | 3000 | ✅ Dashboard ID 2 |
|
||||
| Nginx | — | 80/443 | ✅ gzip, cache 6M, auto-serve .min |
|
||||
| Gunicorn | — | 5001 | ✅ gthread, 4×4, max_requests=1000 |
|
||||
| Gunicorn POS | — | 5001 | ✅ systemd `nexus-pos.service`, gthread 4×4 |
|
||||
| Gunicorn Dashboard | — | 5000 | ✅ systemd `nexus.service` |
|
||||
| Quart Catalog | — | 5002 | ✅ systemd `nexus-quart.service`, hypercorn |
|
||||
| Celery | — | — | ✅ 4 prefork workers, broker redis://localhost:6379/1 |
|
||||
| Prometheus | v2.51 | 9090 | ✅ Docker, node/postgres/redis exporters |
|
||||
| Grafana | v10.4 | 3001 | ✅ Docker, auto-provisioned Prometheus datasource |
|
||||
|
||||
---
|
||||
|
||||
@@ -125,6 +169,11 @@ WHATSAPP_BRIDGE_KEY=
|
||||
# AI (opcional)
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# QWEN AI Fitment (opcional)
|
||||
QWEN_API_URL=https://api.nan.builders/v1
|
||||
QWEN_API_KEY=
|
||||
QWEN_MODEL=qwen3.6
|
||||
|
||||
# Metabase (opcional)
|
||||
METABASE_DB_PASS=
|
||||
METABASE_URL=http://localhost:3000
|
||||
@@ -132,28 +181,104 @@ METABASE_URL=http://localhost:3000
|
||||
|
||||
---
|
||||
|
||||
## Próximos Pasos (Roadmap restante)
|
||||
## ✅ Completados recientemente
|
||||
|
||||
### Opción C — Consolidación Técnica (en progreso)
|
||||
1. **Materialized view `part_vehicle_preview`** — Fallback robusto al Redis cache para vehicle info
|
||||
2. **Fix cache warming script** — Autonomía sin `sudo -u postgres`
|
||||
3. **CSS dinámico residual** — Extraer CSS inyectado por JS a archivos externos
|
||||
4. **Load testing script** — Benchmark básico de endpoints críticos
|
||||
5. **Docs audit** — Corregir métricas y marcar estado post-FASE 7
|
||||
| # | Mejora | Fecha | Commit |
|
||||
|---|--------|-------|--------|
|
||||
| — | **Particionar `vehicle_parts` en producción** | 2026-04-26 | `f24f25e` |
|
||||
| — | **Quart async catalog en producción** | 2026-04-26 | `b829e4f` |
|
||||
| — | **Arreglar `scripts/minify-assets.sh`** | 2026-04-26 | `b829e4f` |
|
||||
| — | **Dashboard outage fix (env vars + static files)** | 2026-04-26 | `27cb4ee` |
|
||||
| — | **IA por Voz (STT + TTS) en POS y Dashboard** | 2026-04-26 | `afb3b24` |
|
||||
| — | **Fix chat.js null reference (`chatTtsToggle`)** | 2026-04-29 | `44c3a6c` |
|
||||
| — | **Optimizar PostgreSQL config + restart** | 2026-04-29 | — |
|
||||
| — | **Cache warming systemd timer** | 2026-04-29 | `c766571` |
|
||||
| — | **Monitoreo Prometheus + Grafana** | 2026-04-29 | `4b3b0f8` |
|
||||
| — | **PWA install prompt** | 2026-04-29 | `3b8224d` |
|
||||
| — | **Playwright E2E tests** | 2026-04-29 | `c4db5e7` |
|
||||
| — | **Dashboard in-app charts** | 2026-04-29 | `12989e3` |
|
||||
| — | **Stubs BNPL / ERP / WhatsApp Cloud / Supplier Portal** | 2026-04-29 | `2cfe4b3` |
|
||||
| — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` |
|
||||
| — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` |
|
||||
|
||||
### Opción A — Arquitectura (pendiente)
|
||||
1. **Serialización `orjson`** — 2-10× faster JSON
|
||||
2. **Virtual scroll** — Tablas grandes sin lag
|
||||
3. **Celery worker queue** — Tareas pesadas async
|
||||
4. **Asyncpg + Quart PoC** — Evaluar I/O no bloqueante para catálogo
|
||||
5. **Particionar `vehicle_parts`** — Escalabilidad ilimitada (254 GB → particiones)
|
||||
## FASE 7: Precios de Proveedor + Multi-sucursal + Factura Global
|
||||
|
||||
### Features de Negocio (futuro)
|
||||
1. **Mercado Libre / Amazon sync** — Publicar inventario en marketplaces
|
||||
2. **IA por voz (Chalán de Nexus)** — Web Speech API → chatbot existente
|
||||
3. **PWA mejorada** — Offline mode, install prompt, background sync
|
||||
4. **Portal de proveedores** — Demand analytics, heatmaps, stock recommendations
|
||||
5. **Dashboard in-app** — Gráficos de rendimiento en tiempo real
|
||||
**Commit:** `2b73c2c` (2026-06-11)
|
||||
|
||||
### 7.1 Lista de Precios de Proveedor
|
||||
|
||||
| Feature | Archivos | Capacidades |
|
||||
|---------|----------|-------------|
|
||||
| **Precios por proveedor** | `supplier_catalog_prices` (master DB) | Precio, moneda, vigencia (effective_from/to), activo/inactivo |
|
||||
| **Upload masivo** | `supplier_catalog_bp.py` | CSV/Excel con supplier_name, sku, price, currency |
|
||||
| **Visualización** | `catalog.js`, `catalog_service.py` | `supplier_price` + `supplier_currency` en tarjetas y búsqueda |
|
||||
| **Endpoints** | `supplier_catalog_bp.py` | `GET/POST/PUT/DELETE /pos/api/supplier-catalog/prices/*` |
|
||||
|
||||
### 7.2 Multi-sucursal Completo
|
||||
|
||||
| Feature | Archivos | Capacidades |
|
||||
|---------|----------|-------------|
|
||||
| **Schema migration v4.0** | `v4.0_multi_branch.sql` | `inventory.branch_id=NULL` (catálogo compartido), tabla `inventory_stock` |
|
||||
| **Datos fiscales por sucursal** | `branches` (tenant DB) | `rfc`, `razon_social`, `regimen_fiscal`, `codigo_postal`, `serie_cfdi`, `folio_inicial`, `licencia_fiscal`, `certificado_pem`, `llave_pem`, `is_main` |
|
||||
| **Sincronización de stock** | Trigger `trg_update_inventory_stock` | `inventory_operations` → `inventory_stock` automático |
|
||||
| **Backend branches** | `config_bp.py` | CRUD completo con campos fiscales, validación de única sucursal `is_main` |
|
||||
| **Backend inventario** | `inventory_bp.py`, `inventory_engine.py`, `pos_bp.py` | Stock por sucursal vía `inventory_stock`, catálogo compartido, verificación de stock en POS |
|
||||
| **Backend facturación** | `invoicing_bp.py` | CFDI usa datos fiscales de la sucursal de la venta (`_get_issuer_config`) |
|
||||
| **Frontend config** | `config.html`, `config.js` | Modal de sucursal expandido con todos los campos fiscales, edición inline |
|
||||
|
||||
### 7.3 Factura Global Mensual
|
||||
|
||||
| Feature | Archivos | Capacidades |
|
||||
|---------|----------|-------------|
|
||||
| **Schema migration v4.1** | `v4.1_global_invoice.sql` | `global_invoice_sales`, `sales.global_invoiced_at` |
|
||||
| **Builder CFDI global** | `cfdi_builder.py` | `build_global_invoice_xml()` con `InformacionGlobal` SAT-compliant (`Periodicidad="04"`) |
|
||||
| **Servicio** | `global_invoice.py` | Agrupa ventas PUE ≤$2,000 sin CFDI individual del mes/año solicitado |
|
||||
| **Endpoints** | `invoicing_bp.py` | `POST /global-invoice`, `GET /global-invoice/<id>`, `GET /global-invoice/eligible-sales` |
|
||||
| **Frontend** | `invoicing.html`, `invoicing.js` | Botón "Factura Global" con modal de año/mes + vista previa de ventas elegibles |
|
||||
|
||||
### 7.4 Mercado Libre — Mejoras
|
||||
|
||||
| Feature | Archivos | Capacidades |
|
||||
|---------|----------|-------------|
|
||||
| **Importar publicaciones existentes** | `meli_service.py`, `marketplace_external_service.py` | `get_user_items()` + `import_existing_listings()` — importa items del vendedor a `marketplace_listings` intentando match por SKU/part_number |
|
||||
| **Sync stock POS → ML** | `inventory_engine.py`, `marketplace_external_service.py` | Trigger en `inventory_operations` inserta en `meli_sync_queue`; `process_meli_sync_queue()` actualiza `available_quantity` en ML vía API |
|
||||
| **Sync órdenes ML → POS** | `marketplace_external_bp.py` | `POST /orders/sync` para sincronización manual; webhook `/webhook/meli` ya maneja notificaciones de órdenes vía Celery |
|
||||
| **Migration v4.2** | `v4.2_meli_sync_queue.sql` | Tabla `meli_sync_queue` para encolar actualizaciones de stock |
|
||||
|
||||
---
|
||||
|
||||
## Mejoras Pendientes (Roadmap Actualizado)
|
||||
|
||||
### 🔴 Crítico — Deuda Técnica
|
||||
|
||||
*Sin items críticos pendientes.*
|
||||
|
||||
### 🟠 Alto — Features de Negocio (requieren integración con terceros)
|
||||
|
||||
| # | Mejora | Descripción | Esfuerzo | Notas |
|
||||
|---|--------|-------------|----------|-------|
|
||||
| 1 | **WhatsApp Business API (Meta Cloud) real** | Migrar de Baileys a Meta Cloud API. Requiere verificación de cuenta Meta, Business Manager, número de teléfono verificado. | 2-3 semanas | Stub creado (`whatsapp_cloud_bp.py`) |
|
||||
| 2 | **BNPL real** | Integrar APLAZO/Kueski/Clip con credenciales de sandbox/producción. | 2 semanas | Stub creado (`bnpl_bp.py`) |
|
||||
| 3 | **ERP Sync real** | Conectar Aspel/CONTPAQi/SAP/Odoo vía API o archivos de intercambio. | 2-3 semanas | Stub creado (`erp_bp.py`) |
|
||||
| 4 | **Mercado Libre / Amazon sync** | Publicar inventario de bodegas en marketplaces. API de ML Seller + Amazon SP-API. | 3 semanas | **Parcialmente listo** — ver Fase 7.4 |
|
||||
|
||||
### 🟡 Medio — Diferenciadores
|
||||
|
||||
| # | Mejora | Descripción | Esfuerzo |
|
||||
|---|--------|-------------|----------|
|
||||
| 5 | **App móvil nativa (Capacitor)** | Wrap del POS como app iOS/Android. Camera nativa, push notifications, biometrics. | 3-4 semanas |
|
||||
| 6 | **Crédito basado en comportamiento** | Evaluación automática de línea de crédito por historial de pagos del cliente. | 2 semanas |
|
||||
| 7 | **Programa de embajadores** | Referidos con recompensas, tracking de conversiones. | 1 semana |
|
||||
|
||||
### 🟢 Bajo — Polish
|
||||
|
||||
| # | Mejora | Descripción |
|
||||
|---|--------|-------------|
|
||||
| 8 | **Backup automatizado** | Último backup 2026-04-27. Automatizar con cron + S3/GCS. |
|
||||
| 9 | **Grafana dashboards predefinidos** | Actualmente solo datasource auto-provisionado. Falta crear dashboards JSON para PostgreSQL, Redis, Gunicorn. |
|
||||
| 10 | **Alertas Prometheus** | Alertmanager para notificaciones cuando PostgreSQL, Redis o Gunicorn fallen. |
|
||||
| 11 | **Tests E2E adicionales** | Playwright: checkout, búsqueda de catálogo, flujo de inventario. |
|
||||
| 12 | **Service Worker mejorado** | Background sync real para carrito offline, notificaciones push. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
82
docs/GLOBAL_INVOICE.md
Normal file
82
docs/GLOBAL_INVOICE.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Factura Global Mensual — Documentación Técnica
|
||||
|
||||
**Versión DB:** v4.1
|
||||
**Commit:** `2b73c2c`
|
||||
|
||||
---
|
||||
|
||||
## Requerimiento SAT
|
||||
|
||||
El SAT permite agrupar tickets de contado (menores a $2,000) en una sola factura mensual tipo **Ingreso** con `InformacionGlobal`.
|
||||
|
||||
## Criterios de elegibilidad
|
||||
|
||||
Una venta es elegible para factura global si:
|
||||
1. `metodo_pago_sat = 'PUE'` (pagado al momento)
|
||||
2. `total <= $2,000`
|
||||
3. `status = 'completed'`
|
||||
4. No tiene CFDI individual timbrado (`cfdi_queue.status = 'stamped'`)
|
||||
5. No está ya en una factura global (`sales.global_invoiced_at IS NULL`)
|
||||
6. Fecha dentro del mes/año solicitado
|
||||
|
||||
## Arquitectura
|
||||
|
||||
### Tablas
|
||||
- `global_invoice_sales (global_invoice_id, sale_id)` — relación N:M
|
||||
- `sales.global_invoiced_at` — marca de inclusión
|
||||
|
||||
### XML
|
||||
- `build_global_invoice_xml()` en `cfdi_builder.py`
|
||||
- `InformacionGlobal Periodicidad="04"` (mensual)
|
||||
- Receptor: `PUBLICO EN GENERAL` (RFC XAXX010101000)
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/pos/api/invoicing/global-invoice/eligible-sales?year=&month=&branch_id=` | Preview de ventas elegibles |
|
||||
| `POST` | `/pos/api/invoicing/global-invoice` | Genera factura global |
|
||||
| `GET` | `/pos/api/invoicing/global-invoice/<id>` | Estado y ventas vinculadas |
|
||||
|
||||
### POST body
|
||||
```json
|
||||
{
|
||||
"year": 2026,
|
||||
"month": 6,
|
||||
"branch_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"status": "pending",
|
||||
"sales_count": 15,
|
||||
"total": 18450.00,
|
||||
"provisional_folio": "PRE-00042",
|
||||
"xml": "<?xml ...>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flujo de uso (Frontend)
|
||||
|
||||
1. Ir a **Facturación**
|
||||
2. Clic en botón **Factura Global**
|
||||
3. Seleccionar año y mes
|
||||
4. Clic en **Vista previa** para ver ventas elegibles
|
||||
5. Clic en **Generar** para crear y encolar el CFDI
|
||||
6. Procesar cola de timbrado normalmente
|
||||
|
||||
---
|
||||
|
||||
## Timbrado
|
||||
|
||||
La factura global entra en la cola `cfdi_queue` con:
|
||||
- `type = 'ingreso'`
|
||||
- `sale_id = NULL`
|
||||
- Se timbra igual que cualquier otro CFDI vía Horux360
|
||||
@@ -280,3 +280,39 @@ sudo -u postgres psql tenant_mi_refaccionaria -c "SELECT name, role FROM employe
|
||||
| "Tenant not found" | Verificar que `provision_tenant` se ejecuto correctamente |
|
||||
| PIN bloqueado | Esperar 15 minutos o reiniciar el servicio POS |
|
||||
| Base de datos no conecta | Verificar credenciales en `pos/config.py` |
|
||||
|
||||
---
|
||||
|
||||
## Servicios Systemd (Produccion)
|
||||
|
||||
Despues de la instalacion, copiar los servicios y habilitarlos:
|
||||
|
||||
```bash
|
||||
sudo cp systemd/*.service systemd/*.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now nexus-pos.service nexus.service nexus-celery.service nexus-quart.service
|
||||
sudo systemctl enable --now nexus-mv-refresh.timer nexus-cache-warm.timer
|
||||
```
|
||||
|
||||
## Monitoreo (Opcional)
|
||||
|
||||
Levantar Prometheus + Grafana:
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker compose -f docker-compose.monitoring.yml up -d
|
||||
```
|
||||
|
||||
- Grafana: http://servidor:3001 (admin / nexus2026)
|
||||
- Prometheus: http://servidor:9090
|
||||
|
||||
## PWA (Instalable)
|
||||
|
||||
El POS ya incluye Service Worker y manifest. Al abrir cualquier pagina del POS en Chrome/Edge, aparecera un banner para instalar la app. Requiere HTTPS en produccion para el prompt automatico.
|
||||
|
||||
## Tests E2E (Playwright)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
52
docs/MULTI_BRANCH.md
Normal file
52
docs/MULTI_BRANCH.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Multi-sucursal — Documentación Técnica
|
||||
|
||||
**Versión DB:** v4.0
|
||||
**Commit:** `2b73c2c`
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura
|
||||
|
||||
### Catálogo compartido
|
||||
- `inventory.branch_id` es siempre `NULL` (catálogo compartido a nivel tenant).
|
||||
- `part_number` tiene unique index `idx_inventory_part_unique`.
|
||||
- Productos duplicados por `part_number` en múltiples sucursales fueron consolidados en la migración v4.0.
|
||||
|
||||
### Stock por sucursal
|
||||
- Tabla `inventory_stock (inventory_id, branch_id, stock, location)`.
|
||||
- Trigger `trg_update_inventory_stock` en `inventory_operations` mantiene `inventory_stock` sincronizado automáticamente.
|
||||
- `inventory_stock_summary` sigue existiendo como stock total agregado (sin `branch_id`).
|
||||
|
||||
### Datos fiscales por sucursal
|
||||
- Tabla `branches` incluye: `rfc`, `razon_social`, `regimen_fiscal`, `codigo_postal`, `serie_cfdi`, `folio_inicial`, `licencia_fiscal`, `certificado_pem`, `llave_pem`, `is_main`.
|
||||
- Solo una sucursal puede ser `is_main = true`.
|
||||
- Al facturar, `_get_issuer_config(cur, branch_id)` usa datos de la sucursal de la venta; fallback a config global del tenant.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Config
|
||||
- `GET /pos/api/config/branches` — lista sucursales (sin PEM)
|
||||
- `GET /pos/api/config/branches/<id>` — detalle completo (con PEM)
|
||||
- `POST /pos/api/config/branches` — crear
|
||||
- `PUT /pos/api/config/branches/<id>` — editar
|
||||
|
||||
### Inventario
|
||||
- `GET /pos/api/inventory/items` — acepta `?branch_id=` para mostrar stock por sucursal
|
||||
- Stock se lee de `inventory_stock` cuando se filtra por sucursal
|
||||
|
||||
### POS
|
||||
- Ventas verifican stock vía `get_stock(conn, inventory_id, branch_id)`
|
||||
- `inventory_operations` registra `branch_id` de la venta
|
||||
|
||||
---
|
||||
|
||||
## Migración
|
||||
|
||||
```bash
|
||||
cd /home/Autopartes/pos
|
||||
python3 migrations/runner.py
|
||||
```
|
||||
|
||||
Archivo: `pos/migrations/v4.0_multi_branch.sql`
|
||||
40
docs/POSTGRESQL_TUNING.md
Normal file
40
docs/POSTGRESQL_TUNING.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# PostgreSQL Tuning — Nexus Autoparts
|
||||
|
||||
**Server:** 48 GB RAM, 8 cores, SSD (QEMU)
|
||||
**Applied:** 2026-04-26
|
||||
**Requires restart:** Yes (done)
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
File: `/etc/postgresql/17/main/postgresql.conf`
|
||||
|
||||
| Parameter | Before | After | Rationale |
|
||||
|-----------|--------|-------|-----------|
|
||||
| `shared_buffers` | 128 MB | **8 GB** | ~25% of RAM for PostgreSQL buffer cache |
|
||||
| `work_mem` | 4 MB | **64 MB** | Larger sorts/joins without disk spilling |
|
||||
| `maintenance_work_mem` | 64 MB | **1 GB** | Faster VACUUM, CREATE INDEX, ALTER |
|
||||
| `effective_cache_size` | 4 GB | **36 GB** | Planner knows OS cache is large |
|
||||
| `max_wal_size` | 1 GB | **8 GB** | Fewer checkpoints under heavy write load |
|
||||
| `checkpoint_completion_target` | 0.5 | **0.9** | Spread checkpoint I/O over more time |
|
||||
| `wal_buffers` | - | **16 MB** | WAL buffer sizing |
|
||||
| `random_page_cost` | 4.0 | **1.1** | SSD-appropriate random read cost |
|
||||
| `effective_io_concurrency` | 1 | **200** | SSD can handle many concurrent requests |
|
||||
| `max_connections` | 100 | **200** | Headroom for Celery, Quart, Dashboard, PgBouncer |
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql -d nexus_autoparts -c "SHOW shared_buffers;"
|
||||
```
|
||||
|
||||
## Backup
|
||||
|
||||
A backup of the previous config is stored at:
|
||||
`/etc/postgresql/17/main/postgresql.conf.backup.<timestamp>`
|
||||
|
||||
## pg_hba Adjustment for Monitoring
|
||||
|
||||
Added Docker network access for postgres-exporter:
|
||||
```
|
||||
host nexus_autoparts postgres 172.17.0.0/16 trust
|
||||
```
|
||||
35
docs/SYSTEMD_SERVICES.md
Normal file
35
docs/SYSTEMD_SERVICES.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Systemd Services — Nexus Autoparts
|
||||
|
||||
All production services are managed via systemd. Files are versioned in `systemd/`.
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| `nexus-pos.service` | Gunicorn POS (Flask), port 5001 | Active |
|
||||
| `nexus.service` | Dashboard (Flask), port 5000 | Active |
|
||||
| `nexus-quart.service` | Hypercorn async catalog, port 5002 | Active |
|
||||
| `nexus-celery.service` | Celery worker (4 prefork) | Active |
|
||||
| `nexus-mv-refresh.timer` | Daily MV refresh at 03:00 UTC | Active |
|
||||
| `nexus-cache-warm.timer` | Daily Redis cache warming at 04:00 UTC | Active |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Reload all
|
||||
systemctl daemon-reload
|
||||
|
||||
# Restart POS
|
||||
systemctl restart nexus-pos.service
|
||||
|
||||
# View logs
|
||||
journalctl -u nexus-pos.service -f
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
sudo cp systemd/*.service systemd/*.timer /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now nexus-pos.service nexus-cache-warm.timer
|
||||
```
|
||||
25
manager/.env.example
Normal file
25
manager/.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# Nexus Instance Manager — Environment Variables
|
||||
# Copy to .env and fill in your values.
|
||||
|
||||
# ─── Database (REQUIRED) ───────────────────────────────────────────────────
|
||||
# If manager runs on a separate VM, use the IP of the PostgreSQL server.
|
||||
MASTER_DB_URL=postgresql://nexus:PASSWORD@192.168.10.91/nexus_autopartes
|
||||
TENANT_DB_URL_TEMPLATE=postgresql://nexus:PASSWORD@192.168.10.91/{db_name}
|
||||
|
||||
# ─── Remote Nexus Server IP (for VM-separated deployment) ──────────────────
|
||||
# IP or hostname of the server running POS, Dashboard, Quart, Redis.
|
||||
NEXUS_SERVER_HOST=192.168.10.91
|
||||
|
||||
# ─── Security (REQUIRED) ───────────────────────────────────────────────────
|
||||
MANAGER_JWT_SECRET=change-me-to-a-random-64-char-hex-string
|
||||
|
||||
# ─── Demo Defaults ─────────────────────────────────────────────────────────
|
||||
DEMO_DEFAULT_DAYS=14
|
||||
DEMO_DEFAULT_PIN=0000
|
||||
|
||||
# ─── Redis (OPTIONAL — health check only) ──────────────────────────────────
|
||||
# Redis may only listen on localhost. If so, health check will show warning.
|
||||
REDIS_URL=redis://192.168.10.91:6379/0
|
||||
|
||||
# ─── Internal ──────────────────────────────────────────────────────────────
|
||||
POS_DIR=/home/Autopartes/pos
|
||||
203
manager/README.md
Normal file
203
manager/README.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Nexus Instance Manager
|
||||
|
||||
Panel de control central para gestionar instancias multi-tenant de Nexus POS.
|
||||
|
||||
## Qué hace
|
||||
|
||||
- **Crear demos** en 1 clic con subdominio, PIN de acceso y fecha de expiración
|
||||
- **Monitorear** salud de todos los servicios (POS, DB, Redis, Quart, Systemd)
|
||||
- **Gestionar tenants**: activar/desactivar, resetear datos, eliminar
|
||||
- **Ejecutar migraciones** de schema en todos los tenants desde una UI
|
||||
- **Dashboard** con estadísticas globales y alertas de demos por expirar
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
manager/
|
||||
├── app.py # Flask app principal
|
||||
├── config.py # Variables de entorno
|
||||
├── wsgi.py # Entry point para Gunicorn
|
||||
├── requirements.txt # Dependencias
|
||||
├── services/ # Lógica de negocio
|
||||
│ ├── health_service.py # Health checks de infraestructura
|
||||
│ ├── tenant_service.py # CRUD tenants (usa tenant_manager del POS)
|
||||
│ └── migration_service.py# Orquestación de migraciones
|
||||
├── blueprints/ # API REST
|
||||
│ ├── auth_bp.py # Login/logout JWT
|
||||
│ ├── tenants_bp.py # Gestión de tenants
|
||||
│ ├── demos_bp.py # Creación de demos
|
||||
│ ├── health_bp.py # Health checks
|
||||
│ └── admin_bp.py # Dashboard stats y migraciones
|
||||
├── static/ # Frontend SPA
|
||||
│ ├── css/manager.css
|
||||
│ └── js/manager.js
|
||||
├── templates/
|
||||
│ └── index.html # Single Page App
|
||||
├── scripts/
|
||||
│ └── init_manager.py # Inicialización de DB + admin
|
||||
├── systemd/
|
||||
│ └── nexus-manager.service # Servicio systemd
|
||||
└── README.md # Documentación completa
|
||||
```
|
||||
|
||||
## Instalación rápida (mismo servidor)
|
||||
|
||||
### 1. Dependencias
|
||||
|
||||
```bash
|
||||
cd /home/Autopartes/manager
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Inicializar base de datos y usuario admin
|
||||
|
||||
```bash
|
||||
cd /home/Autopartes/manager
|
||||
python scripts/init_manager.py --email admin@nexus.local --password nexus2026 --name "Super Admin"
|
||||
```
|
||||
|
||||
Esto crea:
|
||||
- Tabla `manager_users` (login del panel)
|
||||
- Tabla `manager_audit_log` (registro de acciones)
|
||||
- Usuario admin por defecto
|
||||
|
||||
### 3. Configurar variables de entorno
|
||||
|
||||
Asegúrate de que estas variables estén disponibles (en systemd o `.env`):
|
||||
|
||||
```bash
|
||||
MASTER_DB_URL=postgresql://postgres@localhost/nexus_autoparts
|
||||
TENANT_DB_URL_TEMPLATE=postgresql://postgres@localhost/{db_name}
|
||||
MANAGER_JWT_SECRET=genera-un-segredo-largo-aqui
|
||||
POS_DIR=/home/Autopartes/pos
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
```
|
||||
|
||||
### 4. Registrar servicio systemd
|
||||
|
||||
```bash
|
||||
cp systemd/nexus-manager.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable nexus-manager
|
||||
systemctl start nexus-manager
|
||||
```
|
||||
|
||||
Accede en: `http://TU_IP:5003`
|
||||
|
||||
### 5. (Opcional) Agregar a nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name manager.nexusautoparts.com.mx;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5003;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Instalación en Máquina Virtual separada (misma red local)
|
||||
|
||||
Si el manager corre en una VM diferente al servidor principal (donde está PostgreSQL + POS):
|
||||
|
||||
### Requisitos de red
|
||||
- PostgreSQL del servidor principal debe escuchar en `0.0.0.0:5432` (verificar `listen_addresses = '*'` en `postgresql.conf`)
|
||||
- POS (5001), Dashboard (5000) y Quart (5002) ya escuchan en `0.0.0.0` por defecto
|
||||
- Redis puede estar solo en `127.0.0.1`; en ese caso el health check mostrará advertencia pero no afecta el funcionamiento
|
||||
|
||||
### 1. Clonar el repo en la VM
|
||||
|
||||
```bash
|
||||
git clone https://git.consultoria-as.com/consultoria-as/Autoparts-DB.git /home/Autopartes
|
||||
cd /home/Autopartes/manager
|
||||
```
|
||||
|
||||
### 2. Instalar dependencias
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. Configurar variables para conexión remota
|
||||
|
||||
Crea `/home/Autopartes/manager/.env` o edita el servicio systemd:
|
||||
|
||||
```bash
|
||||
# IP del servidor principal donde corre PostgreSQL y POS
|
||||
NEXUS_SERVER_HOST=192.168.10.91
|
||||
|
||||
# PostgreSQL remoto (cambiar localhost por la IP del servidor)
|
||||
MASTER_DB_URL=postgresql://nexus:PASSWORD@192.168.10.91/nexus_autoparts
|
||||
TENANT_DB_URL_TEMPLATE=postgresql://nexus:PASSWORD@192.168.10.91/{db_name}
|
||||
|
||||
# Redis remoto (puede no funcionar si Redis solo escucha en localhost)
|
||||
REDIS_URL=redis://192.168.10.91:6379/0
|
||||
|
||||
# Seguridad
|
||||
MANAGER_JWT_SECRET=genera-un-segredo-largo-aqui
|
||||
POS_DIR=/home/Autopartes/pos
|
||||
```
|
||||
|
||||
**Nota importante:** La VM manager no necesita una instalación completa del POS. Solo necesita:
|
||||
- Los archivos de `manager/`
|
||||
- Los archivos de `pos/` (para reutilizar `tenant_manager.py` y migraciones)
|
||||
- Conectividad TCP al puerto 5432 del servidor principal
|
||||
|
||||
### 4. Inicializar DB y admin
|
||||
|
||||
```bash
|
||||
cd /home/Autopartes/manager
|
||||
python scripts/init_manager.py --email admin@nexus.local --password TU_PASSWORD
|
||||
```
|
||||
|
||||
### 5. Systemd
|
||||
|
||||
```bash
|
||||
cp systemd/nexus-manager.service /etc/systemd/system/
|
||||
# Edita el archivo y cambia localhost por la IP del servidor en MASTER_DB_URL y NEXUS_SERVER_HOST
|
||||
nano /etc/systemd/system/nexus-manager.service
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable nexus-manager
|
||||
systemctl start nexus-manager
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Uso
|
||||
|
||||
### Crear una demo
|
||||
1. Ve a la sección **Crear Demos**
|
||||
2. Llena nombre del negocio, email, días de vigencia
|
||||
3. El subdominio se genera automáticamente (puedes personalizarlo)
|
||||
4. Click en **Crear Demo**
|
||||
5. El panel muestra la URL de acceso y el PIN del owner
|
||||
|
||||
### Resetear una demo
|
||||
- Presiona el ícono de 🔄 en la tabla de demos
|
||||
- Limpia TODO el inventario, ventas, clientes, facturas
|
||||
- Conserva empleados (incluyendo el owner) y configuración fiscal
|
||||
|
||||
### Eliminar una demo
|
||||
- Presiona 🗑️ y confirma
|
||||
- Borra permanentemente la base de datos del tenant
|
||||
|
||||
### Migraciones
|
||||
- Ve a **Migraciones** para ver la versión de schema de cada tenant
|
||||
- **Ejecutar todas pendientes** aplica migraciones en TODOS los tenants
|
||||
|
||||
---
|
||||
|
||||
## Notas de seguridad
|
||||
|
||||
- Cambia `MANAGER_JWT_SECRET` en producción
|
||||
- El panel expone acciones destructivas (delete/reset); protege el acceso con firewall o VPN
|
||||
- Usa HTTPS en producción
|
||||
- Si despliegas en VM separada, asegúrate de que el firewall del servidor principal permite conexiones desde la IP de la VM manager al puerto 5432 (PostgreSQL)
|
||||
99
manager/app.py
Normal file
99
manager/app.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Nexus Instance Manager — Flask Application."""
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from flask import Flask, jsonify, render_template, send_from_directory, request
|
||||
|
||||
# Ensure POS modules are importable for tenant_manager reuse
|
||||
POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos")
|
||||
if POS_DIR not in sys.path:
|
||||
sys.path.insert(0, POS_DIR)
|
||||
|
||||
from config import APP_NAME, APP_VERSION
|
||||
from blueprints.auth_bp import auth_bp, require_manager_auth
|
||||
from blueprints.tenants_bp import tenants_bp
|
||||
from blueprints.demos_bp import demos_bp
|
||||
from blueprints.health_bp import health_bp
|
||||
from blueprints.admin_bp import admin_bp
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder="templates",
|
||||
static_folder="static"
|
||||
)
|
||||
app.secret_key = os.environ.get("MANAGER_JWT_SECRET", "dev-secret-change-me")
|
||||
|
||||
# Register blueprints
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(tenants_bp)
|
||||
app.register_blueprint(demos_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# ─── Frontend Routes ───────────────────────────────────────────────────
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route("/login")
|
||||
def login_page():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route("/dashboard")
|
||||
def dashboard_page():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route("/tenants")
|
||||
def tenants_page():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route("/demos")
|
||||
def demos_page():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route("/health")
|
||||
def health_page():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route("/migrations")
|
||||
def migrations_page():
|
||||
return render_template("index.html")
|
||||
|
||||
# ─── Static Asset Helpers ──────────────────────────────────────────────
|
||||
@app.route("/static/<path:filename>")
|
||||
def static_files(filename):
|
||||
return send_from_directory("static", filename)
|
||||
|
||||
# ─── API Status ────────────────────────────────────────────────────────
|
||||
@app.route("/api/status")
|
||||
def api_status():
|
||||
return jsonify({
|
||||
"app": APP_NAME,
|
||||
"version": APP_VERSION,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"pos_dir": POS_DIR
|
||||
})
|
||||
|
||||
# ─── Error Handlers ────────────────────────────────────────────────────
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
return render_template("index.html")
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify({"error": "Internal server error"}), 500
|
||||
return render_template("index.html")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Entry point for Gunicorn: gunicorn -w 2 -b 0.0.0.0:5003 app:app
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5003, debug=True)
|
||||
0
manager/blueprints/__init__.py
Normal file
0
manager/blueprints/__init__.py
Normal file
35
manager/blueprints/admin_bp.py
Normal file
35
manager/blueprints/admin_bp.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Admin dashboard blueprint."""
|
||||
from flask import Blueprint, jsonify
|
||||
from blueprints.auth_bp import require_manager_auth
|
||||
from services import tenant_service, migration_service
|
||||
|
||||
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
|
||||
|
||||
|
||||
@admin_bp.route("/stats", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def dashboard_stats():
|
||||
return jsonify(tenant_service.get_dashboard_stats())
|
||||
|
||||
|
||||
@admin_bp.route("/migrations", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def list_migrations():
|
||||
return jsonify({
|
||||
"migrations": migration_service.list_available_migrations(),
|
||||
"tenants": migration_service.get_tenant_versions()
|
||||
})
|
||||
|
||||
|
||||
@admin_bp.route("/migrations/run-all", methods=["POST"])
|
||||
@require_manager_auth
|
||||
def run_all_migrations():
|
||||
result = migration_service.run_all_pending_migrations()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@admin_bp.route("/migrations/run/<version>", methods=["POST"])
|
||||
@require_manager_auth
|
||||
def run_specific_migration(version):
|
||||
result = migration_service.run_migration_on_all_tenants(version)
|
||||
return jsonify({"results": result})
|
||||
99
manager/blueprints/auth_bp.py
Normal file
99
manager/blueprints/auth_bp.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Auth blueprint for Nexus Manager."""
|
||||
import datetime
|
||||
import jwt
|
||||
import bcrypt
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from config import MANAGER_JWT_SECRET, MANAGER_JWT_EXPIRES
|
||||
from services.tenant_service import get_master_conn
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def check_password(password, hashed):
|
||||
return bcrypt.checkpw(password.encode(), hashed.encode())
|
||||
|
||||
|
||||
def create_manager_token(user_id, email, role="admin"):
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"role": role,
|
||||
"type": "access",
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(seconds=MANAGER_JWT_EXPIRES),
|
||||
"iat": datetime.datetime.utcnow()
|
||||
}
|
||||
return jwt.encode(payload, MANAGER_JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_manager_token(token):
|
||||
try:
|
||||
return jwt.decode(token, MANAGER_JWT_SECRET, algorithms=["HS256"])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def require_manager_auth(f):
|
||||
from functools import wraps
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
token = None
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
elif request.cookies.get("manager_token"):
|
||||
token = request.cookies.get("manager_token")
|
||||
|
||||
if not token:
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
payload = decode_manager_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
return jsonify({"error": "Invalid or expired token"}), 401
|
||||
|
||||
request.manager_user = payload
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["POST"])
|
||||
def login():
|
||||
data = request.get_json() or {}
|
||||
email = data.get("email", "").strip().lower()
|
||||
password = data.get("password", "")
|
||||
|
||||
if not email or not password:
|
||||
return jsonify({"error": "Email and password required"}), 400
|
||||
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, email, password_hash, role, name
|
||||
FROM manager_users
|
||||
WHERE email = %s AND is_active = true
|
||||
""", (email,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "Invalid credentials"}), 401
|
||||
|
||||
user_id, db_email, pwd_hash, role, name = row
|
||||
if not check_password(password, pwd_hash):
|
||||
return jsonify({"error": "Invalid credentials"}), 401
|
||||
|
||||
token = create_manager_token(user_id, db_email, role)
|
||||
return jsonify({
|
||||
"access_token": token,
|
||||
"user": {"id": user_id, "email": db_email, "role": role, "name": name}
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route("/me", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def me():
|
||||
return jsonify({"user": request.manager_user})
|
||||
42
manager/blueprints/demos_bp.py
Normal file
42
manager/blueprints/demos_bp.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Demo provisioning blueprint."""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from blueprints.auth_bp import require_manager_auth
|
||||
from services import tenant_service
|
||||
|
||||
demos_bp = Blueprint("demos", __name__, url_prefix="/api/demos")
|
||||
|
||||
|
||||
@demos_bp.route("", methods=["POST"])
|
||||
@require_manager_auth
|
||||
def create_demo():
|
||||
data = request.get_json() or {}
|
||||
name = data.get("name", "").strip()
|
||||
email = data.get("email", "").strip()
|
||||
days = data.get("days")
|
||||
subdomain = data.get("subdomain", "").strip() or None
|
||||
pin = data.get("pin", "0000").strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({"error": "Business name is required"}), 400
|
||||
|
||||
try:
|
||||
result = tenant_service.create_demo(
|
||||
name=name,
|
||||
email=email,
|
||||
demo_days=days,
|
||||
subdomain=subdomain,
|
||||
pin=pin
|
||||
)
|
||||
return jsonify({"data": result}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 409
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@demos_bp.route("", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def list_demos():
|
||||
all_tenants = tenant_service.list_tenants(include_stats=True)
|
||||
demos = [t for t in all_tenants if t.get("is_demo")]
|
||||
return jsonify({"data": demos})
|
||||
18
manager/blueprints/health_bp.py
Normal file
18
manager/blueprints/health_bp.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Health check blueprint."""
|
||||
from flask import Blueprint, jsonify
|
||||
from blueprints.auth_bp import require_manager_auth
|
||||
from services import health_service
|
||||
|
||||
health_bp = Blueprint("health", __name__, url_prefix="/api/health")
|
||||
|
||||
|
||||
@health_bp.route("", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def full_health():
|
||||
return jsonify(health_service.get_full_health_report())
|
||||
|
||||
|
||||
@health_bp.route("/tenant/<db_name>", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def tenant_health(db_name):
|
||||
return jsonify(health_service.get_tenant_health(db_name))
|
||||
81
manager/blueprints/tenants_bp.py
Normal file
81
manager/blueprints/tenants_bp.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Tenant management blueprint."""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from blueprints.auth_bp import require_manager_auth
|
||||
from services import tenant_service
|
||||
|
||||
tenants_bp = Blueprint("tenants", __name__, url_prefix="/api/tenants")
|
||||
|
||||
|
||||
@tenants_bp.route("", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def list_tenants():
|
||||
include_stats = request.args.get("stats", "false").lower() == "true"
|
||||
return jsonify({"data": tenant_service.list_tenants(include_stats=include_stats)})
|
||||
|
||||
|
||||
@tenants_bp.route("/<int:tenant_id>", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def get_tenant(tenant_id):
|
||||
tenant = tenant_service.get_tenant(tenant_id)
|
||||
if not tenant:
|
||||
return jsonify({"error": "Tenant not found"}), 404
|
||||
return jsonify({"data": tenant})
|
||||
|
||||
|
||||
@tenants_bp.route("/<int:tenant_id>/stats", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def get_tenant_stats(tenant_id):
|
||||
tenant = tenant_service.get_tenant(tenant_id)
|
||||
if not tenant:
|
||||
return jsonify({"error": "Tenant not found"}), 404
|
||||
return jsonify({"data": tenant_service._get_tenant_quick_stats(tenant["db_name"])})
|
||||
|
||||
|
||||
@tenants_bp.route("/<int:tenant_id>/toggle", methods=["POST"])
|
||||
@require_manager_auth
|
||||
def toggle_tenant(tenant_id):
|
||||
data = request.get_json() or {}
|
||||
active = data.get("active", True)
|
||||
result = tenant_service.toggle_tenant(tenant_id, active)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@tenants_bp.route("/<int:tenant_id>/reset", methods=["POST"])
|
||||
@require_manager_auth
|
||||
def reset_tenant(tenant_id):
|
||||
try:
|
||||
result = tenant_service.reset_tenant(tenant_id)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@tenants_bp.route("/<int:tenant_id>", methods=["DELETE"])
|
||||
@require_manager_auth
|
||||
def delete_tenant(tenant_id):
|
||||
try:
|
||||
result = tenant_service.delete_tenant(tenant_id)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@tenants_bp.route("/<int:tenant_id>/modules", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def get_tenant_modules(tenant_id):
|
||||
try:
|
||||
result = tenant_service.get_tenant_modules(tenant_id)
|
||||
return jsonify({"data": result})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@tenants_bp.route("/<int:tenant_id>/modules", methods=["PUT"])
|
||||
@require_manager_auth
|
||||
def update_tenant_modules(tenant_id):
|
||||
data = request.get_json() or {}
|
||||
try:
|
||||
result = tenant_service.update_tenant_modules(tenant_id, data)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
57
manager/config.py
Normal file
57
manager/config.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Nexus Instance Manager — Configuration."""
|
||||
import os
|
||||
|
||||
# ─── Database ──────────────────────────────────────────────────────────────
|
||||
MASTER_DB_URL = os.environ.get("MASTER_DB_URL") or os.environ.get("DATABASE_URL")
|
||||
if not MASTER_DB_URL:
|
||||
raise ValueError(
|
||||
"MASTER_DB_URL environment variable is required. "
|
||||
"Example: postgresql://user:pass@localhost/nexus_autoparts"
|
||||
)
|
||||
|
||||
TENANT_DB_URL_TEMPLATE = os.environ.get("TENANT_DB_URL_TEMPLATE")
|
||||
if not TENANT_DB_URL_TEMPLATE:
|
||||
raise ValueError(
|
||||
"TENANT_DB_URL_TEMPLATE environment variable is required. "
|
||||
"Example: postgresql://user:pass@localhost/{db_name}"
|
||||
)
|
||||
|
||||
# ─── Security ──────────────────────────────────────────────────────────────
|
||||
MANAGER_JWT_SECRET = os.environ.get("MANAGER_JWT_SECRET")
|
||||
if not MANAGER_JWT_SECRET:
|
||||
raise ValueError(
|
||||
"MANAGER_JWT_SECRET environment variable is required. "
|
||||
"Generate one with: python3 -c 'import secrets; print(secrets.token_hex(32))'"
|
||||
)
|
||||
|
||||
MANAGER_JWT_EXPIRES = int(os.environ.get("MANAGER_JWT_EXPIRES", "28800")) # 8 hours
|
||||
|
||||
# Internal API key for manager-to-POS operations
|
||||
INTERNAL_API_KEY = os.environ.get("INTERNAL_API_KEY", "")
|
||||
|
||||
# ─── POS Server (for internal API calls from manager VM) ───────────────────
|
||||
POS_INTERNAL_URL = os.environ.get("POS_INTERNAL_URL", "http://192.168.10.91:5001")
|
||||
|
||||
# ─── Demo Settings ─────────────────────────────────────────────────────────
|
||||
DEMO_DEFAULT_DAYS = int(os.environ.get("DEMO_DEFAULT_DAYS", "14"))
|
||||
DEMO_DEFAULT_PIN = os.environ.get("DEMO_DEFAULT_PIN", "0000")
|
||||
DEMO_SUBDOMAIN_PREFIX = os.environ.get("DEMO_SUBDOMAIN_PREFIX", "demo")
|
||||
|
||||
# ─── Remote Nexus Server (for VM-separated manager) ────────────────────────
|
||||
# Set this to the IP/hostname of the server running POS/PostgreSQL/Redis
|
||||
NEXUS_SERVER_HOST = os.environ.get("NEXUS_SERVER_HOST", "127.0.0.1")
|
||||
|
||||
# ─── Services Health Check ─────────────────────────────────────────────────
|
||||
POS_URL = os.environ.get("POS_URL", f"http://{NEXUS_SERVER_HOST}:5001/pos/health")
|
||||
DASHBOARD_URL = os.environ.get("DASHBOARD_URL", f"http://{NEXUS_SERVER_HOST}:5000/")
|
||||
QUART_URL = os.environ.get("QUART_URL", f"http://{NEXUS_SERVER_HOST}:5002/")
|
||||
REDIS_URL = os.environ.get("REDIS_URL", f"redis://{NEXUS_SERVER_HOST}:6379/0")
|
||||
|
||||
# ─── Paths ─────────────────────────────────────────────────────────────────
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos")
|
||||
MIGRATIONS_DIR = os.path.join(POS_DIR, "migrations")
|
||||
|
||||
# ─── App Identity ──────────────────────────────────────────────────────────
|
||||
APP_NAME = "Nexus Instance Manager"
|
||||
APP_VERSION = "1.0.0"
|
||||
5
manager/requirements.txt
Normal file
5
manager/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Flask>=2.3.0
|
||||
psycopg2-binary>=2.9.0
|
||||
bcrypt>=4.0.0
|
||||
PyJWT>=2.8.0
|
||||
redis>=5.0.0
|
||||
86
manager/scripts/init_manager.py
Normal file
86
manager/scripts/init_manager.py
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Initialize Nexus Instance Manager: create admin tables and default user."""
|
||||
import os
|
||||
import sys
|
||||
import bcrypt
|
||||
import argparse
|
||||
|
||||
# Add manager to path
|
||||
MANAGER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if MANAGER_DIR not in sys.path:
|
||||
sys.path.insert(0, MANAGER_DIR)
|
||||
|
||||
from services.tenant_service import get_master_conn
|
||||
|
||||
|
||||
def init_schema():
|
||||
"""Create manager_users table in master DB if not exists."""
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS manager_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(200) UNIQUE NOT NULL,
|
||||
name VARCHAR(200) NOT NULL DEFAULT 'Admin',
|
||||
password_hash VARCHAR(200) NOT NULL,
|
||||
role VARCHAR(20) DEFAULT 'admin',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS manager_audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_email VARCHAR(200),
|
||||
action VARCHAR(100) NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
print("[OK] Manager schema initialized.")
|
||||
|
||||
|
||||
def create_admin(email, password, name="Admin"):
|
||||
"""Create or update admin user."""
|
||||
pwd_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO manager_users (email, name, password_hash, role)
|
||||
VALUES (%s, %s, %s, 'admin')
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
name = EXCLUDED.name,
|
||||
is_active = TRUE
|
||||
""", (email.lower(), name, pwd_hash))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
print(f"[OK] Admin user '{email}' created/updated.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Initialize Nexus Manager")
|
||||
parser.add_argument("--email", default="admin@nexus.local", help="Admin email")
|
||||
parser.add_argument("--password", default="nexus2026", help="Admin password")
|
||||
parser.add_argument("--name", default="Super Admin", help="Admin display name")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("Nexus Instance Manager — Initialization")
|
||||
print("=" * 40)
|
||||
init_schema()
|
||||
create_admin(args.email, args.password, args.name)
|
||||
print("=" * 40)
|
||||
print(f"Login: {args.email}")
|
||||
print(f"URL: http://manager-ip:5003")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
manager/services/__init__.py
Normal file
0
manager/services/__init__.py
Normal file
175
manager/services/health_service.py
Normal file
175
manager/services/health_service.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Health monitoring service for Nexus infrastructure."""
|
||||
import subprocess
|
||||
import shutil
|
||||
import socket
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import psycopg2
|
||||
import redis
|
||||
from config import (
|
||||
MASTER_DB_URL, REDIS_URL, POS_URL, DASHBOARD_URL, QUART_URL,
|
||||
TENANT_DB_URL_TEMPLATE, NEXUS_SERVER_HOST
|
||||
)
|
||||
|
||||
|
||||
def check_postgresql():
|
||||
"""Check PostgreSQL connectivity."""
|
||||
try:
|
||||
conn = psycopg2.connect(MASTER_DB_URL, connect_timeout=5)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT version(), pg_database_size('nexus_autoparts')")
|
||||
version, size = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": version.split()[1] if version else "unknown",
|
||||
"master_size_mb": round(size / (1024 * 1024), 2)
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def check_redis():
|
||||
"""Check Redis connectivity. May be unreachable if Redis only binds to localhost."""
|
||||
try:
|
||||
r = redis.from_url(REDIS_URL, socket_connect_timeout=3)
|
||||
info = r.info()
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": info.get("redis_version", "unknown"),
|
||||
"used_memory_human": info.get("used_memory_human", "?"),
|
||||
"connected_clients": info.get("connected_clients", 0)
|
||||
}
|
||||
except redis.ConnectionError:
|
||||
return {
|
||||
"status": "warning",
|
||||
"error": "Redis unreachable. If manager runs on a separate VM, ensure Redis binds to 0.0.0.0 or a VPN interface, or that a tunnel is active."
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def check_http_service(name, url, timeout=5):
|
||||
"""Generic HTTP health check."""
|
||||
try:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
req.add_header("User-Agent", "Nexus-Manager/1.0")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return {
|
||||
"status": "ok",
|
||||
"http_status": resp.status,
|
||||
"latency_ms": None # Could add timing later
|
||||
}
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"status": "warning", "http_status": e.code, "error": str(e)}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def check_disk_space(path="/"):
|
||||
"""Check disk usage."""
|
||||
try:
|
||||
total, used, free = shutil.disk_usage(path)
|
||||
return {
|
||||
"status": "ok",
|
||||
"total_gb": round(total / (1024**3), 2),
|
||||
"used_gb": round(used / (1024**3), 2),
|
||||
"free_gb": round(free / (1024**3), 2),
|
||||
"percent_used": round((used / total) * 100, 1)
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def check_memory():
|
||||
"""Check system memory via /proc/meminfo."""
|
||||
try:
|
||||
with open("/proc/meminfo") as f:
|
||||
meminfo = f.read()
|
||||
data = {}
|
||||
for line in meminfo.splitlines():
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
data[key.strip()] = int(value.strip().split()[0]) # kB
|
||||
total = data.get("MemTotal", 0) / 1024 / 1024 # GB
|
||||
available = data.get("MemAvailable", data.get("MemFree", 0)) / 1024 / 1024
|
||||
used = total - available
|
||||
return {
|
||||
"status": "ok",
|
||||
"total_gb": round(total, 2),
|
||||
"used_gb": round(used, 2),
|
||||
"available_gb": round(available, 2),
|
||||
"percent_used": round((used / total) * 100, 1) if total else 0
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def check_systemd_service(service_name):
|
||||
"""Check systemd service status."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-active", service_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
active = result.stdout.strip() == "active"
|
||||
return {
|
||||
"status": "ok" if active else "warning",
|
||||
"active": active,
|
||||
"state": result.stdout.strip()
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def get_full_health_report():
|
||||
"""Aggregate health report for all services."""
|
||||
return {
|
||||
"_meta": {
|
||||
"nexus_server_host": NEXUS_SERVER_HOST,
|
||||
"note": "disk/memory are local to this manager VM. PostgreSQL/HTTP checks target the remote Nexus server."
|
||||
},
|
||||
"postgresql": check_postgresql(),
|
||||
"redis": check_redis(),
|
||||
"pos": check_http_service("pos", POS_URL),
|
||||
"dashboard": check_http_service("dashboard", DASHBOARD_URL),
|
||||
"quart": check_http_service("quart", QUART_URL),
|
||||
"disk": check_disk_space(),
|
||||
"memory": check_memory(),
|
||||
"services": {
|
||||
"nexus": check_systemd_service("nexus.service"),
|
||||
"nexus-pos": check_systemd_service("nexus-pos.service"),
|
||||
"nexus-quart": check_systemd_service("nexus-quart.service"),
|
||||
"nexus-celery": check_systemd_service("nexus-celery.service"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_tenant_health(db_name, timeout=5):
|
||||
"""Check connectivity to a specific tenant database."""
|
||||
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||
try:
|
||||
conn = psycopg2.connect(dsn, connect_timeout=timeout)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM employees WHERE is_active = true) as employees,
|
||||
(SELECT COUNT(*) FROM inventory WHERE is_active = true) as inventory,
|
||||
(SELECT COUNT(*) FROM customers WHERE is_active = true) as customers,
|
||||
(SELECT COUNT(*) FROM sales WHERE created_at > NOW() - INTERVAL '30 days') as sales_30d,
|
||||
pg_database_size(current_database()) as db_size
|
||||
""")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return {
|
||||
"status": "ok",
|
||||
"employees": row[0],
|
||||
"inventory": row[1],
|
||||
"customers": row[2],
|
||||
"sales_30d": row[3],
|
||||
"db_size_mb": round(row[4] / (1024 * 1024), 2)
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
100
manager/services/migration_service.py
Normal file
100
manager/services/migration_service.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Migration orchestration service."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos")
|
||||
if POS_DIR not in sys.path:
|
||||
sys.path.insert(0, POS_DIR)
|
||||
|
||||
from tenant_db import get_master_conn
|
||||
from config import MIGRATIONS_DIR
|
||||
|
||||
|
||||
def list_available_migrations():
|
||||
"""List migrations found in POS migrations directory."""
|
||||
migrations = []
|
||||
if os.path.isdir(MIGRATIONS_DIR):
|
||||
for fname in sorted(os.listdir(MIGRATIONS_DIR)):
|
||||
if fname.endswith(".sql") and fname.startswith("v"):
|
||||
version = fname.replace(".sql", "")
|
||||
migrations.append({"version": version, "file": fname})
|
||||
return migrations
|
||||
|
||||
|
||||
def get_tenant_versions():
|
||||
"""Get schema version for every tenant."""
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT t.id, t.name, t.db_name, COALESCE(v.version, 'v0.0') as version
|
||||
FROM tenants t
|
||||
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
|
||||
WHERE t.is_active = true
|
||||
ORDER BY t.id
|
||||
""")
|
||||
results = []
|
||||
for row in cur.fetchall():
|
||||
results.append({
|
||||
"tenant_id": row[0], "name": row[1], "db_name": row[2], "version": row[3]
|
||||
})
|
||||
cur.close()
|
||||
conn.close()
|
||||
return results
|
||||
|
||||
|
||||
def run_migration_on_tenant(db_name, version):
|
||||
"""Apply a single migration file to a tenant DB."""
|
||||
from migrations.runner import apply_migration
|
||||
return apply_migration(db_name, version)
|
||||
|
||||
|
||||
def run_all_pending_migrations():
|
||||
"""Run all pending migrations on all active tenants (wrapper around POS runner)."""
|
||||
from migrations.runner import run_migrations
|
||||
import io
|
||||
import contextlib
|
||||
|
||||
# Capture stdout to return as log
|
||||
f = io.StringIO()
|
||||
with contextlib.redirect_stdout(f):
|
||||
run_migrations()
|
||||
return {"log": f.getvalue()}
|
||||
|
||||
|
||||
def run_migration_on_all_tenants(version):
|
||||
"""Apply one specific migration version to all tenants that don't have it."""
|
||||
from migrations.runner import MIGRATIONS, apply_migration
|
||||
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT t.id, t.db_name, COALESCE(v.version, 'v0.0') as version
|
||||
FROM tenants t
|
||||
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
|
||||
WHERE t.is_active = true
|
||||
""")
|
||||
tenants = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
results = []
|
||||
for tenant_id, db_name, current_version in tenants:
|
||||
if current_version >= version:
|
||||
results.append({"tenant_id": tenant_id, "db_name": db_name, "skipped": True, "reason": "already at or past version"})
|
||||
continue
|
||||
|
||||
success = apply_migration(db_name, version)
|
||||
if success:
|
||||
# Update version tracker
|
||||
conn2 = get_master_conn()
|
||||
cur2 = conn2.cursor()
|
||||
cur2.execute("""
|
||||
INSERT INTO tenant_schema_version (tenant_id, version)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW()
|
||||
""", (tenant_id, version, version))
|
||||
conn2.commit()
|
||||
cur2.close()
|
||||
conn2.close()
|
||||
results.append({"tenant_id": tenant_id, "db_name": db_name, "success": success})
|
||||
return results
|
||||
400
manager/services/tenant_service.py
Normal file
400
manager/services/tenant_service.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""Tenant management service wrapping POS tenant_manager."""
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
from psycopg2 import sql
|
||||
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
|
||||
|
||||
# Add POS to path so we can reuse tenant_manager
|
||||
POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos")
|
||||
if POS_DIR not in sys.path:
|
||||
sys.path.insert(0, POS_DIR)
|
||||
|
||||
from config import MASTER_DB_URL, TENANT_DB_URL_TEMPLATE, DEMO_DEFAULT_DAYS
|
||||
|
||||
|
||||
def get_master_conn():
|
||||
return psycopg2.connect(MASTER_DB_URL)
|
||||
|
||||
|
||||
def list_tenants(include_stats=False):
|
||||
"""List all tenants with optional per-tenant stats."""
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT t.id, t.name, t.db_name, t.subdomain, t.rfc, t.plan, t.is_active,
|
||||
t.created_at, COALESCE(s.expires_at, NULL) as expires_at,
|
||||
COALESCE(v.version, 'v0.0') as schema_version
|
||||
FROM tenants t
|
||||
LEFT JOIN subscriptions s ON s.tenant_id = t.id
|
||||
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
|
||||
ORDER BY t.id DESC
|
||||
""")
|
||||
cols = [desc[0] for desc in cur.description]
|
||||
tenants = []
|
||||
for row in cur.fetchall():
|
||||
tenant = dict(zip(cols, row))
|
||||
tenant["created_at"] = str(tenant["created_at"]) if tenant["created_at"] else None
|
||||
tenant["expires_at"] = str(tenant["expires_at"]) if tenant["expires_at"] else None
|
||||
tenant["is_demo"] = tenant["plan"] in ("demo", "trial")
|
||||
tenant["demo_days_left"] = None
|
||||
if tenant["expires_at"]:
|
||||
from datetime import datetime
|
||||
try:
|
||||
exp = datetime.fromisoformat(tenant["expires_at"].replace("Z", "+00:00"))
|
||||
now = datetime.now(exp.tzinfo) if exp.tzinfo else datetime.now()
|
||||
tenant["demo_days_left"] = max(0, (exp - now).days)
|
||||
except Exception:
|
||||
pass
|
||||
tenants.append(tenant)
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if include_stats:
|
||||
for t in tenants:
|
||||
t["stats"] = _get_tenant_quick_stats(t["db_name"])
|
||||
return tenants
|
||||
|
||||
|
||||
def get_tenant(tenant_id):
|
||||
"""Get single tenant details."""
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT t.id, t.name, t.db_name, t.subdomain, t.rfc, t.plan, t.is_active,
|
||||
t.created_at, COALESCE(s.expires_at, NULL) as expires_at,
|
||||
COALESCE(s.status, 'unknown') as subscription_status,
|
||||
COALESCE(v.version, 'v0.0') as schema_version
|
||||
FROM tenants t
|
||||
LEFT JOIN subscriptions s ON s.tenant_id = t.id
|
||||
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
|
||||
WHERE t.id = %s
|
||||
""", (tenant_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
keys = ["id", "name", "db_name", "subdomain", "rfc", "plan", "is_active",
|
||||
"created_at", "expires_at", "subscription_status", "schema_version"]
|
||||
return {k: str(v) if v is not None else None for k, v in zip(keys, row)}
|
||||
|
||||
|
||||
def _get_tenant_quick_stats(db_name):
|
||||
"""Quick stats for a tenant DB."""
|
||||
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||
try:
|
||||
conn = psycopg2.connect(dsn, connect_timeout=5)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM employees WHERE is_active = true),
|
||||
(SELECT COUNT(*) FROM inventory WHERE is_active = true),
|
||||
(SELECT COUNT(*) FROM customers WHERE is_active = true),
|
||||
(SELECT COUNT(*) FROM sales WHERE status = 'completed'),
|
||||
pg_database_size(current_database())
|
||||
""")
|
||||
emp, inv, cust, sales, size = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return {
|
||||
"employees": emp,
|
||||
"inventory_items": inv,
|
||||
"customers": cust,
|
||||
"completed_sales": sales,
|
||||
"db_size_mb": round(size / (1024 * 1024), 2)
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def create_demo(name, email, demo_days=None, subdomain=None, pin="0000"):
|
||||
"""Provision a new demo tenant using POS tenant_manager."""
|
||||
from services.tenant_manager import provision_tenant
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
days = demo_days or DEMO_DEFAULT_DAYS
|
||||
if not subdomain:
|
||||
from services.tenant_manager import generate_subdomain
|
||||
subdomain = generate_subdomain(name)
|
||||
# Ensure uniqueness by appending random suffix if needed
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT 1 FROM tenants WHERE subdomain = %s", (subdomain,))
|
||||
if cur.fetchone():
|
||||
import secrets
|
||||
subdomain = f"{subdomain}-{secrets.token_hex(2)}"
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
result = provision_tenant(
|
||||
name=name,
|
||||
rfc=None,
|
||||
owner_name="Admin Demo",
|
||||
owner_email=email,
|
||||
owner_pin=pin,
|
||||
subdomain=subdomain
|
||||
)
|
||||
|
||||
# Mark as demo plan and set expiration
|
||||
tenant_id = result["tenant_id"]
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE tenants SET plan = 'demo' WHERE id = %s", (tenant_id,))
|
||||
cur.execute("""
|
||||
INSERT INTO subscriptions (tenant_id, plan, status, expires_at)
|
||||
VALUES (%s, 'demo', 'active', %s)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
plan = 'demo',
|
||||
status = 'active',
|
||||
expires_at = EXCLUDED.expires_at
|
||||
""", (tenant_id, datetime.now() + timedelta(days=days)))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Auto-provision WhatsApp Bridge
|
||||
try:
|
||||
import urllib.request
|
||||
import json as _json
|
||||
from config import POS_INTERNAL_URL, INTERNAL_API_KEY
|
||||
bridge_payload = _json.dumps({
|
||||
"tenant_id": tenant_id,
|
||||
"subdomain": subdomain,
|
||||
"db_name": result["db_name"]
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{POS_INTERNAL_URL}/pos/api/internal/whatsapp-bridge",
|
||||
data=bridge_payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Internal-Key": INTERNAL_API_KEY
|
||||
},
|
||||
method="POST"
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
bridge_data = _json.loads(resp.read().decode())
|
||||
result["whatsapp_bridge"] = bridge_data
|
||||
except Exception as e:
|
||||
result["whatsapp_bridge_error"] = str(e)
|
||||
|
||||
result["demo_days"] = days
|
||||
result["expires_at"] = str(datetime.now() + timedelta(days=days))
|
||||
result["access_url"] = f"https://{subdomain}.nexusautoparts.com.mx/pos/login"
|
||||
result["owner_pin"] = pin
|
||||
return result
|
||||
|
||||
|
||||
def reset_tenant(tenant_id, keep_config=True):
|
||||
"""Reset a tenant: truncate business data but keep structure and owner."""
|
||||
tenant = get_tenant(tenant_id)
|
||||
if not tenant:
|
||||
raise ValueError("Tenant not found")
|
||||
db_name = tenant["db_name"]
|
||||
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||
|
||||
tables_to_truncate = [
|
||||
"inventory_operations",
|
||||
"inventory",
|
||||
"sale_items",
|
||||
"sales",
|
||||
"customer_payments",
|
||||
"cash_register_closings",
|
||||
"cash_register_movements",
|
||||
"cash_registers",
|
||||
"invoices",
|
||||
"accounting_entries",
|
||||
"journal_entries",
|
||||
"service_orders",
|
||||
"fleet_vehicles",
|
||||
"crm_activities",
|
||||
"quotations",
|
||||
"quotation_items",
|
||||
"savings_transactions",
|
||||
"savings_accounts",
|
||||
"supplier_orders",
|
||||
"supplier_order_items",
|
||||
"warranty_claims",
|
||||
"notifications",
|
||||
"inventory_uploads",
|
||||
]
|
||||
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
for table in tables_to_truncate:
|
||||
try:
|
||||
cur.execute(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE")
|
||||
except Exception:
|
||||
pass # Table may not exist
|
||||
conn.commit()
|
||||
success = True
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
success = False
|
||||
raise RuntimeError(f"Reset failed: {e}")
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return {"success": success, "tenant_id": tenant_id, "tables_reset": len(tables_to_truncate)}
|
||||
|
||||
|
||||
def delete_tenant(tenant_id):
|
||||
"""Permanently delete a tenant and its database."""
|
||||
tenant = get_tenant(tenant_id)
|
||||
if not tenant:
|
||||
raise ValueError("Tenant not found")
|
||||
db_name = tenant["db_name"]
|
||||
subdomain = tenant.get("subdomain") or f"tenant-{tenant_id}"
|
||||
|
||||
# Destroy WhatsApp Bridge container
|
||||
try:
|
||||
import urllib.request
|
||||
import json as _json
|
||||
from config import POS_INTERNAL_URL, INTERNAL_API_KEY
|
||||
bridge_payload = _json.dumps({"subdomain": subdomain}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{POS_INTERNAL_URL}/pos/api/internal/whatsapp-bridge",
|
||||
data=bridge_payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Internal-Key": INTERNAL_API_KEY
|
||||
},
|
||||
method="DELETE"
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=15)
|
||||
except Exception:
|
||||
pass # Bridge may not exist
|
||||
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Drop database
|
||||
try:
|
||||
master_conn = psycopg2.connect(MASTER_DB_URL)
|
||||
master_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
master_cur = master_conn.cursor()
|
||||
master_cur.execute(
|
||||
sql.SQL('DROP DATABASE IF EXISTS {}').format(sql.Identifier(db_name))
|
||||
)
|
||||
master_cur.close()
|
||||
master_conn.close()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# Clean master records
|
||||
cur.execute("DELETE FROM tenant_schema_version WHERE tenant_id = %s", (tenant_id,))
|
||||
cur.execute("DELETE FROM subscriptions WHERE tenant_id = %s", (tenant_id,))
|
||||
cur.execute("DELETE FROM tenants WHERE id = %s", (tenant_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return {"success": True, "tenant_id": tenant_id, "db_name": db_name}
|
||||
|
||||
|
||||
def toggle_tenant(tenant_id, active):
|
||||
"""Activate or deactivate a tenant."""
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE tenants SET is_active = %s WHERE id = %s", (active, tenant_id))
|
||||
conn.commit()
|
||||
rowcount = cur.rowcount
|
||||
cur.close()
|
||||
conn.close()
|
||||
return {"success": rowcount > 0, "tenant_id": tenant_id, "is_active": active}
|
||||
|
||||
|
||||
def get_tenant_login_url(subdomain):
|
||||
"""Generate login URL for a tenant."""
|
||||
domain = os.environ.get("NEXUS_DOMAIN", "nexusautoparts.com.mx")
|
||||
return f"https://{subdomain}.{domain}/pos/login"
|
||||
|
||||
|
||||
def get_tenant_modules(tenant_id):
|
||||
"""Get enabled modules for a tenant from tenant_config."""
|
||||
tenant = get_tenant(tenant_id)
|
||||
if not tenant:
|
||||
raise ValueError("Tenant not found")
|
||||
db_name = tenant["db_name"]
|
||||
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
modules = {}
|
||||
for key in ["module_whatsapp", "module_marketplace", "module_meli", "module_catalog"]:
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
|
||||
row = cur.fetchone()
|
||||
modules[key.replace("module_", "")] = (row[0] or "").lower() == "true" if row else True
|
||||
return modules
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_tenant_modules(tenant_id, modules):
|
||||
"""Update enabled modules for a tenant in tenant_config."""
|
||||
tenant = get_tenant(tenant_id)
|
||||
if not tenant:
|
||||
raise ValueError("Tenant not found")
|
||||
db_name = tenant["db_name"]
|
||||
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
key_map = {
|
||||
"whatsapp": "module_whatsapp",
|
||||
"marketplace": "module_marketplace",
|
||||
"meli": "module_meli",
|
||||
"catalog": "module_catalog",
|
||||
}
|
||||
for field, key in key_map.items():
|
||||
value = "true" if modules.get(field) else "false"
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (key, value))
|
||||
conn.commit()
|
||||
return {"success": True, "tenant_id": tenant_id, "modules": modules}
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_dashboard_stats():
|
||||
"""Global stats for the manager dashboard."""
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM tenants")
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM tenants WHERE is_active = true")
|
||||
active = cur.fetchone()[0]
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM tenants WHERE plan = 'demo'")
|
||||
demos = cur.fetchone()[0]
|
||||
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM subscriptions
|
||||
WHERE status = 'active' AND expires_at < NOW() + INTERVAL '7 days'
|
||||
""")
|
||||
expiring_soon = cur.fetchone()[0]
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Get system health summary
|
||||
from services.health_service import check_disk_space, check_memory
|
||||
disk = check_disk_space()
|
||||
mem = check_memory()
|
||||
|
||||
return {
|
||||
"tenants": {"total": total, "active": active, "demos": demos, "expiring_soon": expiring_soon},
|
||||
"system": {
|
||||
"disk_percent": disk.get("percent_used"),
|
||||
"memory_percent": mem.get("percent_used"),
|
||||
"disk_free_gb": disk.get("free_gb"),
|
||||
"memory_available_gb": mem.get("available_gb")
|
||||
}
|
||||
}
|
||||
702
manager/static/css/manager.css
Normal file
702
manager/static/css/manager.css
Normal file
@@ -0,0 +1,702 @@
|
||||
:root {
|
||||
--bg-dark: #0f1117;
|
||||
--bg-card: #1a1d26;
|
||||
--bg-sidebar: #161920;
|
||||
--bg-hover: #232631;
|
||||
--border: #2a2e3b;
|
||||
--text-primary: #e8eaf0;
|
||||
--text-secondary: #9ca3af;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--info: #06b6d4;
|
||||
--purple: #8b5cf6;
|
||||
--radius: 10px;
|
||||
--shadow: 0 4px 6px -1px rgba(0,0,0,0.3), 0 2px 4px -1px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── Login ─────────────────────────────────────────────────────────────── */
|
||||
.login-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0f1117 0%, #1a1d26 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo i {
|
||||
font-size: 48px;
|
||||
color: var(--accent);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-logo h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.login-logo p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ─── Layout ────────────────────────────────────────────────────────────── */
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-brand i {
|
||||
color: var(--accent);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item .badge {
|
||||
margin-left: auto;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 60px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 28px;
|
||||
}
|
||||
|
||||
.topbar h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-indicator i {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.status-indicator.warning { color: var(--warning); }
|
||||
.status-indicator.error { color: var(--danger); }
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.page { animation: fadeIn 0.2s ease; }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ─── Cards & Grid ──────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-header h3 i {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bg-blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
.bg-green { background: linear-gradient(135deg, #22c55e, #16a34a); }
|
||||
.bg-purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
.bg-orange { background: linear-gradient(135deg, #f59e0b, #d97706); }
|
||||
.bg-red { background: linear-gradient(135deg, #ef4444, #dc2626); }
|
||||
.bg-cyan { background: linear-gradient(135deg, #06b6d4, #0891b2); }
|
||||
|
||||
.stat-info h3 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-info p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ─── Tables ────────────────────────────────────────────────────────────── */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table th {
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table tr:hover td {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
.table.compact td, .table.compact th {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* ─── Forms ─────────────────────────────────────────────────────────────── */
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
border-radius: 8px 0 0 8px;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.input-suffix {
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0 8px 8px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Buttons ───────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover { background: var(--border); }
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
||||
.btn-block { width: 100%; justify-content: center; }
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ─── Badges & Tags ─────────────────────────────────────────────────────── */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.tag-success { background: rgba(34,197,94,0.15); color: var(--success); }
|
||||
.tag-warning { background: rgba(245,158,11,0.15); color: var(--warning); }
|
||||
.tag-danger { background: rgba(239,68,68,0.15); color: var(--danger); }
|
||||
.tag-info { background: rgba(6,182,212,0.15); color: var(--info); }
|
||||
.tag-default { background: var(--bg-hover); color: var(--text-secondary); }
|
||||
|
||||
/* ─── Alerts & Boxes ────────────────────────────────────────────────────── */
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: rgba(239,68,68,0.1);
|
||||
border: 1px solid rgba(239,68,68,0.2);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: rgba(34,197,94,0.1);
|
||||
border: 1px solid rgba(34,197,94,0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.result-box {
|
||||
background: rgba(34,197,94,0.05);
|
||||
border: 1px solid rgba(34,197,94,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.result-box h4 {
|
||||
margin-bottom: 8px;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.result-box .copy-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 6px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.result-box code {
|
||||
background: var(--bg-dark);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-box {
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ─── Modal ─────────────────────────────────────────────────────────────── */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
box-shadow: var(--shadow);
|
||||
animation: modalIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ─── Toast ─────────────────────────────────────────────────────────────── */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
animation: toastIn 0.3s ease;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.toast.success { border-left: 3px solid var(--success); }
|
||||
.toast.error { border-left: 3px solid var(--danger); }
|
||||
.toast.warning { border-left: 3px solid var(--warning); }
|
||||
|
||||
@keyframes toastIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ─── Utilities ─────────────────────────────────────────────────────────── */
|
||||
.loading {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-muted { color: var(--text-secondary); }
|
||||
.text-center { text-align: center; }
|
||||
|
||||
.health-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.health-item:last-child { border-bottom: none; }
|
||||
|
||||
.health-label { color: var(--text-secondary); font-size: 13px; }
|
||||
.health-value { font-weight: 500; font-size: 13px; }
|
||||
|
||||
.health-bar-bg {
|
||||
height: 6px;
|
||||
background: var(--bg-dark);
|
||||
border-radius: 3px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.health-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
/* ─── Responsive ────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { width: 64px; }
|
||||
.sidebar-brand span, .nav-item span, .user-info span { display: none; }
|
||||
.stats-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Toggle switch for modules modal */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--border);
|
||||
border-radius: 24px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.toggle-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: var(--success);
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
536
manager/static/js/manager.js
Normal file
536
manager/static/js/manager.js
Normal file
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* Nexus Instance Manager — Frontend SPA
|
||||
*/
|
||||
|
||||
const API_BASE = "";
|
||||
let currentToken = localStorage.getItem("manager_token") || "";
|
||||
|
||||
// ─── Router ────────────────────────────────────────────────────────────────
|
||||
const routes = {
|
||||
"#dashboard": "dashboard",
|
||||
"#demos": "demos",
|
||||
"#tenants": "tenants",
|
||||
"#health": "health",
|
||||
"#migrations": "migrations"
|
||||
};
|
||||
|
||||
function navigate() {
|
||||
const hash = window.location.hash || "#dashboard";
|
||||
const page = routes[hash] || "dashboard";
|
||||
|
||||
document.querySelectorAll(".page").forEach(p => p.style.display = "none");
|
||||
document.getElementById(`page-${page}`).style.display = "block";
|
||||
|
||||
document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active"));
|
||||
const nav = document.querySelector(`.nav-item[data-page="${page}"]`);
|
||||
if (nav) nav.classList.add("active");
|
||||
|
||||
const titles = {
|
||||
dashboard: "Dashboard",
|
||||
demos: "Crear Demos",
|
||||
tenants: "Tenants",
|
||||
health: "Salud del Sistema",
|
||||
migrations: "Migraciones"
|
||||
};
|
||||
document.getElementById("page-title").textContent = titles[page] || "Dashboard";
|
||||
|
||||
// Load page data
|
||||
if (page === "dashboard") loadDashboard();
|
||||
if (page === "demos") loadDemos();
|
||||
if (page === "tenants") loadTenants();
|
||||
if (page === "health") loadHealth();
|
||||
if (page === "migrations") loadMigrations();
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", navigate);
|
||||
|
||||
// ─── Auth ──────────────────────────────────────────────────────────────────
|
||||
async function api(url, opts = {}) {
|
||||
const options = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${currentToken}`
|
||||
},
|
||||
...opts
|
||||
};
|
||||
if (opts.body && typeof opts.body !== "string") {
|
||||
options.body = JSON.stringify(opts.body);
|
||||
}
|
||||
const res = await fetch(`${API_BASE}${url}`, options);
|
||||
if (res.status === 401) {
|
||||
logout();
|
||||
return null;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById("login-screen").style.display = "flex";
|
||||
document.getElementById("app").style.display = "none";
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
document.getElementById("login-screen").style.display = "none";
|
||||
document.getElementById("app").style.display = "flex";
|
||||
navigate();
|
||||
}
|
||||
|
||||
async function initAuth() {
|
||||
if (!currentToken) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
const res = await api("/api/auth/me");
|
||||
if (res && res.status === 200) {
|
||||
document.getElementById("user-email").textContent = res.data.user.email;
|
||||
showApp();
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("login-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById("login-email").value;
|
||||
const password = document.getElementById("login-password").value;
|
||||
const errEl = document.getElementById("login-error");
|
||||
errEl.style.display = "none";
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
currentToken = data.access_token;
|
||||
localStorage.setItem("manager_token", currentToken);
|
||||
document.getElementById("user-email").textContent = data.user.email;
|
||||
showApp();
|
||||
} else {
|
||||
errEl.textContent = data.error || "Error de autenticación";
|
||||
errEl.style.display = "block";
|
||||
}
|
||||
});
|
||||
|
||||
function logout() {
|
||||
currentToken = "";
|
||||
localStorage.removeItem("manager_token");
|
||||
showLogin();
|
||||
}
|
||||
|
||||
// ─── Dashboard ─────────────────────────────────────────────────────────────
|
||||
async function loadDashboard() {
|
||||
const statsRes = await api("/api/admin/stats");
|
||||
if (statsRes && statsRes.status === 200) {
|
||||
const s = statsRes.data;
|
||||
document.getElementById("stat-total").textContent = s.tenants.total;
|
||||
document.getElementById("stat-active").textContent = s.tenants.active;
|
||||
document.getElementById("stat-demos").textContent = s.tenants.demos;
|
||||
document.getElementById("stat-expiring").textContent = s.tenants.expiring_soon;
|
||||
|
||||
const healthEl = document.getElementById("system-health-summary");
|
||||
healthEl.innerHTML = `
|
||||
<div class="health-item">
|
||||
<span class="health-label">Disco usado</span>
|
||||
<span class="health-value">${s.system.disk_percent}%</span>
|
||||
</div>
|
||||
<div class="health-bar-bg"><div class="health-bar-fill bg-blue" style="width:${s.system.disk_percent}%; background:${getBarColor(s.system.disk_percent)}"></div></div>
|
||||
<div class="health-item" style="margin-top:12px">
|
||||
<span class="health-label">Memoria usada</span>
|
||||
<span class="health-value">${s.system.memory_percent}%</span>
|
||||
</div>
|
||||
<div class="health-bar-bg"><div class="health-bar-fill bg-blue" style="width:${s.system.memory_percent}%; background:${getBarColor(s.system.memory_percent)}"></div></div>
|
||||
<div class="health-item" style="margin-top:12px">
|
||||
<span class="health-label">Disco libre</span>
|
||||
<span class="health-value">${s.system.disk_free_gb} GB</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-label">RAM disponible</span>
|
||||
<span class="health-value">${s.system.memory_available_gb} GB</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const tenantsRes = await api("/api/demos");
|
||||
if (tenantsRes && tenantsRes.status === 200) {
|
||||
const tbody = document.getElementById("recent-demos-table");
|
||||
const demos = tenantsRes.data.data.slice(0, 5);
|
||||
tbody.innerHTML = demos.map(d => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(d.name)}</strong></td>
|
||||
<td><code>${escapeHtml(d.subdomain)}</code></td>
|
||||
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
|
||||
<td>${d.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
|
||||
</tr>
|
||||
`).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay demos activas</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getBarColor(pct) {
|
||||
if (pct < 60) return "var(--success)";
|
||||
if (pct < 85) return "var(--warning)";
|
||||
return "var(--danger)";
|
||||
}
|
||||
|
||||
// ─── Demos ─────────────────────────────────────────────────────────────────
|
||||
async function loadDemos() {
|
||||
const res = await api("/api/demos");
|
||||
if (!res || res.status !== 200) return;
|
||||
|
||||
const tbody = document.getElementById("demos-table");
|
||||
const demos = res.data.data;
|
||||
tbody.innerHTML = demos.map(d => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(d.name)}</strong></td>
|
||||
<td><a href="https://${escapeHtml(d.subdomain)}.nexusautoparts.com.mx/pos/login" target="_blank" style="color:var(--accent)">${escapeHtml(d.subdomain)}</a></td>
|
||||
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
|
||||
<td>
|
||||
<button class="btn-icon" onclick="openModulesModal(${d.id}, '${escapeHtml(d.name)}')" title="Módulos"><i class="fas fa-cubes" style="color:var(--accent)"></i></button>
|
||||
<button class="btn-icon" onclick="resetTenant(${d.id})" title="Resetear"><i class="fas fa-undo"></i></button>
|
||||
<button class="btn-icon" onclick="toggleTenant(${d.id}, ${!d.is_active})" title="${d.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${d.is_active ? "pause" : "play"}"></i></button>
|
||||
<button class="btn-icon" onclick="confirmDelete(${d.id}, '${escapeHtml(d.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay demos</td></tr>`;
|
||||
}
|
||||
|
||||
document.getElementById("demo-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector("button[type=submit]");
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> Creando...`;
|
||||
btn.disabled = true;
|
||||
|
||||
const payload = {
|
||||
name: document.getElementById("demo-name").value,
|
||||
email: document.getElementById("demo-email").value,
|
||||
days: parseInt(document.getElementById("demo-days").value),
|
||||
pin: document.getElementById("demo-pin").value,
|
||||
subdomain: document.getElementById("demo-subdomain").value || undefined
|
||||
};
|
||||
|
||||
const res = await api("/api/demos", { method: "POST", body: payload });
|
||||
const resultBox = document.getElementById("demo-result");
|
||||
|
||||
if (res && res.status === 201) {
|
||||
const d = res.data.data;
|
||||
resultBox.innerHTML = `
|
||||
<h4><i class="fas fa-check-circle"></i> Demo creada exitosamente</h4>
|
||||
<div class="copy-row"><strong>URL:</strong> <code>${d.access_url}</code> <button class="btn-icon" onclick="copyText('${d.access_url}')"><i class="fas fa-copy"></i></button></div>
|
||||
<div class="copy-row"><strong>Subdominio:</strong> <code>${d.subdomain}</code></div>
|
||||
<div class="copy-row"><strong>PIN Owner:</strong> <code>${d.owner_pin}</code></div>
|
||||
<div class="copy-row"><strong>Expira:</strong> ${new Date(d.expires_at).toLocaleDateString()}</div>
|
||||
`;
|
||||
resultBox.style.display = "block";
|
||||
toast("Demo creada correctamente", "success");
|
||||
document.getElementById("demo-form").reset();
|
||||
loadDemos();
|
||||
} else {
|
||||
toast(res?.data?.error || "Error al crear demo", "error");
|
||||
}
|
||||
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
// ─── Tenants ───────────────────────────────────────────────────────────────
|
||||
async function loadTenants(withStats = false) {
|
||||
const res = await api(`/api/tenants?stats=${withStats}`);
|
||||
if (!res || res.status !== 200) return;
|
||||
|
||||
const tbody = document.getElementById("tenants-table");
|
||||
const tenants = res.data.data;
|
||||
document.getElementById("tenant-count").textContent = tenants.length;
|
||||
|
||||
tbody.innerHTML = tenants.map(t => `
|
||||
<tr>
|
||||
<td>${t.id}</td>
|
||||
<td><strong>${escapeHtml(t.name)}</strong></td>
|
||||
<td><code>${escapeHtml(t.subdomain)}</code></td>
|
||||
<td>${tag(t.plan || "basic", t.plan === "demo" ? "info" : "default")}</td>
|
||||
<td>${t.schema_version || "v0.0"}</td>
|
||||
<td>${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
|
||||
<td>${formatDate(t.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn-icon" onclick="openModulesModal(${t.id}, '${escapeHtml(t.name)}')" title="Módulos"><i class="fas fa-cubes" style="color:var(--accent)"></i></button>
|
||||
<button class="btn-icon" onclick="resetTenant(${t.id})" title="Resetear datos"><i class="fas fa-undo"></i></button>
|
||||
<button class="btn-icon" onclick="toggleTenant(${t.id}, ${!t.is_active})" title="${t.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${t.is_active ? "pause" : "play"}"></i></button>
|
||||
<button class="btn-icon" onclick="confirmDelete(${t.id}, '${escapeHtml(t.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("") || `<tr><td colspan="8" class="text-muted text-center">No hay tenants</td></tr>`;
|
||||
}
|
||||
|
||||
document.getElementById("tenant-search")?.addEventListener("input", (e) => {
|
||||
const term = e.target.value.toLowerCase();
|
||||
document.querySelectorAll("#tenants-table tr").forEach(row => {
|
||||
row.style.display = row.textContent.toLowerCase().includes(term) ? "" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Health ────────────────────────────────────────────────────────────────
|
||||
async function loadHealth() {
|
||||
const res = await api("/api/health");
|
||||
if (!res || res.status !== 200) return;
|
||||
|
||||
const h = res.data;
|
||||
|
||||
// PostgreSQL
|
||||
const pg = h.postgresql;
|
||||
document.getElementById("health-postgresql").innerHTML = pg.status === "ok" ? `
|
||||
<div class="health-item"><span class="health-label">Estado</span><span class="health-value" style="color:var(--success)">Online</span></div>
|
||||
<div class="health-item"><span class="health-label">Versión</span><span class="health-value">${pg.version}</span></div>
|
||||
<div class="health-item"><span class="health-label">Master DB</span><span class="health-value">${pg.master_size_mb} MB</span></div>
|
||||
` : renderError(pg.error);
|
||||
|
||||
// Redis
|
||||
const rd = h.redis;
|
||||
document.getElementById("health-redis").innerHTML = rd.status === "ok" ? `
|
||||
<div class="health-item"><span class="health-label">Estado</span><span class="health-value" style="color:var(--success)">Online</span></div>
|
||||
<div class="health-item"><span class="health-label">Versión</span><span class="health-value">${rd.version}</span></div>
|
||||
<div class="health-item"><span class="health-label">Memoria</span><span class="health-value">${rd.used_memory_human}</span></div>
|
||||
<div class="health-item"><span class="health-label">Clientes</span><span class="health-value">${rd.connected_clients}</span></div>
|
||||
` : renderError(rd.error);
|
||||
|
||||
// Disk
|
||||
const dk = h.disk;
|
||||
document.getElementById("health-disk").innerHTML = dk.status === "ok" ? `
|
||||
<div class="health-item"><span class="health-label">Total</span><span class="health-value">${dk.total_gb} GB</span></div>
|
||||
<div class="health-item"><span class="health-label">Usado</span><span class="health-value">${dk.used_gb} GB (${dk.percent_used}%)</span></div>
|
||||
<div class="health-bar-bg"><div class="health-bar-fill" style="width:${dk.percent_used}%; background:${getBarColor(dk.percent_used)}"></div></div>
|
||||
<div class="health-item" style="margin-top:12px"><span class="health-label">Libre</span><span class="health-value">${dk.free_gb} GB</span></div>
|
||||
` : renderError(dk.error);
|
||||
|
||||
// Memory
|
||||
const mem = h.memory;
|
||||
document.getElementById("health-memory").innerHTML = mem.status === "ok" ? `
|
||||
<div class="health-item"><span class="health-label">Total</span><span class="health-value">${mem.total_gb} GB</span></div>
|
||||
<div class="health-item"><span class="health-label">Usada</span><span class="health-value">${mem.used_gb} GB (${mem.percent_used}%)</span></div>
|
||||
<div class="health-bar-bg"><div class="health-bar-fill" style="width:${mem.percent_used}%; background:${getBarColor(mem.percent_used)}"></div></div>
|
||||
<div class="health-item" style="margin-top:12px"><span class="health-label">Disponible</span><span class="health-value">${mem.available_gb} GB</span></div>
|
||||
` : renderError(mem.error);
|
||||
|
||||
// Services
|
||||
const svcs = h.services || {};
|
||||
document.getElementById("health-services").innerHTML = Object.entries(svcs).map(([name, s]) => `
|
||||
<div class="health-item">
|
||||
<span class="health-label"><i class="fas fa-${s.active ? "check-circle" : "times-circle"}" style="color:${s.active ? "var(--success)" : "var(--danger)"}; margin-right:6px"></i>${name}</span>
|
||||
<span class="health-value" style="color:${s.active ? "var(--success)" : "var(--danger)"}">${s.state}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
// HTTP
|
||||
const httpChecks = ["pos", "dashboard", "quart"];
|
||||
document.getElementById("health-http").innerHTML = `
|
||||
<div class="grid-3">
|
||||
${httpChecks.map(key => {
|
||||
const svc = h[key];
|
||||
const ok = svc && svc.status === "ok";
|
||||
return `
|
||||
<div class="health-item">
|
||||
<span class="health-label">${key.toUpperCase()}</span>
|
||||
<span class="health-value" style="color:${ok ? "var(--success)" : "var(--danger)"}">
|
||||
${ok ? `HTTP ${svc.http_status}` : (svc.error || "Offline")}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
return `<div class="text-muted" style="padding:20px; text-align:center; color:var(--danger)"><i class="fas fa-exclamation-triangle"></i> ${escapeHtml(msg)}</div>`;
|
||||
}
|
||||
|
||||
// ─── Migrations ────────────────────────────────────────────────────────────
|
||||
async function loadMigrations() {
|
||||
const res = await api("/api/admin/migrations");
|
||||
if (!res || res.status !== 200) return;
|
||||
|
||||
const tbody = document.getElementById("migrations-table");
|
||||
const tenants = res.data.tenants || [];
|
||||
tbody.innerHTML = tenants.map(t => {
|
||||
const needsUpdate = t.version !== (res.data.migrations.slice(-1)[0]?.version || t.version);
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(t.name)}</td>
|
||||
<td><code>${t.db_name}</code></td>
|
||||
<td>${t.version}</td>
|
||||
<td>${needsUpdate ? tag("Pendiente", "warning") : tag("OK", "success")}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay tenants</td></tr>`;
|
||||
}
|
||||
|
||||
async function runAllMigrations() {
|
||||
if (!confirm("¿Ejecutar todas las migraciones pendientes en TODOS los tenants?")) return;
|
||||
|
||||
const logBox = document.getElementById("migration-log");
|
||||
logBox.style.display = "block";
|
||||
logBox.textContent = "Ejecutando migraciones...";
|
||||
|
||||
const res = await api("/api/admin/migrations/run-all", { method: "POST" });
|
||||
if (res && res.status === 200) {
|
||||
logBox.textContent = res.data.log || "Completado";
|
||||
toast("Migraciones ejecutadas", "success");
|
||||
loadMigrations();
|
||||
} else {
|
||||
logBox.textContent = "Error: " + (res?.data?.error || "Unknown");
|
||||
toast("Error en migraciones", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions ───────────────────────────────────────────────────────────────
|
||||
async function toggleTenant(id, active) {
|
||||
const res = await api(`/api/tenants/${id}/toggle`, {
|
||||
method: "POST",
|
||||
body: { active }
|
||||
});
|
||||
if (res && res.status === 200) {
|
||||
toast(active ? "Tenant activado" : "Tenant desactivado", "success");
|
||||
loadTenants();
|
||||
loadDemos();
|
||||
} else {
|
||||
toast(res?.data?.error || "Error", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function resetTenant(id) {
|
||||
if (!confirm("¿Resetear TODOS los datos de negocio de este tenant? Se conservan empleados y configuración.")) return;
|
||||
|
||||
const res = await api(`/api/tenants/${id}/reset`, { method: "POST" });
|
||||
if (res && res.status === 200) {
|
||||
toast("Tenant reseteado", "success");
|
||||
} else {
|
||||
toast(res?.data?.error || "Error al resetear", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id, name) {
|
||||
openModal(
|
||||
"Eliminar Tenant",
|
||||
`¿Eliminar permanentemente <strong>${escapeHtml(name)}</strong>? Esta acción no se puede deshacer. Se borrará la base de datos completa.`,
|
||||
async () => {
|
||||
const res = await api(`/api/tenants/${id}`, { method: "DELETE" });
|
||||
if (res && res.status === 200) {
|
||||
toast("Tenant eliminado", "success");
|
||||
loadTenants();
|
||||
loadDemos();
|
||||
} else {
|
||||
toast(res?.data?.error || "Error al eliminar", "error");
|
||||
}
|
||||
closeModal();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Modal ─────────────────────────────────────────────────────────────────
|
||||
function openModal(title, body, onConfirm) {
|
||||
document.getElementById("modal-title").textContent = title;
|
||||
document.getElementById("modal-body").innerHTML = body;
|
||||
const btn = document.getElementById("modal-confirm-btn");
|
||||
btn.onclick = onConfirm;
|
||||
document.getElementById("modal").style.display = "flex";
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById("modal").style.display = "none";
|
||||
}
|
||||
|
||||
// ─── Toast ─────────────────────────────────────────────────────────────────
|
||||
function toast(message, type = "info") {
|
||||
const container = document.getElementById("toast-container");
|
||||
const el = document.createElement("div");
|
||||
el.className = `toast ${type}`;
|
||||
el.innerHTML = `<i class="fas fa-${type === "success" ? "check-circle" : type === "error" ? "exclamation-circle" : "info-circle"}"></i> ${escapeHtml(message)}`;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => {
|
||||
el.style.opacity = "0";
|
||||
el.style.transform = "translateX(100%)";
|
||||
setTimeout(() => el.remove(), 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// ─── Utilities ─────────────────────────────────────────────────────────────
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function tag(text, type) {
|
||||
return `<span class="tag tag-${type}">${escapeHtml(text)}</span>`;
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return "-";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("es-MX");
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
navigator.clipboard.writeText(text).then(() => toast("Copiado al portapapeles", "success"));
|
||||
}
|
||||
|
||||
// ─── Modules ───────────────────────────────────────────────────────────────
|
||||
let currentModulesTenantId = null;
|
||||
|
||||
async function openModulesModal(tenantId, name) {
|
||||
currentModulesTenantId = tenantId;
|
||||
document.getElementById("modules-modal-title").textContent = `Módulos — ${escapeHtml(name)}`;
|
||||
document.getElementById("modules-modal").style.display = "flex";
|
||||
|
||||
// Load current state
|
||||
const res = await api(`/api/tenants/${tenantId}/modules`);
|
||||
if (res && res.status === 200) {
|
||||
const m = res.data.data;
|
||||
document.getElementById("mod-whatsapp").checked = m.whatsapp !== false;
|
||||
document.getElementById("mod-marketplace").checked = m.marketplace !== false;
|
||||
document.getElementById("mod-meli").checked = m.meli !== false;
|
||||
} else {
|
||||
toast("Error al cargar módulos", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function closeModulesModal() {
|
||||
document.getElementById("modules-modal").style.display = "none";
|
||||
currentModulesTenantId = null;
|
||||
}
|
||||
|
||||
async function saveModules() {
|
||||
if (!currentModulesTenantId) return;
|
||||
const btn = document.getElementById("modules-save-btn");
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> Guardando...`;
|
||||
btn.disabled = true;
|
||||
|
||||
const payload = {
|
||||
whatsapp: document.getElementById("mod-whatsapp").checked,
|
||||
marketplace: document.getElementById("mod-marketplace").checked,
|
||||
meli: document.getElementById("mod-meli").checked,
|
||||
catalog: document.getElementById("mod-catalog").checked,
|
||||
};
|
||||
|
||||
const res = await api(`/api/tenants/${currentModulesTenantId}/modules`, {
|
||||
method: "PUT",
|
||||
body: payload
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast("Módulos actualizados", "success");
|
||||
closeModulesModal();
|
||||
} else {
|
||||
toast(res?.data?.error || "Error al guardar", "error");
|
||||
}
|
||||
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
// ─── Init ──────────────────────────────────────────────────────────────────
|
||||
document.addEventListener("DOMContentLoaded", initAuth);
|
||||
40
manager/systemd/nexus-manager.service
Normal file
40
manager/systemd/nexus-manager.service
Normal file
@@ -0,0 +1,40 @@
|
||||
[Unit]
|
||||
Description=Nexus Instance Manager (Control Central)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/home/Autopartes/manager
|
||||
ExecStart=/usr/local/bin/gunicorn -w 2 --threads 4 -b 0.0.0.0:5003 "app:create_app()"
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# ─── Local Paths ───────────────────────────────────────────────────────────
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONPATH=/home/Autopartes/manager:/home/Autopartes/pos
|
||||
Environment=POS_DIR=/home/Autopartes/pos
|
||||
|
||||
# ─── Database (UPDATE FOR REMOTE VM) ───────────────────────────────────────
|
||||
# If manager runs on a separate VM, change localhost to the IP of the
|
||||
# PostgreSQL server (e.g. 192.168.10.91).
|
||||
Environment=MASTER_DB_URL=postgresql://postgres@localhost/nexus_autoparts
|
||||
Environment=TENANT_DB_URL_TEMPLATE=postgresql://postgres@localhost/{db_name}
|
||||
|
||||
# ─── Remote Nexus Server IP ────────────────────────────────────────────────
|
||||
# Set to the IP/hostname of the server running POS/Dashboard/Quart/Redis.
|
||||
# Leave as 127.0.0.1 if manager runs on the same server.
|
||||
Environment=NEXUS_SERVER_HOST=127.0.0.1
|
||||
|
||||
# ─── Security (CHANGE THIS) ────────────────────────────────────────────────
|
||||
Environment=MANAGER_JWT_SECRET=change-me-to-a-random-64-char-hex-string
|
||||
Environment=INTERNAL_API_KEY=c58db62766712e618a881dbe8de580960812e57a069ef92c9dd00e7e69158cb2
|
||||
|
||||
# ─── POS Internal API (for WhatsApp bridge orchestration) ──────────────────
|
||||
Environment=POS_INTERNAL_URL=http://192.168.10.91:5001
|
||||
|
||||
# ─── Redis (optional, health check only) ───────────────────────────────────
|
||||
Environment=REDIS_URL=redis://127.0.0.1:6379/0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
381
manager/templates/index.html
Normal file
381
manager/templates/index.html
Normal file
@@ -0,0 +1,381 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nexus Instance Manager</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/manager.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Login Screen -->
|
||||
<div id="login-screen" class="login-screen">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<i class="fas fa-cube"></i>
|
||||
<h1>Nexus Manager</h1>
|
||||
<p>Control Central de Instancias</p>
|
||||
</div>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" id="login-email" required placeholder="admin@nexus.local">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Contraseña</label>
|
||||
<input type="password" id="login-password" required placeholder="••••••••">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
<i class="fas fa-sign-in-alt"></i> Ingresar
|
||||
</button>
|
||||
<div id="login-error" class="alert alert-error" style="display:none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main App -->
|
||||
<div id="app" class="app" style="display:none;">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<i class="fas fa-cube"></i>
|
||||
<span>Nexus Manager</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="#/dashboard" class="nav-item active" data-page="dashboard">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a href="#/demos" class="nav-item" data-page="demos">
|
||||
<i class="fas fa-rocket"></i>
|
||||
<span>Crear Demos</span>
|
||||
</a>
|
||||
<a href="#/tenants" class="nav-item" data-page="tenants">
|
||||
<i class="fas fa-building"></i>
|
||||
<span>Tenants</span>
|
||||
<span class="badge" id="tenant-count">0</span>
|
||||
</a>
|
||||
<a href="#/health" class="nav-item" data-page="health">
|
||||
<i class="fas fa-heartbeat"></i>
|
||||
<span>Salud</span>
|
||||
</a>
|
||||
<a href="#/migrations" class="nav-item" data-page="migrations">
|
||||
<i class="fas fa-database"></i>
|
||||
<span>Migraciones</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
<span id="user-email">admin</span>
|
||||
<button onclick="logout()" class="btn-icon" title="Cerrar sesión">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<h2 id="page-title">Dashboard</h2>
|
||||
<div class="topbar-actions">
|
||||
<span class="status-indicator" id="system-status">
|
||||
<i class="fas fa-circle"></i> Online
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<!-- Dashboard Page -->
|
||||
<section id="page-dashboard" class="page">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-blue"><i class="fas fa-building"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3 id="stat-total">0</h3>
|
||||
<p>Total Tenants</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-green"><i class="fas fa-check-circle"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3 id="stat-active">0</h3>
|
||||
<p>Activos</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-purple"><i class="fas fa-rocket"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3 id="stat-demos">0</h3>
|
||||
<p>Demos</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-orange"><i class="fas fa-clock"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3 id="stat-expiring">0</h3>
|
||||
<p>Expiran pronto</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-server"></i> Estado del Sistema</h3>
|
||||
</div>
|
||||
<div class="card-body" id="system-health-summary">
|
||||
<div class="loading">Cargando...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-building"></i> Demos Recientes</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table compact">
|
||||
<thead>
|
||||
<tr><th>Nombre</th><th>Subdominio</th><th>Expira</th><th>Estado</th></tr>
|
||||
</thead>
|
||||
<tbody id="recent-demos-table">
|
||||
<tr><td colspan="4" class="text-muted">Cargando...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Demos Page -->
|
||||
<section id="page-demos" class="page" style="display:none;">
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-plus-circle"></i> Nueva Demo</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="demo-form">
|
||||
<div class="form-group">
|
||||
<label>Nombre del negocio *</label>
|
||||
<input type="text" id="demo-name" required placeholder="Refaccionaria López">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email de contacto</label>
|
||||
<input type="email" id="demo-email" placeholder="cliente@email.com">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Días de vigencia</label>
|
||||
<input type="number" id="demo-days" value="14" min="1" max="90">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>PIN del owner</label>
|
||||
<input type="text" id="demo-pin" value="0000" maxlength="10">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Subdominio (opcional)</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="demo-subdomain" placeholder="refaccionaria-lopez">
|
||||
<span class="input-suffix">.nexusautoparts.com.mx</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-rocket"></i> Crear Demo
|
||||
</button>
|
||||
</form>
|
||||
<div id="demo-result" class="result-box" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-list"></i> Demos Activas</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Negocio</th><th>URL</th><th>Días rest.</th><th>Acciones</th></tr>
|
||||
</thead>
|
||||
<tbody id="demos-table">
|
||||
<tr><td colspan="4" class="text-muted">Cargando...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tenants Page -->
|
||||
<section id="page-tenants" class="page" style="display:none;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-building"></i> Todos los Tenants</h3>
|
||||
<div class="card-actions">
|
||||
<input type="text" id="tenant-search" placeholder="Buscar..." class="input-sm">
|
||||
<button class="btn btn-sm btn-secondary" onclick="loadTenants(true)">
|
||||
<i class="fas fa-sync"></i> Refrescar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nombre</th>
|
||||
<th>Subdominio</th>
|
||||
<th>Plan</th>
|
||||
<th>Versión</th>
|
||||
<th>Estado</th>
|
||||
<th>Creado</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tenants-table">
|
||||
<tr><td colspan="8" class="text-muted">Cargando...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Health Page -->
|
||||
<section id="page-health" class="page" style="display:none;">
|
||||
<div class="grid-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-database"></i> PostgreSQL</h3></div>
|
||||
<div class="card-body" id="health-postgresql"><div class="loading">...</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-bolt"></i> Redis</h3></div>
|
||||
<div class="card-body" id="health-redis"><div class="loading">...</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-hdd"></i> Disco</h3></div>
|
||||
<div class="card-body" id="health-disk"><div class="loading">...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-memory"></i> Memoria</h3></div>
|
||||
<div class="card-body" id="health-memory"><div class="loading">...</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-cogs"></i> Servicios Systemd</h3></div>
|
||||
<div class="card-body" id="health-services"><div class="loading">...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-network-wired"></i> Servicios HTTP</h3></div>
|
||||
<div class="card-body" id="health-http"><div class="loading">...</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Migrations Page -->
|
||||
<section id="page-migrations" class="page" style="display:none;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-database"></i> Migraciones de Schema</h3>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-primary" onclick="runAllMigrations()">
|
||||
<i class="fas fa-play"></i> Ejecutar todas pendientes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="migration-log" class="log-box" style="display:none;"></div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Tenant</th><th>DB</th><th>Versión actual</th><th>Estado</th></tr>
|
||||
</thead>
|
||||
<tbody id="migrations-table">
|
||||
<tr><td colspan="4" class="text-muted">Cargando...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div id="modal" class="modal" style="display:none;">
|
||||
<div class="modal-overlay" onclick="closeModal()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title">Confirmar</h3>
|
||||
<button class="btn-icon" onclick="closeModal()"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-body"></div>
|
||||
<div class="modal-footer" id="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Cancelar</button>
|
||||
<button class="btn btn-danger" id="modal-confirm-btn">Confirmar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modules Modal -->
|
||||
<div id="modules-modal" class="modal" style="display:none;">
|
||||
<div class="modal-overlay" onclick="closeModulesModal()"></div>
|
||||
<div class="modal-content" style="max-width:480px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="modules-modal-title">Módulos del Tenant</h3>
|
||||
<button class="btn-icon" onclick="closeModulesModal()"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border);">
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--text);">WhatsApp</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de WhatsApp Bridge</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="mod-whatsapp">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border);">
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--text);">Marketplace</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de Marketplace interno</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="mod-marketplace">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border);">
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--text);">MercadoLibre</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de MercadoLibre</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="mod-meli">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;">
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--text);">Catálogo</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de Catálogo de productos</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="mod-catalog">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModulesModal()">Cancelar</button>
|
||||
<button class="btn btn-primary" id="modules-save-btn" onclick="saveModules()">Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/manager.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
4
manager/wsgi.py
Normal file
4
manager/wsgi.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""WSGI entry point for Nexus Instance Manager."""
|
||||
from app import create_app
|
||||
|
||||
application = create_app()
|
||||
@@ -1,106 +1,134 @@
|
||||
# Wildcard subdomain routing for Nexus POS
|
||||
# DNS: *.nexusautoparts.com -> server IP (Cloudflare wildcard)
|
||||
|
||||
# Rate limiting zone
|
||||
limit_req_zone $binary_remote_addr zone=pos_login:10m rate=10r/s;
|
||||
|
||||
# Upstream backends
|
||||
upstream nexus_main {
|
||||
server 127.0.0.1:5000;
|
||||
}
|
||||
|
||||
upstream nexus_pos {
|
||||
server 127.0.0.1:5001;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||
upstream nexus_dashboard {
|
||||
server 127.0.0.1:5000;
|
||||
}
|
||||
|
||||
# Main site (no subdomain)
|
||||
upstream nexus_quart {
|
||||
server 127.0.0.1:5002;
|
||||
}
|
||||
|
||||
# ─── Landing page / Dashboard (primary domain) ───
|
||||
server {
|
||||
listen 80;
|
||||
server_name nexusautoparts.com www.nexusautoparts.com;
|
||||
server_name nexusautoparts.com.mx www.nexusautoparts.com.mx;
|
||||
|
||||
# Static asset caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 6M;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
}
|
||||
client_max_body_size 10M;
|
||||
|
||||
# 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;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
}
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
location / {
|
||||
proxy_pass http://nexus_main;
|
||||
proxy_pass http://nexus_dashboard;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
# POS subdomains (wildcard)
|
||||
# ─── POS (dedicated subdomain) ───
|
||||
server {
|
||||
listen 80;
|
||||
server_name ~^(?<tenant>.+)\.nexusautoparts\.com$;
|
||||
server_name pos.nexusautoparts.com.mx;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
client_max_body_size 10M;
|
||||
|
||||
# Static asset caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# Static assets with caching (proxy to Flask)
|
||||
location /pos/static/ {
|
||||
proxy_pass http://nexus_pos;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
expires 6M;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
}
|
||||
|
||||
# Async catalog search via Quart+asyncpg (non-blocking I/O)
|
||||
location /pos/api/catalog/async-search {
|
||||
proxy_pass http://nexus_quart;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location = / {
|
||||
return 302 /pos/login;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://nexus_pos;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Tenant-Subdomain $tenant;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
# Rate limit login endpoint
|
||||
location /pos/api/auth/login {
|
||||
limit_req zone=pos_login burst=5 nodelay;
|
||||
proxy_pass http://nexus_pos;
|
||||
# ─── Dashboard admin (alternative access) ───
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.nexusautoparts.com.mx;
|
||||
|
||||
client_max_body_size 10M;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
location / {
|
||||
proxy_pass http://nexus_dashboard;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Tenant-Subdomain $tenant;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Legacy domain (keep for migration period) ───
|
||||
server {
|
||||
listen 80;
|
||||
server_name nexus.consultoria-as.com;
|
||||
|
||||
client_max_body_size 10M;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
location / {
|
||||
proxy_pass http://nexus_dashboard;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "autopartes",
|
||||
"version": "1.0.0",
|
||||
"description": "**POS + Catalogo de autopartes para refaccionarias mexicanas.**",
|
||||
"main": "index.js",
|
||||
"directories": {
|
||||
"doc": "docs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://consultoria-as:b708144ceef22fef31217f1259a695005d67477b@git.consultoria-as.com/consultoria-as/Autoparts-DB.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
}
|
||||
21
playwright.config.js
Normal file
21
playwright.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const { defineConfig, devices } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: 'http://localhost:5001',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
20
pos/Dockerfile.whatsapp-bridge
Normal file
20
pos/Dockerfile.whatsapp-bridge
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install git and build tools (needed for some npm deps)
|
||||
RUN apk add --no-cache git python3 make g++
|
||||
|
||||
# Install dependencies
|
||||
COPY whatsapp-bridge-package.json package.json
|
||||
RUN npm install
|
||||
|
||||
# Copy bridge server
|
||||
COPY whatsapp-bridge-server.js .
|
||||
|
||||
# Create auth directory
|
||||
RUN mkdir -p /app/auth
|
||||
|
||||
EXPOSE 21465
|
||||
|
||||
CMD ["node", "whatsapp-bridge-server.js"]
|
||||
46
pos/app.py
46
pos/app.py
@@ -32,6 +32,9 @@ def create_app():
|
||||
from blueprints.pos_bp import pos_bp
|
||||
app.register_blueprint(pos_bp)
|
||||
|
||||
from blueprints.public_bp import public_bp
|
||||
app.register_blueprint(public_bp)
|
||||
|
||||
from blueprints.customers_bp import customers_bp
|
||||
app.register_blueprint(customers_bp)
|
||||
|
||||
@@ -56,6 +59,12 @@ def create_app():
|
||||
from blueprints.marketplace_bp import marketplace_bp
|
||||
app.register_blueprint(marketplace_bp)
|
||||
|
||||
from blueprints.marketplace_external_bp import marketplace_ext_bp
|
||||
app.register_blueprint(marketplace_ext_bp)
|
||||
|
||||
from blueprints.dropshipping_bp import dropship_bp
|
||||
app.register_blueprint(dropship_bp)
|
||||
|
||||
from blueprints.peer_bp import peer_bp
|
||||
app.register_blueprint(peer_bp)
|
||||
|
||||
@@ -89,6 +98,27 @@ def create_app():
|
||||
from blueprints.tasks_bp import tasks_bp
|
||||
app.register_blueprint(tasks_bp)
|
||||
|
||||
from blueprints.bnpl_bp import bnpl_bp
|
||||
app.register_blueprint(bnpl_bp)
|
||||
|
||||
from blueprints.erp_bp import erp_bp
|
||||
app.register_blueprint(erp_bp)
|
||||
|
||||
from blueprints.whatsapp_cloud_bp import whatsapp_cloud_bp
|
||||
app.register_blueprint(whatsapp_cloud_bp)
|
||||
|
||||
from blueprints.dashboard_stats_bp import dashboard_stats_bp
|
||||
app.register_blueprint(dashboard_stats_bp)
|
||||
|
||||
from blueprints.supplier_portal_bp import supplier_portal_bp
|
||||
app.register_blueprint(supplier_portal_bp)
|
||||
|
||||
from blueprints.supplier_catalog_bp import supplier_catalog_bp
|
||||
app.register_blueprint(supplier_catalog_bp)
|
||||
|
||||
from blueprints.internal_bp import internal_bp
|
||||
app.register_blueprint(internal_bp)
|
||||
|
||||
# Health check
|
||||
@app.route('/pos/health')
|
||||
def health():
|
||||
@@ -107,6 +137,10 @@ def create_app():
|
||||
tenant_name=getattr(g, 'tenant_name', None),
|
||||
tenant_subdomain=getattr(g, 'tenant_subdomain', None))
|
||||
|
||||
@app.route('/pos/supplier-catalog')
|
||||
def supplier_catalog_page():
|
||||
return render_template('supplier_catalog.html')
|
||||
|
||||
@app.route('/pos/catalog')
|
||||
def pos_catalog():
|
||||
return render_template('catalog.html')
|
||||
@@ -159,6 +193,18 @@ def create_app():
|
||||
def pos_marketplace():
|
||||
return render_template('marketplace.html')
|
||||
|
||||
@app.route('/pos/marketplace-external')
|
||||
def pos_marketplace_external():
|
||||
return render_template('marketplace_external.html')
|
||||
|
||||
@app.route('/pos/marketplace-external/callback')
|
||||
def pos_marketplace_external_callback():
|
||||
return render_template('marketplace_external.html')
|
||||
|
||||
@app.route('/pos/historical-sales')
|
||||
def pos_historical_sales():
|
||||
return render_template('historical_sales.html')
|
||||
|
||||
@app.route('/pos/static/<path:filename>')
|
||||
def pos_static(filename):
|
||||
return send_from_directory('static', filename)
|
||||
|
||||
@@ -741,3 +741,45 @@ def close_period():
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@accounting_bp.route('/stats', methods=['GET'])
|
||||
@require_auth('accounting.read')
|
||||
def api_accounting_stats():
|
||||
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Count asset accounts with positive balance (cuentas por cobrar)
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT a.id
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_entry_lines l ON l.account_id = a.id
|
||||
WHERE a.type = 'activo' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
HAVING COALESCE(SUM(l.debit), 0) - COALESCE(SUM(l.credit), 0) > 0
|
||||
) x
|
||||
""")
|
||||
cxc = cur.fetchone()[0] or 0
|
||||
|
||||
# Count liability accounts with positive balance (cuentas por pagar)
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT a.id
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_entry_lines l ON l.account_id = a.id
|
||||
WHERE a.type = 'pasivo' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
HAVING COALESCE(SUM(l.credit), 0) - COALESCE(SUM(l.debit), 0) > 0
|
||||
) x
|
||||
""")
|
||||
cxp = cur.fetchone()[0] or 0
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'cuentas_cobrar': cxc,
|
||||
'cuentas_pagar': cxp,
|
||||
})
|
||||
|
||||
90
pos/blueprints/bnpl_bp.py
Normal file
90
pos/blueprints/bnpl_bp.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""BNPL Blueprint — Buy Now Pay Later integrations (stub architecture).
|
||||
|
||||
Providers: APLAZO, Kueski, Clip (configured per tenant).
|
||||
All endpoints are stubs with mock responses until real credentials are provided.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from functools import wraps
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
bnpl_bp = Blueprint('bnpl', __name__, url_prefix='/pos/api/bnpl')
|
||||
|
||||
# ─── Auth helper ───
|
||||
from middleware import require_auth
|
||||
|
||||
# ─── Mock store ───
|
||||
_mock_applications = {}
|
||||
|
||||
|
||||
@bnpl_bp.route('/providers', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_providers():
|
||||
"""List configured BNPL providers."""
|
||||
return jsonify({
|
||||
'providers': [
|
||||
{'id': 'ap lazo', 'name': 'APLAZO', 'enabled': False, 'config_needed': ['api_key', 'merchant_id']},
|
||||
{'id': 'kueski', 'name': 'Kueski Pay', 'enabled': False, 'config_needed': ['api_key', 'secret']},
|
||||
{'id': 'clip', 'name': 'Clip Pagos', 'enabled': False, 'config_needed': ['api_key']},
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@bnpl_bp.route('/applications', methods=['POST'])
|
||||
@require_auth()
|
||||
def create_application():
|
||||
"""Create a BNPL application for a sale."""
|
||||
data = request.get_json() or {}
|
||||
sale_id = data.get('sale_id')
|
||||
amount = data.get('amount')
|
||||
provider = data.get('provider', 'ap lazo')
|
||||
customer = data.get('customer', {})
|
||||
|
||||
if not sale_id or amount is None:
|
||||
return jsonify({'error': 'sale_id and amount are required'}), 400
|
||||
|
||||
app_id = str(uuid.uuid4())
|
||||
_mock_applications[app_id] = {
|
||||
'id': app_id,
|
||||
'sale_id': sale_id,
|
||||
'provider': provider,
|
||||
'amount': float(amount),
|
||||
'status': 'pending',
|
||||
'customer': customer,
|
||||
'created_at': datetime.utcnow().isoformat(),
|
||||
'expires_at': (datetime.utcnow() + timedelta(hours=24)).isoformat(),
|
||||
'approval_url': f'/pos/api/bnpl/applications/{app_id}/approve',
|
||||
'webhook_url': f'/pos/api/bnpl/webhook/{provider}',
|
||||
}
|
||||
|
||||
return jsonify(_mock_applications[app_id]), 201
|
||||
|
||||
|
||||
@bnpl_bp.route('/applications/<app_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_application(app_id):
|
||||
"""Get BNPL application status."""
|
||||
app = _mock_applications.get(app_id)
|
||||
if not app:
|
||||
return jsonify({'error': 'Application not found'}), 404
|
||||
return jsonify(app)
|
||||
|
||||
|
||||
@bnpl_bp.route('/applications/<app_id>/approve', methods=['POST'])
|
||||
@require_auth()
|
||||
def approve_application(app_id):
|
||||
"""Mock approve an application (admin/override)."""
|
||||
app = _mock_applications.get(app_id)
|
||||
if not app:
|
||||
return jsonify({'error': 'Application not found'}), 404
|
||||
app['status'] = 'approved'
|
||||
app['approved_at'] = datetime.utcnow().isoformat()
|
||||
return jsonify(app)
|
||||
|
||||
|
||||
@bnpl_bp.route('/webhook/<provider>', methods=['POST'])
|
||||
def webhook(provider):
|
||||
"""Receive webhooks from BNPL providers."""
|
||||
data = request.get_json() or {}
|
||||
# In production, verify signature per provider
|
||||
return jsonify({'received': True, 'provider': provider, 'payload': data}), 200
|
||||
@@ -35,6 +35,25 @@ def _oem_blocked():
|
||||
return None
|
||||
|
||||
|
||||
def _get_allowed_brands(tenant_conn):
|
||||
"""Read allowed part brands from tenant_config. Returns list or None."""
|
||||
import json
|
||||
cur = tenant_conn.cursor()
|
||||
try:
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'allowed_part_brands'")
|
||||
row = cur.fetchone()
|
||||
if row and row[0]:
|
||||
try:
|
||||
brands = json.loads(row[0])
|
||||
if isinstance(brands, list) and brands:
|
||||
return brands
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
cur.close()
|
||||
return None
|
||||
|
||||
|
||||
def _with_conns(fn):
|
||||
"""Helper: open master + tenant connections, call fn, close both.
|
||||
fn receives (master_conn, tenant_conn, branch_id).
|
||||
@@ -71,6 +90,34 @@ def _master_only(fn):
|
||||
except: pass
|
||||
|
||||
|
||||
def _filter_parts_by_allowed_brands(master_conn, parts_data, allowed_brands):
|
||||
"""Filter a list of part dicts to only include those with aftermarket equivalents
|
||||
from allowed brands. parts_data items must have 'id_part' or 'id' key."""
|
||||
if not allowed_brands or not parts_data:
|
||||
return parts_data
|
||||
part_ids = []
|
||||
for p in parts_data:
|
||||
pid = p.get('id_part') or p.get('id')
|
||||
# Skip local inventory IDs (strings like 'inv:3') — aftermarket filter
|
||||
# only applies to catalog parts with integer OEM part IDs.
|
||||
if pid is not None and isinstance(pid, int):
|
||||
part_ids.append(pid)
|
||||
if not part_ids:
|
||||
return parts_data
|
||||
cur = master_conn.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT ap.oem_part_id
|
||||
FROM aftermarket_parts ap
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
WHERE ap.oem_part_id = ANY(%s) AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
""", (part_ids, allowed_brands))
|
||||
allowed_ids = {r[0] for r in cur.fetchall()}
|
||||
finally:
|
||||
cur.close()
|
||||
return [p for p in parts_data if (p.get('id_part') or p.get('id')) in allowed_ids]
|
||||
|
||||
|
||||
# ─── Hierarchy navigation (master DB only) ───
|
||||
|
||||
@catalog_bp.route('/brands', methods=['GET'])
|
||||
@@ -79,10 +126,11 @@ def brands():
|
||||
from services.catalog_modes import normalize_mode
|
||||
year_id = request.args.get('year_id', type=int)
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
def _do(master):
|
||||
data = catalog_service.get_brands(master, year_id=year_id, mode=mode)
|
||||
def _do(master, tenant, branch_id):
|
||||
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
|
||||
data = catalog_service.get_brands(master, year_id=year_id, mode=mode, mye_ids=mye_ids)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/models', methods=['GET'])
|
||||
@@ -92,10 +140,11 @@ def models():
|
||||
year_id = request.args.get('year_id', type=int)
|
||||
if not brand_id:
|
||||
return jsonify({'error': 'brand_id required'}), 400
|
||||
def _do(master):
|
||||
data = catalog_service.get_models(master, brand_id, year_id=year_id)
|
||||
def _do(master, tenant, branch_id):
|
||||
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
|
||||
data = catalog_service.get_models(master, brand_id, year_id=year_id, mye_ids=mye_ids)
|
||||
return jsonify({'data': data})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/years', methods=['GET'])
|
||||
@@ -104,10 +153,11 @@ def years():
|
||||
model_id = request.args.get('model_id', type=int)
|
||||
if not model_id:
|
||||
return jsonify({'error': 'model_id required'}), 400
|
||||
def _do(master):
|
||||
data = catalog_service.get_years(master, model_id)
|
||||
def _do(master, tenant, branch_id):
|
||||
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
|
||||
data = catalog_service.get_years(master, model_id, mye_ids=mye_ids)
|
||||
return jsonify({'data': data})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/years-all', methods=['GET'])
|
||||
@@ -130,10 +180,11 @@ def engines():
|
||||
year_id = request.args.get('year_id', type=int)
|
||||
if not model_id or not year_id:
|
||||
return jsonify({'error': 'model_id and year_id required'}), 400
|
||||
def _do(master):
|
||||
data = catalog_service.get_engines(master, model_id, year_id)
|
||||
def _do(master, tenant, branch_id):
|
||||
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
|
||||
data = catalog_service.get_engines(master, model_id, year_id, mye_ids=mye_ids)
|
||||
return jsonify({'data': data})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/categories', methods=['GET'])
|
||||
@@ -150,13 +201,14 @@ def categories():
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id required'}), 400
|
||||
def _do(master):
|
||||
def _do(master, tenant, branch_id):
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
if mode == 'local':
|
||||
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id)
|
||||
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id, tenant)
|
||||
else:
|
||||
data = catalog_service.get_categories(master, mye_id)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
data = catalog_service.get_categories(master, mye_id, allowed_brands)
|
||||
return jsonify({'data': data, 'mode': mode, 'allowed_brands': allowed_brands or []})
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/groups', methods=['GET'])
|
||||
@@ -174,17 +226,17 @@ def groups():
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id required'}), 400
|
||||
def _do(master):
|
||||
def _do(master, tenant, branch_id):
|
||||
if mode == 'local':
|
||||
if not category_slug:
|
||||
return jsonify({'error': 'category_slug required for local mode'}), 400
|
||||
data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug)
|
||||
data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug, tenant)
|
||||
else:
|
||||
if not category_id:
|
||||
return jsonify({'error': 'category_id required for oem mode'}), 400
|
||||
data = catalog_service.get_groups(master, mye_id, category_id)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
# ─── Parts with stock enrichment (master + tenant) ───
|
||||
@@ -205,19 +257,19 @@ def part_types():
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id required'}), 400
|
||||
def _do(master):
|
||||
def _do(master, tenant, branch_id):
|
||||
if mode == 'local':
|
||||
if not group_slug or not subgroup_slug:
|
||||
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
|
||||
data = catalog_service.get_nexpart_part_types_for_vehicle(
|
||||
master, mye_id, group_slug, subgroup_slug
|
||||
master, mye_id, group_slug, subgroup_slug, tenant
|
||||
)
|
||||
else:
|
||||
if not group_id:
|
||||
return jsonify({'error': 'group_id required for oem mode'}), 400
|
||||
data = catalog_service.get_part_types(master, mye_id, group_id)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/shop-supplies/groups', methods=['GET'])
|
||||
@@ -261,8 +313,8 @@ def shop_supplies_parts():
|
||||
group_slug = request.args.get('group_slug')
|
||||
subgroup_slug = request.args.get('subgroup_slug')
|
||||
part_type_slug = request.args.get('part_type_slug')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 30, type=int)
|
||||
page = max(1, request.args.get('page', 1, type=int) or 1)
|
||||
per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100))
|
||||
if not group_slug or not subgroup_slug or not part_type_slug:
|
||||
return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400
|
||||
def _do(master, tenant, branch_id):
|
||||
@@ -298,8 +350,8 @@ def parts():
|
||||
nexpart_subgroup = request.args.get('nexpart_subgroup')
|
||||
nexpart_part_type = request.args.get('nexpart_part_type')
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 30, type=int)
|
||||
page = max(1, request.args.get('page', 1, type=int) or 1)
|
||||
per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100))
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
|
||||
if not mye_id:
|
||||
@@ -317,19 +369,34 @@ def parts():
|
||||
return blocked
|
||||
|
||||
def _do(master, tenant, branch_id):
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
# For local mode with allowed_brands, fetch everything first so filtering
|
||||
# happens before pagination. OEM mode keeps post-filter for now.
|
||||
fetch_all_for_filter = bool(allowed_brands) and (mode == 'local' or use_nexpart_nav)
|
||||
_page = 1 if fetch_all_for_filter else page
|
||||
_per_page = 9999 if fetch_all_for_filter else per_page
|
||||
|
||||
if use_nexpart_nav:
|
||||
result = catalog_service.get_parts_for_nexpart_triple(
|
||||
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
|
||||
tenant, branch_id, page, per_page,
|
||||
tenant, branch_id, _page, _per_page, tenant_id=g.tenant_id,
|
||||
)
|
||||
elif mode == 'local':
|
||||
result = catalog_service.get_parts_local(
|
||||
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
|
||||
master, mye_id, group_id, tenant, branch_id, _page, _per_page, part_type=part_type,
|
||||
)
|
||||
else:
|
||||
result = catalog_service.get_parts(
|
||||
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
|
||||
)
|
||||
if allowed_brands:
|
||||
result['data'] = _filter_parts_by_allowed_brands(master, result.get('data', []), allowed_brands)
|
||||
if fetch_all_for_filter:
|
||||
total = len(result['data'])
|
||||
offset = (page - 1) * per_page
|
||||
result['data'] = result['data'][offset:offset + per_page]
|
||||
result['pagination'] = catalog_service._pagination(page, per_page, total)
|
||||
result['allowed_brands'] = allowed_brands or []
|
||||
return jsonify(result)
|
||||
return _with_conns(_do)
|
||||
|
||||
@@ -337,9 +404,8 @@ def parts():
|
||||
@catalog_bp.route('/part/<int:part_id>', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def part_detail(part_id):
|
||||
blocked = _oem_blocked()
|
||||
if blocked:
|
||||
return blocked
|
||||
# Part detail is available in both local and OEM modes
|
||||
# — it reads from the master parts DB and enriches with local stock.
|
||||
def _do(master, tenant, branch_id):
|
||||
result = catalog_service.get_part_detail(master, part_id, tenant, branch_id)
|
||||
if not result:
|
||||
@@ -351,16 +417,19 @@ def part_detail(part_id):
|
||||
@catalog_bp.route('/search', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def search():
|
||||
blocked = _oem_blocked()
|
||||
if blocked:
|
||||
return blocked
|
||||
# Search is available in both local and OEM modes
|
||||
# — it reads from the master parts DB and enriches with local stock.
|
||||
q = request.args.get('q', '').strip()
|
||||
if not q or len(q) < 2:
|
||||
return jsonify({'data': []})
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
def _do(master, tenant, branch_id):
|
||||
data = catalog_service.smart_search(master, q, tenant, branch_id, limit)
|
||||
return jsonify({'data': data})
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
data = catalog_service.smart_search(master, q, tenant, branch_id, limit, mye_id, tenant_id=g.tenant_id)
|
||||
if allowed_brands:
|
||||
data = _filter_parts_by_allowed_brands(master, data, allowed_brands)
|
||||
return jsonify({'data': data, 'allowed_brands': allowed_brands or []})
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@@ -592,3 +661,472 @@ def _match_vin_to_catalog(master_conn, vin_info):
|
||||
return None
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
# ─── Brand Catalog (vehicle-brand-first navigation) ───
|
||||
|
||||
@catalog_bp.route('/vehicle-brands', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def vehicle_brands():
|
||||
"""Return North American vehicle brands for brand-first catalog browsing.
|
||||
|
||||
Uses the same OEM_BRANDS_NA filter as the regular catalog so that
|
||||
the brand list is consistent across both navigation modes.
|
||||
"""
|
||||
from services.catalog_modes import get_brands_for_mode
|
||||
allowed = list(get_brands_for_mode('oem'))
|
||||
|
||||
def _query(master):
|
||||
cur = master.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT id_brand, name_brand
|
||||
FROM brands
|
||||
WHERE name_brand = ANY(%s)
|
||||
ORDER BY name_brand ASC
|
||||
""", (allowed,))
|
||||
rows = cur.fetchall()
|
||||
return jsonify({
|
||||
'brands': [
|
||||
{'id': r[0], 'name': r[1], 'part_count': 0}
|
||||
for r in rows
|
||||
]
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
return _master_only(_query)
|
||||
|
||||
|
||||
@catalog_bp.route('/brand-categories', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def brand_categories():
|
||||
"""Return part categories available for a given vehicle brand."""
|
||||
brand = request.args.get('brand', '')
|
||||
if not brand:
|
||||
return jsonify({'error': 'brand parameter required'}), 400
|
||||
|
||||
def _query(master, tenant, branch_id):
|
||||
cur = master.cursor()
|
||||
try:
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
brand_filter = ""
|
||||
params = [brand]
|
||||
if allowed_brands:
|
||||
brand_filter = """AND EXISTS (
|
||||
SELECT 1 FROM aftermarket_parts ap2
|
||||
JOIN manufacturers m2 ON m2.id_manufacture = ap2.manufacturer_id
|
||||
WHERE ap2.oem_part_id = p.id_part AND UPPER(m2.name_manufacture) = ANY(%s)
|
||||
)"""
|
||||
params.append(allowed_brands)
|
||||
cur.execute(f"""
|
||||
SELECT pc.id_part_category,
|
||||
COALESCE(NULLIF(pc.name_es, ''), pc.name_part_category) as name,
|
||||
pc.slug,
|
||||
COUNT(DISTINCT p.id_part) as part_count
|
||||
FROM part_vehicle_preview pvp
|
||||
JOIN parts p ON p.id_part = pvp.part_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE pvp.name_brand = %s
|
||||
{brand_filter}
|
||||
GROUP BY pc.id_part_category, pc.name_part_category, pc.name_es, pc.slug
|
||||
ORDER BY part_count DESC
|
||||
""", params)
|
||||
rows = cur.fetchall()
|
||||
return jsonify({
|
||||
'brand': brand,
|
||||
'categories': [
|
||||
{'id': r[0], 'name': r[1], 'slug': r[2], 'part_count': r[3]}
|
||||
for r in rows
|
||||
],
|
||||
'allowed_brands': allowed_brands or []
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
return _with_conns(_query)
|
||||
|
||||
|
||||
@catalog_bp.route('/brand-parts', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def brand_parts():
|
||||
"""Return parts for a given vehicle brand + category, optionally filtered by search term."""
|
||||
brand = request.args.get('brand', '')
|
||||
category_id = request.args.get('category_id', type=int)
|
||||
search = request.args.get('search', '').strip()
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
offset = request.args.get('offset', 0, type=int)
|
||||
|
||||
if not brand:
|
||||
return jsonify({'error': 'brand parameter required'}), 400
|
||||
|
||||
def _query(master, tenant, branch_id):
|
||||
cur = master.cursor()
|
||||
try:
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
|
||||
cat_filter = ""
|
||||
search_filter = ""
|
||||
params = [brand]
|
||||
|
||||
if category_id:
|
||||
cat_filter = "AND pc.id_part_category = %s"
|
||||
params.append(category_id)
|
||||
|
||||
# --- Brand-filtered mode: return aftermarket parts directly ---
|
||||
if allowed_brands:
|
||||
am_search = ""
|
||||
am_params = list(params)
|
||||
if search:
|
||||
am_search = "AND (ap.part_number ILIKE %s OR COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) ILIKE %s)"
|
||||
like_term = f"%{search}%"
|
||||
am_params.extend([like_term, like_term])
|
||||
|
||||
query_params = list(am_params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT ap.id_aftermarket_parts,
|
||||
ap.part_number,
|
||||
COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) as name,
|
||||
m.name_manufacture,
|
||||
ap.price_usd,
|
||||
p.id_part,
|
||||
pg.id_part_group, pg.name_part_group,
|
||||
pc.id_part_category, pc.name_part_category
|
||||
FROM part_vehicle_preview pvp
|
||||
JOIN parts p ON p.id_part = pvp.part_id
|
||||
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE pvp.name_brand = %s
|
||||
{cat_filter}
|
||||
{am_search}
|
||||
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
ORDER BY m.name_manufacture, ap.part_number
|
||||
LIMIT %s OFFSET %s
|
||||
""", query_params + [allowed_brands, limit, offset])
|
||||
|
||||
part_rows = cur.fetchall()
|
||||
oem_ids = [r[5] for r in part_rows]
|
||||
|
||||
count_params = list(am_params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT ap.id_aftermarket_parts)
|
||||
FROM part_vehicle_preview pvp
|
||||
JOIN parts p ON p.id_part = pvp.part_id
|
||||
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE pvp.name_brand = %s
|
||||
{cat_filter}
|
||||
{am_search}
|
||||
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
""", count_params + [allowed_brands])
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
local_stock = {}
|
||||
if tenant and oem_ids:
|
||||
try:
|
||||
from services.catalog_service import _get_local_stock_bulk
|
||||
local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
items = []
|
||||
for r in part_rows:
|
||||
oem_id = r[5]
|
||||
stock_info = local_stock.get(oem_id, {})
|
||||
items.append({
|
||||
'id': r[0],
|
||||
'oem_part_number': r[1],
|
||||
'name': r[2],
|
||||
'manufacturer': r[3],
|
||||
'price_usd': float(r[4]) if r[4] is not None else None,
|
||||
'oem_id': oem_id,
|
||||
'group': {'id': r[6], 'name': r[7]},
|
||||
'category': {'id': r[8], 'name': r[9]},
|
||||
'local_stock': stock_info.get('stock', 0),
|
||||
'local_price': stock_info.get('price', None),
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'brand': brand,
|
||||
'category_id': category_id,
|
||||
'search': search,
|
||||
'items': items,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': allowed_brands
|
||||
})
|
||||
|
||||
# --- Normal mode: return OEM parts ---
|
||||
if search:
|
||||
search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)"
|
||||
like_term = f"%{search}%"
|
||||
params.extend([like_term, like_term])
|
||||
|
||||
query_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT p.id_part, p.oem_part_number,
|
||||
COALESCE(NULLIF(p.name_es, ''), p.name_part) as name,
|
||||
pg.id_part_group, pg.name_part_group,
|
||||
pc.id_part_category, pc.name_part_category
|
||||
FROM part_vehicle_preview pvp
|
||||
JOIN parts p ON p.id_part = pvp.part_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE pvp.name_brand = %s
|
||||
{cat_filter}
|
||||
{search_filter}
|
||||
ORDER BY p.id_part
|
||||
LIMIT %s OFFSET %s
|
||||
""", query_params + [limit, offset])
|
||||
|
||||
part_rows = cur.fetchall()
|
||||
part_ids = [r[0] for r in part_rows]
|
||||
|
||||
count_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT p.id_part)
|
||||
FROM part_vehicle_preview pvp
|
||||
JOIN parts p ON p.id_part = pvp.part_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE pvp.name_brand = %s
|
||||
{cat_filter}
|
||||
{search_filter}
|
||||
""", count_params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
local_stock = {}
|
||||
if tenant and part_ids:
|
||||
try:
|
||||
from services.catalog_service import _get_local_stock_bulk
|
||||
local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
items = []
|
||||
for r in part_rows:
|
||||
part_id = r[0]
|
||||
stock_info = local_stock.get(part_id, {})
|
||||
items.append({
|
||||
'id': part_id,
|
||||
'oem_part_number': r[1],
|
||||
'name': r[2],
|
||||
'group': {'id': r[3], 'name': r[4]},
|
||||
'category': {'id': r[5], 'name': r[6]},
|
||||
'local_stock': stock_info.get('stock', 0),
|
||||
'local_price': stock_info.get('price', None),
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'brand': brand,
|
||||
'category_id': category_id,
|
||||
'search': search,
|
||||
'items': items,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': []
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
return _with_conns(_query)
|
||||
|
||||
|
||||
@catalog_bp.route('/mye-parts', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def mye_parts():
|
||||
"""Return parts for a specific MYE + category (brand-catalog flow).
|
||||
|
||||
Skips the group/subgroup level and goes directly from category to parts.
|
||||
"""
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
category_id = request.args.get('category_id', type=int)
|
||||
search = request.args.get('search', '').strip()
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
offset = request.args.get('offset', 0, type=int)
|
||||
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id required'}), 400
|
||||
|
||||
def _query(master, tenant, branch_id):
|
||||
cur = master.cursor()
|
||||
try:
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
|
||||
cat_filter = ""
|
||||
search_filter = ""
|
||||
params = [mye_id]
|
||||
|
||||
if category_id:
|
||||
cat_filter = "AND pc.id_part_category = %s"
|
||||
params.append(category_id)
|
||||
|
||||
# --- Brand-filtered mode: return aftermarket parts directly ---
|
||||
if allowed_brands:
|
||||
am_search = ""
|
||||
am_params = list(params)
|
||||
if search:
|
||||
am_search = "AND (ap.part_number ILIKE %s OR COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) ILIKE %s)"
|
||||
like_term = f"%{search}%"
|
||||
am_params.extend([like_term, like_term])
|
||||
|
||||
# Get aftermarket parts
|
||||
query_params = list(am_params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT ap.id_aftermarket_parts,
|
||||
ap.part_number,
|
||||
COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) as name,
|
||||
m.name_manufacture,
|
||||
ap.price_usd,
|
||||
p.id_part,
|
||||
pg.id_part_group, pg.name_part_group,
|
||||
pc.id_part_category, pc.name_part_category
|
||||
FROM vehicle_parts vp
|
||||
JOIN parts p ON p.id_part = vp.part_id
|
||||
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE vp.model_year_engine_id = %s
|
||||
{cat_filter}
|
||||
{am_search}
|
||||
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
ORDER BY m.name_manufacture, ap.part_number
|
||||
LIMIT %s OFFSET %s
|
||||
""", query_params + [allowed_brands, limit, offset])
|
||||
|
||||
part_rows = cur.fetchall()
|
||||
oem_ids = [r[5] for r in part_rows]
|
||||
|
||||
# Count total
|
||||
count_params = list(am_params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT ap.id_aftermarket_parts)
|
||||
FROM vehicle_parts vp
|
||||
JOIN parts p ON p.id_part = vp.part_id
|
||||
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE vp.model_year_engine_id = %s
|
||||
{cat_filter}
|
||||
{am_search}
|
||||
AND UPPER(m.name_manufacture) = ANY(%s)
|
||||
""", count_params + [allowed_brands])
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Local stock keyed by OEM part id
|
||||
local_stock = {}
|
||||
if tenant and oem_ids:
|
||||
try:
|
||||
from services.catalog_service import _get_local_stock_bulk
|
||||
local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
items = []
|
||||
for r in part_rows:
|
||||
oem_id = r[5]
|
||||
stock_info = local_stock.get(oem_id, {})
|
||||
items.append({
|
||||
'id': r[0],
|
||||
'oem_part_number': r[1],
|
||||
'name': r[2],
|
||||
'manufacturer': r[3],
|
||||
'price_usd': float(r[4]) if r[4] is not None else None,
|
||||
'oem_id': oem_id,
|
||||
'group': {'id': r[6], 'name': r[7]},
|
||||
'category': {'id': r[8], 'name': r[9]},
|
||||
'local_stock': stock_info.get('stock', 0),
|
||||
'local_price': stock_info.get('price', None),
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'mye_id': mye_id,
|
||||
'category_id': category_id,
|
||||
'search': search,
|
||||
'items': items,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': allowed_brands
|
||||
})
|
||||
|
||||
# --- Normal mode: return OEM parts ---
|
||||
if search:
|
||||
search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)"
|
||||
like_term = f"%{search}%"
|
||||
params.extend([like_term, like_term])
|
||||
|
||||
query_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT p.id_part, p.oem_part_number,
|
||||
COALESCE(NULLIF(p.name_es, ''), p.name_part) as name,
|
||||
pg.id_part_group, pg.name_part_group,
|
||||
pc.id_part_category, pc.name_part_category
|
||||
FROM vehicle_parts vp
|
||||
JOIN parts p ON p.id_part = vp.part_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE vp.model_year_engine_id = %s
|
||||
{cat_filter}
|
||||
{search_filter}
|
||||
ORDER BY p.id_part
|
||||
LIMIT %s OFFSET %s
|
||||
""", query_params + [limit, offset])
|
||||
|
||||
part_rows = cur.fetchall()
|
||||
part_ids = [r[0] for r in part_rows]
|
||||
|
||||
count_params = list(params)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT p.id_part)
|
||||
FROM vehicle_parts vp
|
||||
JOIN parts p ON p.id_part = vp.part_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
JOIN part_categories pc ON pc.id_part_category = pg.category_id
|
||||
WHERE vp.model_year_engine_id = %s
|
||||
{cat_filter}
|
||||
{search_filter}
|
||||
""", count_params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
local_stock = {}
|
||||
if tenant and part_ids:
|
||||
try:
|
||||
from services.catalog_service import _get_local_stock_bulk
|
||||
local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
items = []
|
||||
for r in part_rows:
|
||||
part_id = r[0]
|
||||
stock_info = local_stock.get(part_id, {})
|
||||
items.append({
|
||||
'id': part_id,
|
||||
'oem_part_number': r[1],
|
||||
'name': r[2],
|
||||
'group': {'id': r[3], 'name': r[4]},
|
||||
'category': {'id': r[5], 'name': r[6]},
|
||||
'local_stock': stock_info.get('stock', 0),
|
||||
'local_price': stock_info.get('price', None),
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'mye_id': mye_id,
|
||||
'category_id': category_id,
|
||||
'search': search,
|
||||
'items': items,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'allowed_brands': []
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
return _with_conns(_query)
|
||||
|
||||
@@ -13,15 +13,51 @@ config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
|
||||
def list_branches():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, name, address, phone, is_active FROM branches ORDER BY id")
|
||||
cur.execute("""
|
||||
SELECT id, name, address, phone, is_active, is_main,
|
||||
rfc, razon_social, regimen_fiscal, cp,
|
||||
direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email
|
||||
FROM branches ORDER BY id
|
||||
""")
|
||||
branches = []
|
||||
for r in cur.fetchall():
|
||||
branches.append({'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3], 'is_active': r[4]})
|
||||
branches.append({
|
||||
'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3],
|
||||
'is_active': r[4], 'is_main': r[5],
|
||||
'rfc': r[6], 'razon_social': r[7], 'regimen_fiscal': r[8],
|
||||
'cp': r[9], 'direccion_fiscal': r[10], 'serie_cfdi': r[11],
|
||||
'folio_inicio': r[12], 'folio_actual': r[13], 'email': r[14],
|
||||
})
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'data': branches})
|
||||
|
||||
|
||||
@config_bp.route('/branches/<int:branch_id>', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
def get_branch(branch_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, name, address, phone, is_active, is_main,
|
||||
rfc, razon_social, regimen_fiscal, cp,
|
||||
direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email
|
||||
FROM branches WHERE id = %s
|
||||
""", (branch_id,))
|
||||
r = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if not r:
|
||||
return jsonify({'error': 'Branch not found'}), 404
|
||||
return jsonify({
|
||||
'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3],
|
||||
'is_active': r[4], 'is_main': r[5],
|
||||
'rfc': r[6], 'razon_social': r[7], 'regimen_fiscal': r[8],
|
||||
'cp': r[9], 'direccion_fiscal': r[10], 'serie_cfdi': r[11],
|
||||
'folio_inicio': r[12], 'folio_actual': r[13], 'email': r[14],
|
||||
})
|
||||
|
||||
|
||||
@config_bp.route('/branches', methods=['POST'])
|
||||
@require_auth('config.edit')
|
||||
def create_branch():
|
||||
@@ -47,10 +83,23 @@ def create_branch():
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
# If setting as main, clear any existing main
|
||||
if data.get('is_main'):
|
||||
cur.execute("UPDATE branches SET is_main = false WHERE is_main = true")
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO branches (name, address, phone)
|
||||
VALUES (%s, %s, %s) RETURNING id
|
||||
""", (data['name'], data.get('address'), data.get('phone')))
|
||||
INSERT INTO branches (
|
||||
name, address, phone, is_main,
|
||||
rfc, razon_social, regimen_fiscal, cp,
|
||||
direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id
|
||||
""", (
|
||||
data['name'], data.get('address'), data.get('phone'), bool(data.get('is_main')),
|
||||
data.get('rfc'), data.get('razon_social'), data.get('regimen_fiscal'), data.get('cp'),
|
||||
data.get('direccion_fiscal'), data.get('serie_cfdi'), data.get('folio_inicio'), data.get('folio_actual'), data.get('email'),
|
||||
))
|
||||
branch_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
@@ -58,6 +107,49 @@ def create_branch():
|
||||
return jsonify({'id': branch_id, 'message': 'Branch created'}), 201
|
||||
|
||||
|
||||
@config_bp.route('/branches/<int:branch_id>', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_branch(branch_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT id FROM branches WHERE id = %s", (branch_id,))
|
||||
if not cur.fetchone():
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Branch not found'}), 404
|
||||
|
||||
# If setting as main, clear any existing main
|
||||
if data.get('is_main'):
|
||||
cur.execute("UPDATE branches SET is_main = false WHERE is_main = true AND id <> %s", (branch_id,))
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
field_map = {
|
||||
'name': 'name', 'address': 'address', 'phone': 'phone',
|
||||
'is_active': 'is_active', 'is_main': 'is_main',
|
||||
'rfc': 'rfc', 'razon_social': 'razon_social',
|
||||
'regimen_fiscal': 'regimen_fiscal', 'cp': 'cp',
|
||||
'direccion_fiscal': 'direccion_fiscal', 'serie_cfdi': 'serie_cfdi',
|
||||
'folio_inicio': 'folio_inicio', 'folio_actual': 'folio_actual', 'email': 'email',
|
||||
}
|
||||
for json_key, col in field_map.items():
|
||||
if json_key in data:
|
||||
updates.append(f"{col} = %s")
|
||||
params.append(data[json_key])
|
||||
|
||||
if not updates:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Nothing to update'}), 400
|
||||
|
||||
params.append(branch_id)
|
||||
cur.execute(f"UPDATE branches SET {', '.join(updates)} WHERE id = %s", params)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'message': 'Branch updated'})
|
||||
|
||||
|
||||
@config_bp.route('/employees', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
def list_employees():
|
||||
@@ -409,3 +501,247 @@ def upgrade_billing():
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ─── Vehicle Compatibility Source ────────────────────
|
||||
|
||||
@config_bp.route('/vehicle-compat-source', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_vehicle_compat_source():
|
||||
"""Get the configured vehicle compatibility source.
|
||||
|
||||
Returns: {'source': 'tecdoc' | 'qwen' | 'both'}
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'vehicle_compat_source'")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
source = row[0] if row else 'both'
|
||||
if source not in ('tecdoc', 'qwen', 'both'):
|
||||
source = 'both'
|
||||
return jsonify({'source': source})
|
||||
|
||||
|
||||
@config_bp.route('/vehicle-compat-source', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_vehicle_compat_source():
|
||||
"""Set the vehicle compatibility source."""
|
||||
data = request.get_json() or {}
|
||||
source = data.get('source', 'both')
|
||||
if source not in ('tecdoc', 'qwen', 'both'):
|
||||
return jsonify({'error': 'source must be tecdoc, qwen, or both'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES ('vehicle_compat_source', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (source,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Vehicle compatibility source updated', 'source': source})
|
||||
|
||||
|
||||
# ─── Allowed Part Brands ─────────────────────────────────────────────────────
|
||||
|
||||
# Whitelist of part manufacturers shown in the allowed-brands selector
|
||||
_ALLOWED_PART_BRANDS = [
|
||||
'Luk', 'Motocraft', 'Euzcadi', 'Gates', 'Injetech', 'Bilstein',
|
||||
'Monroe', 'Yokomitzu', 'Ecom', 'Lth', 'Dynamik', 'Wagner',
|
||||
'Bosch', 'Brembo', 'Champion', 'Dorman', 'Kyb', 'Handkook',
|
||||
'Tomco', 'Mann Filter', 'Total Parts', 'Kanadian', 'Pirelli',
|
||||
'NGK', 'Moresa', 'Fritec', 'Acdelco', 'Dash4', 'Moog', 'SYD',
|
||||
'FRAM', 'AUTOLITE'
|
||||
]
|
||||
|
||||
|
||||
@config_bp.route('/available-brands', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_available_brands():
|
||||
"""Return the whitelisted part manufacturer names.
|
||||
|
||||
The master DB manufacturers/aftermarket_parts tables were removed with
|
||||
TecDoc, so we return the curated whitelist directly.
|
||||
"""
|
||||
brands = sorted({b.strip() for b in _ALLOWED_PART_BRANDS if b and b.strip()})
|
||||
return jsonify({'brands': brands})
|
||||
|
||||
|
||||
@config_bp.route('/allowed-brands', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_allowed_brands():
|
||||
"""Return the tenant's allowed part brands from tenant_config."""
|
||||
import json
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'allowed_part_brands'")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if row and row[0]:
|
||||
try:
|
||||
brands = json.loads(row[0])
|
||||
if isinstance(brands, list):
|
||||
return jsonify({'brands': brands})
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
return jsonify({'brands': []})
|
||||
|
||||
|
||||
@config_bp.route('/allowed-brands', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_allowed_brands():
|
||||
"""Save the tenant's allowed part brands to tenant_config."""
|
||||
import json
|
||||
data = request.get_json() or {}
|
||||
brands = data.get('brands', [])
|
||||
if not isinstance(brands, list):
|
||||
return jsonify({'error': 'brands must be an array'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES ('allowed_part_brands', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (json.dumps(brands),))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Allowed brands updated', 'brands': brands})
|
||||
|
||||
|
||||
# ─── WhatsApp Configuration ────────────────────────────────────────────────
|
||||
|
||||
@config_bp.route('/whatsapp', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
def get_whatsapp_config():
|
||||
"""Get WhatsApp bridge configuration for this tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'")
|
||||
rows = {row[0]: row[1] for row in cur.fetchall()}
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'bridge_url': rows.get('whatsapp_bridge_url', ''),
|
||||
'bridge_key': rows.get('whatsapp_bridge_key', ''),
|
||||
'enabled': rows.get('whatsapp_enabled', 'false').lower() == 'true',
|
||||
'phone_number': rows.get('whatsapp_phone_number', ''),
|
||||
})
|
||||
|
||||
|
||||
@config_bp.route('/whatsapp', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_whatsapp_config():
|
||||
"""Update WhatsApp bridge configuration for this tenant."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
settings = {
|
||||
'whatsapp_bridge_url': data.get('bridge_url', ''),
|
||||
'whatsapp_bridge_key': data.get('bridge_key', ''),
|
||||
'whatsapp_enabled': 'true' if data.get('enabled') else 'false',
|
||||
'whatsapp_phone_number': data.get('phone_number', ''),
|
||||
}
|
||||
|
||||
for key, value in settings.items():
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (key, value))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({'message': 'WhatsApp configuration updated'})
|
||||
|
||||
|
||||
@config_bp.route('/modules', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
def get_modules():
|
||||
"""Get enabled modules for this tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%'")
|
||||
rows = {row[0]: row[1] for row in cur.fetchall()}
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def _bool(key):
|
||||
return rows.get(key, 'true').lower() == 'true'
|
||||
|
||||
return jsonify({
|
||||
'whatsapp': _bool('module_whatsapp'),
|
||||
'marketplace': _bool('module_marketplace'),
|
||||
'meli': _bool('module_meli'),
|
||||
'catalog': _bool('module_catalog'),
|
||||
})
|
||||
|
||||
|
||||
@config_bp.route('/modules', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_modules():
|
||||
"""Update enabled modules for this tenant."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
settings = {
|
||||
'module_whatsapp': 'true' if data.get('whatsapp') else 'false',
|
||||
'module_marketplace': 'true' if data.get('marketplace') else 'false',
|
||||
'module_meli': 'true' if data.get('meli') else 'false',
|
||||
'module_catalog': 'true' if data.get('catalog') else 'false',
|
||||
}
|
||||
|
||||
for key, value in settings.items():
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (key, value))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({'message': 'Modules updated', 'modules': {
|
||||
'whatsapp': data.get('whatsapp'),
|
||||
'marketplace': data.get('marketplace'),
|
||||
'meli': data.get('meli'),
|
||||
}})
|
||||
|
||||
|
||||
@config_bp.route('/onboarding-status', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def get_onboarding_status():
|
||||
"""Check if tenant onboarding wizard has been completed."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'onboarding_completed'")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'completed': row[0] == 'true' if row else False})
|
||||
|
||||
|
||||
@config_bp.route('/onboarding-status', methods=['POST'])
|
||||
@require_auth('pos.view')
|
||||
def set_onboarding_status():
|
||||
"""Mark tenant onboarding wizard as completed."""
|
||||
data = request.get_json() or {}
|
||||
completed = 'true' if data.get('completed') else 'false'
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", ('onboarding_completed', completed))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'completed': completed == 'true'})
|
||||
|
||||
@@ -52,6 +52,7 @@ def list_customers():
|
||||
# Fetch
|
||||
cur.execute(f"""
|
||||
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
|
||||
c.address, c.cp,
|
||||
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
|
||||
c.branch_id
|
||||
FROM customers c
|
||||
@@ -64,11 +65,12 @@ def list_customers():
|
||||
for r in cur.fetchall():
|
||||
customers.append({
|
||||
'id': r[0], 'name': r[1], 'rfc': r[2], 'razon_social': r[3],
|
||||
'phone': r[4], 'email': r[5], 'price_tier': r[6],
|
||||
'credit_limit': float(r[7]) if r[7] else 0,
|
||||
'credit_balance': float(r[8]) if r[8] else 0,
|
||||
'vehicle_info': r[9],
|
||||
'branch_id': r[10],
|
||||
'phone': r[4], 'email': r[5], 'address': r[6], 'cp': r[7],
|
||||
'price_tier': r[8],
|
||||
'credit_limit': float(r[9]) if r[9] else 0,
|
||||
'credit_balance': float(r[10]) if r[10] else 0,
|
||||
'vehicle_info': r[11],
|
||||
'branch_id': r[12],
|
||||
})
|
||||
|
||||
cur.close()
|
||||
@@ -91,7 +93,7 @@ def get_customer(customer_id):
|
||||
cur.execute("""
|
||||
SELECT id, branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
|
||||
cp, email, phone, address, price_tier, credit_limit, credit_balance,
|
||||
is_active, vehicle_info, created_at
|
||||
is_active, vehicle_info, created_at, max_discount_pct
|
||||
FROM customers WHERE id = %s
|
||||
""", (customer_id,))
|
||||
row = cur.fetchone()
|
||||
@@ -103,7 +105,7 @@ def get_customer(customer_id):
|
||||
customer = dict(zip(cols, row))
|
||||
|
||||
# Convert Decimal to float
|
||||
for k in ('credit_limit', 'credit_balance'):
|
||||
for k in ('credit_limit', 'credit_balance', 'max_discount_pct'):
|
||||
if customer.get(k) is not None:
|
||||
customer[k] = float(customer[k])
|
||||
|
||||
@@ -213,7 +215,7 @@ def update_customer(customer_id):
|
||||
# Build dynamic update
|
||||
allowed = ['name', 'rfc', 'razon_social', 'regimen_fiscal', 'uso_cfdi',
|
||||
'cp', 'email', 'phone', 'address', 'price_tier', 'credit_limit',
|
||||
'vehicle_info', 'is_active', 'branch_id']
|
||||
'max_discount_pct', 'vehicle_info', 'is_active', 'branch_id']
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
|
||||
120
pos/blueprints/dashboard_stats_bp.py
Normal file
120
pos/blueprints/dashboard_stats_bp.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Dashboard Stats Blueprint — In-app real-time analytics.
|
||||
|
||||
Endpoints for sales, productivity, and top products charts.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import json
|
||||
|
||||
dashboard_stats_bp = Blueprint('dashboard_stats', __name__, url_prefix='/pos/api/dashboard')
|
||||
|
||||
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
|
||||
class DecimalEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
if isinstance(o, Decimal):
|
||||
return float(o)
|
||||
return super().default(o)
|
||||
|
||||
|
||||
@dashboard_stats_bp.route('/stats', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_stats():
|
||||
"""Summary stats for today and this month."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
today = datetime.utcnow().date()
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
try:
|
||||
# Sales today
|
||||
cur.execute(
|
||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) = %s""", (today,)
|
||||
)
|
||||
today_sales = cur.fetchone()
|
||||
|
||||
# Sales this month
|
||||
cur.execute(
|
||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
|
||||
)
|
||||
month_sales = cur.fetchone()
|
||||
|
||||
# Top 5 products today
|
||||
cur.execute(
|
||||
"""SELECT si.name, SUM(si.quantity) as qty, SUM(si.subtotal) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id
|
||||
WHERE DATE(s.created_at) = %s
|
||||
GROUP BY si.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 5""", (today,)
|
||||
)
|
||||
top_products = cur.fetchall()
|
||||
|
||||
# Hourly sales today (0-23)
|
||||
cur.execute(
|
||||
"""SELECT EXTRACT(HOUR FROM created_at)::int as hour,
|
||||
COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) = %s
|
||||
GROUP BY hour ORDER BY hour""", (today,)
|
||||
)
|
||||
hourly = cur.fetchall()
|
||||
hourly_map = {row[0]: {'count': row[1], 'total': row[2]} for row in hourly}
|
||||
|
||||
return jsonify({
|
||||
'today': {
|
||||
'sales_count': today_sales[0],
|
||||
'sales_total': float(today_sales[1]) if today_sales[1] is not None else 0,
|
||||
},
|
||||
'month': {
|
||||
'sales_count': month_sales[0],
|
||||
'sales_total': float(month_sales[1]) if month_sales[1] is not None else 0,
|
||||
},
|
||||
'top_products': [
|
||||
{'name': row[0], 'quantity': row[1], 'revenue': float(row[2]) if row[2] is not None else 0}
|
||||
for row in top_products
|
||||
],
|
||||
'hourly_sales': [
|
||||
{'hour': h, 'count': hourly_map.get(h, {}).get('count', 0),
|
||||
'total': float(hourly_map.get(h, {}).get('total', 0))}
|
||||
for h in range(24)
|
||||
],
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@dashboard_stats_bp.route('/stats/employees', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_employee_stats():
|
||||
"""Sales per employee today."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
today = datetime.utcnow().date()
|
||||
try:
|
||||
cur.execute(
|
||||
"""SELECT e.name, COUNT(s.id) as sales, COALESCE(SUM(s.total), 0) as total
|
||||
FROM sales s
|
||||
JOIN employees e ON s.employee_id = e.id
|
||||
WHERE DATE(s.created_at) = %s
|
||||
GROUP BY e.name
|
||||
ORDER BY total DESC""", (today,)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return jsonify({
|
||||
'employees': [
|
||||
{'name': row[0], 'sales': row[1], 'total': float(row[2]) if row[2] is not None else 0}
|
||||
for row in rows
|
||||
]
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
128
pos/blueprints/dropshipping_bp.py
Normal file
128
pos/blueprints/dropshipping_bp.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Dropshipping API — public read-only inventory endpoints.
|
||||
|
||||
Authentication: X-Dropshipping-Key header (per-tenant).
|
||||
Optional: X-Tenant-Subdomain for faster resolution.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from services import dropshipping_service as ds_svc
|
||||
from services.webhook_service import dispatch_webhooks_bulk
|
||||
|
||||
dropship_bp = Blueprint("dropship", __name__, url_prefix="/pos/api/dropship")
|
||||
|
||||
|
||||
def _resolve_tenant_by_key(api_key: str, subdomain_hint: str = None):
|
||||
"""Return (tenant_conn, tenant_id) for a valid dropshipping API key.
|
||||
|
||||
If subdomain_hint is provided, validate only that tenant.
|
||||
Otherwise scan active tenants (acceptable for small tenant count).
|
||||
"""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
cur = master.cursor()
|
||||
if subdomain_hint:
|
||||
cur.execute(
|
||||
"SELECT id, db_name FROM tenants WHERE subdomain = %s AND is_active = true",
|
||||
(subdomain_hint,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
else:
|
||||
cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true")
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
for tid, db_name in rows:
|
||||
try:
|
||||
tconn = get_tenant_conn(tid)
|
||||
if ds_svc.validate_api_key(tconn, api_key):
|
||||
return tconn, tid
|
||||
tconn.close()
|
||||
except Exception:
|
||||
continue
|
||||
return None, None
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
def _require_dropship_auth():
|
||||
key = request.headers.get("X-Dropshipping-Key")
|
||||
subdomain = request.headers.get("X-Tenant-Subdomain")
|
||||
if not key:
|
||||
return jsonify({"error": "Missing X-Dropshipping-Key header"}), 401
|
||||
tconn, tid = _resolve_tenant_by_key(key, subdomain_hint=subdomain)
|
||||
if not tconn:
|
||||
return jsonify({"error": "Invalid API key or tenant inactive"}), 401
|
||||
g.tenant_id = tid
|
||||
g.tenant_conn = tconn
|
||||
return None
|
||||
|
||||
|
||||
def _release_tenant():
|
||||
if hasattr(g, "tenant_conn") and g.tenant_conn:
|
||||
g.tenant_conn.close()
|
||||
|
||||
|
||||
@dropship_bp.route("/inventory", methods=["GET"])
|
||||
def list_inventory():
|
||||
err = _require_dropship_auth()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
search = request.args.get("q")
|
||||
result = ds_svc.get_inventory_list(g.tenant_conn, search=search, page=page, per_page=per_page)
|
||||
return jsonify(result)
|
||||
finally:
|
||||
_release_tenant()
|
||||
|
||||
|
||||
@dropship_bp.route("/inventory/<sku>", methods=["GET"])
|
||||
def get_inventory_item(sku):
|
||||
err = _require_dropship_auth()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
item = ds_svc.get_inventory_by_sku(g.tenant_conn, sku)
|
||||
if not item:
|
||||
return jsonify({"error": "SKU not found"}), 404
|
||||
return jsonify(item)
|
||||
finally:
|
||||
_release_tenant()
|
||||
|
||||
|
||||
@dropship_bp.route("/stock", methods=["GET"])
|
||||
def get_stock():
|
||||
err = _require_dropship_auth()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
skus = request.args.get("skus", "")
|
||||
sku_list = [s.strip() for s in skus.split(",") if s.strip()]
|
||||
if not sku_list:
|
||||
return jsonify({"error": "Provide ?skus=SKU1,SKU2,SKU3"}), 400
|
||||
result = ds_svc.get_stock_by_skus(g.tenant_conn, sku_list)
|
||||
return jsonify({"stock": result})
|
||||
finally:
|
||||
_release_tenant()
|
||||
|
||||
|
||||
@dropship_bp.route("/webhooks/test", methods=["POST"])
|
||||
def test_webhook():
|
||||
"""Test endpoint to trigger a sample webhook to all configured targets."""
|
||||
err = _require_dropship_auth()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
urls = ds_svc.get_webhook_targets(g.tenant_conn, "stock_updated")
|
||||
if not urls:
|
||||
return jsonify({"error": "No webhook targets configured"}), 400
|
||||
results = dispatch_webhooks_bulk(
|
||||
urls,
|
||||
"test",
|
||||
{"message": "Webhook test from Nexus POS", "tenant_id": g.tenant_id},
|
||||
)
|
||||
return jsonify({"dispatched": len(results), "results": results})
|
||||
finally:
|
||||
_release_tenant()
|
||||
79
pos/blueprints/erp_bp.py
Normal file
79
pos/blueprints/erp_bp.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""ERP Sync Blueprint — Integration with Aspel, CONTPAQi, SAP, Odoo.
|
||||
|
||||
Stubs with architecture ready for real connectors.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from functools import wraps
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
erp_bp = Blueprint('erp', __name__, url_prefix='/pos/api/erp')
|
||||
|
||||
|
||||
from middleware import require_auth
|
||||
|
||||
|
||||
# ─── Mock sync jobs ───
|
||||
_mock_jobs = {}
|
||||
|
||||
|
||||
@erp_bp.route('/providers', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_providers():
|
||||
return jsonify({
|
||||
'providers': [
|
||||
{'id': 'aspel_sae', 'name': 'Aspel SAE', 'type': 'file_exchange', 'enabled': False},
|
||||
{'id': 'contpaqi', 'name': 'CONTPAQi', 'type': 'file_exchange', 'enabled': False},
|
||||
{'id': 'sap_b1', 'name': 'SAP Business One', 'type': 'api', 'enabled': False},
|
||||
{'id': 'odoo', 'name': 'Odoo', 'type': 'api', 'enabled': False},
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@erp_bp.route('/sync', methods=['POST'])
|
||||
@require_auth()
|
||||
def start_sync():
|
||||
data = request.get_json() or {}
|
||||
provider = data.get('provider')
|
||||
sync_type = data.get('sync_type', 'sales') # sales, inventory, customers
|
||||
if not provider:
|
||||
return jsonify({'error': 'provider is required'}), 400
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
_mock_jobs[job_id] = {
|
||||
'id': job_id,
|
||||
'provider': provider,
|
||||
'sync_type': sync_type,
|
||||
'status': 'queued',
|
||||
'records_synced': 0,
|
||||
'errors': [],
|
||||
'created_at': datetime.utcnow().isoformat(),
|
||||
'started_at': None,
|
||||
'finished_at': None,
|
||||
}
|
||||
return jsonify(_mock_jobs[job_id]), 201
|
||||
|
||||
|
||||
@erp_bp.route('/sync/<job_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_sync_status(job_id):
|
||||
job = _mock_jobs.get(job_id)
|
||||
if not job:
|
||||
return jsonify({'error': 'Job not found'}), 404
|
||||
return jsonify(job)
|
||||
|
||||
|
||||
@erp_bp.route('/sync/<job_id>/run', methods=['POST'])
|
||||
@require_auth()
|
||||
def run_sync(job_id):
|
||||
"""Mock execute sync (in production this triggers a Celery task)."""
|
||||
job = _mock_jobs.get(job_id)
|
||||
if not job:
|
||||
return jsonify({'error': 'Job not found'}), 404
|
||||
job['status'] = 'running'
|
||||
job['started_at'] = datetime.utcnow().isoformat()
|
||||
# Mock completion
|
||||
job['status'] = 'completed'
|
||||
job['records_synced'] = 42
|
||||
job['finished_at'] = datetime.utcnow().isoformat()
|
||||
return jsonify(job)
|
||||
152
pos/blueprints/internal_bp.py
Normal file
152
pos/blueprints/internal_bp.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Internal API endpoints for infrastructure orchestration.
|
||||
|
||||
These endpoints are meant to be called by the Nexus Manager or other
|
||||
internal services. They require INTERNAL_API_KEY.
|
||||
"""
|
||||
import subprocess
|
||||
import socket
|
||||
from flask import Blueprint, request, jsonify
|
||||
from config import INTERNAL_API_KEY
|
||||
from tenant_db import get_master_conn, get_tenant_conn
|
||||
|
||||
internal_bp = Blueprint('internal', __name__, url_prefix='/pos/api/internal')
|
||||
|
||||
|
||||
def _check_internal_key():
|
||||
key = request.headers.get('X-Internal-Key', '')
|
||||
if not INTERNAL_API_KEY:
|
||||
return jsonify({'error': 'INTERNAL_API_KEY not configured on server'}), 500
|
||||
if key != INTERNAL_API_KEY:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
return None
|
||||
|
||||
|
||||
def _find_free_port(start=21465, end=21565):
|
||||
"""Find first free TCP port in range."""
|
||||
for port in range(start, end + 1):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
if s.connect_ex(('127.0.0.1', port)) != 0:
|
||||
return port
|
||||
return None
|
||||
|
||||
|
||||
@internal_bp.route('/whatsapp-bridge', methods=['POST'])
|
||||
def provision_whatsapp_bridge():
|
||||
"""Provision a new WhatsApp Bridge Docker container for a tenant."""
|
||||
auth_error = _check_internal_key()
|
||||
if auth_error:
|
||||
return auth_error
|
||||
|
||||
data = request.get_json() or {}
|
||||
tenant_id = data.get('tenant_id')
|
||||
subdomain = data.get('subdomain', f'tenant-{tenant_id}')
|
||||
|
||||
if not tenant_id:
|
||||
return jsonify({'error': 'tenant_id required'}), 400
|
||||
|
||||
# Check if container already exists
|
||||
container_name = f"wpp-{subdomain}"
|
||||
check = subprocess.run(
|
||||
['docker', 'ps', '-a', '-q', '-f', f'name={container_name}'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if check.stdout.strip():
|
||||
return jsonify({'error': f'Container {container_name} already exists'}), 409
|
||||
|
||||
# Find free port
|
||||
port = _find_free_port()
|
||||
if not port:
|
||||
return jsonify({'error': 'No free ports available in range 21465-21565'}), 503
|
||||
|
||||
# Build image if not exists
|
||||
image_check = subprocess.run(
|
||||
['docker', 'images', '-q', 'nexus-whatsapp-bridge'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if not image_check.stdout.strip():
|
||||
build = subprocess.run(
|
||||
['docker', 'build', '-f', '/home/Autopartes/pos/Dockerfile.whatsapp-bridge',
|
||||
'-t', 'nexus-whatsapp-bridge', '/home/Autopartes/pos'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if build.returncode != 0:
|
||||
return jsonify({'error': 'Failed to build bridge image', 'details': build.stderr}), 500
|
||||
|
||||
# Run container
|
||||
bridge_url = f"http://127.0.0.1:{port}"
|
||||
run = subprocess.run([
|
||||
'docker', 'run', '-d',
|
||||
'--name', container_name,
|
||||
'--restart', 'unless-stopped',
|
||||
'-p', f'{port}:21465',
|
||||
'-e', f'PORT=21465',
|
||||
'-e', f'TENANT_ID={tenant_id}',
|
||||
'-e', f'WEBHOOK_BASE=http://127.0.0.1:5001/pos/api/whatsapp/webhook',
|
||||
'-e', f'API_KEY=nexus-wpp-secret-2026',
|
||||
'-e', f'LOG_LEVEL=info',
|
||||
'-v', f'wpp-{subdomain}:/app/auth',
|
||||
'nexus-whatsapp-bridge'
|
||||
], capture_output=True, text=True)
|
||||
|
||||
if run.returncode != 0:
|
||||
return jsonify({'error': 'Failed to start container', 'details': run.stderr}), 500
|
||||
|
||||
container_id = run.stdout.strip()
|
||||
|
||||
# Save config to tenant_config
|
||||
conn = get_tenant_conn_by_dbname(data.get('db_name'))
|
||||
if not conn:
|
||||
# Fallback: get db_name from master
|
||||
mconn = get_master_conn()
|
||||
mcur = mconn.cursor()
|
||||
mcur.execute("SELECT db_name FROM tenants WHERE id = %s", (tenant_id,))
|
||||
row = mcur.fetchone()
|
||||
mcur.close()
|
||||
mconn.close()
|
||||
if row:
|
||||
conn = get_tenant_conn_by_dbname(row[0])
|
||||
|
||||
if conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES
|
||||
('whatsapp_bridge_url', %s),
|
||||
('whatsapp_enabled', 'true')
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (bridge_url,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'tenant_id': tenant_id,
|
||||
'container_id': container_id,
|
||||
'container_name': container_name,
|
||||
'port': port,
|
||||
'bridge_url': bridge_url
|
||||
}), 201
|
||||
|
||||
|
||||
@internal_bp.route('/whatsapp-bridge', methods=['DELETE'])
|
||||
def destroy_whatsapp_bridge():
|
||||
"""Destroy a tenant's WhatsApp Bridge container."""
|
||||
auth_error = _check_internal_key()
|
||||
if auth_error:
|
||||
return auth_error
|
||||
|
||||
data = request.get_json() or {}
|
||||
subdomain = data.get('subdomain')
|
||||
if not subdomain:
|
||||
return jsonify({'error': 'subdomain required'}), 400
|
||||
|
||||
container_name = f"wpp-{subdomain}"
|
||||
|
||||
# Stop and remove container
|
||||
subprocess.run(['docker', 'stop', container_name], capture_output=True)
|
||||
subprocess.run(['docker', 'rm', container_name], capture_output=True)
|
||||
|
||||
# Remove volume
|
||||
subprocess.run(['docker', 'volume', 'rm', f'wpp-{subdomain}'], capture_output=True)
|
||||
|
||||
return jsonify({'success': True, 'message': f'Bridge {container_name} destroyed'})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,39 +6,61 @@ This blueprint is the HTTP layer that validates input and returns JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.cfdi_builder import build_ingreso_xml, build_egreso_xml, build_pago_xml
|
||||
from services.cfdi_facturapi_builder import (
|
||||
build_ingreso_payload, build_egreso_payload, build_pago_payload,
|
||||
)
|
||||
from services.cfdi_queue import (
|
||||
enqueue_cfdi, process_queue, retry_failed,
|
||||
cancel_cfdi, get_queue_status,
|
||||
)
|
||||
from services import facturapi_service
|
||||
from services.audit import log_action
|
||||
|
||||
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
|
||||
|
||||
|
||||
def _get_tenant_config(cur):
|
||||
"""Load tenant CFDI configuration from tenant_config table.
|
||||
def _get_issuer_config(cur, branch_id=None):
|
||||
"""Load CFDI issuer configuration.
|
||||
|
||||
Falls back to sensible defaults if config is incomplete.
|
||||
If branch_id is provided and the branch has fiscal data, use it.
|
||||
Otherwise fall back to tenant-level config.
|
||||
"""
|
||||
# Tenant-level defaults
|
||||
config = {}
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
|
||||
for row in cur.fetchall():
|
||||
config[row[0]] = row[1]
|
||||
|
||||
return {
|
||||
result = {
|
||||
'rfc': config.get('tenant_rfc', ''),
|
||||
'razon_social': config.get('tenant_razon_social', ''),
|
||||
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
|
||||
'cp': config.get('tenant_cp', '00000'),
|
||||
'serie': config.get('cfdi_serie', 'A'),
|
||||
'horux_api_url': config.get('cfdi_horux_api_url', ''),
|
||||
'horux_api_key': config.get('cfdi_horux_api_key', ''),
|
||||
'facturapi_key': config.get('cfdi_facturapi_key', ''),
|
||||
'facturapi_org_id': config.get('cfdi_facturapi_org_id', ''),
|
||||
}
|
||||
|
||||
# Branch-level override
|
||||
if branch_id:
|
||||
cur.execute("""
|
||||
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
|
||||
FROM branches WHERE id = %s
|
||||
""", (branch_id,))
|
||||
row = cur.fetchone()
|
||||
if row and row[0]:
|
||||
result['rfc'] = row[0] or result['rfc']
|
||||
result['razon_social'] = row[1] or result['razon_social']
|
||||
result['regimen_fiscal'] = row[2] or result['regimen_fiscal']
|
||||
result['cp'] = row[3] or result['cp']
|
||||
result['serie'] = row[4] or result['serie']
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _get_sale_with_items(cur, sale_id):
|
||||
"""Load a sale with its items for CFDI generation."""
|
||||
@@ -134,14 +156,14 @@ def generate_invoice():
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
tenant_config = _get_tenant_config(cur)
|
||||
if not tenant_config['rfc']:
|
||||
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
|
||||
|
||||
sale = _get_sale_with_items(cur, sale_id)
|
||||
if not sale:
|
||||
return jsonify({'error': 'Sale not found'}), 404
|
||||
|
||||
tenant_config = _get_issuer_config(cur, sale.get('branch_id'))
|
||||
if not tenant_config['rfc']:
|
||||
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
|
||||
|
||||
if sale['status'] == 'cancelled':
|
||||
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400
|
||||
|
||||
@@ -158,19 +180,19 @@ def generate_invoice():
|
||||
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})'
|
||||
}), 409
|
||||
|
||||
# Build XML
|
||||
# Build Facturapi payload
|
||||
if cfdi_type == 'ingreso':
|
||||
xml = build_ingreso_xml(sale, tenant_config, customer)
|
||||
payload = build_ingreso_payload(sale, tenant_config, customer)
|
||||
elif cfdi_type == 'egreso':
|
||||
original_uuid = data.get('original_uuid')
|
||||
if not original_uuid:
|
||||
return jsonify({'error': 'original_uuid required for egreso'}), 400
|
||||
xml = build_egreso_xml(sale, tenant_config, customer, original_uuid)
|
||||
payload = build_egreso_payload(sale, tenant_config, customer, original_uuid)
|
||||
else:
|
||||
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
|
||||
|
||||
# Enqueue
|
||||
result = enqueue_cfdi(conn, sale_id, cfdi_type, xml)
|
||||
result = enqueue_cfdi(conn, sale_id, cfdi_type, payload)
|
||||
|
||||
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'],
|
||||
new_value={'sale_id': sale_id, 'type': cfdi_type,
|
||||
@@ -225,10 +247,10 @@ def get_queue_item(cfdi_id):
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT q.id, q.sale_id, q.type, q.xml_unsigned, q.xml_signed,
|
||||
SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed,
|
||||
q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
|
||||
q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
|
||||
q.created_at, q.stamped_at
|
||||
q.created_at, q.stamped_at, q.external_id
|
||||
FROM cfdi_queue q WHERE q.id = %s
|
||||
""", (cfdi_id,))
|
||||
row = cur.fetchone()
|
||||
@@ -239,13 +261,14 @@ def get_queue_item(cfdi_id):
|
||||
|
||||
item = {
|
||||
'id': row[0], 'sale_id': row[1], 'type': row[2],
|
||||
'xml_unsigned': row[3], 'xml_signed': row[4],
|
||||
'payload_unsigned': row[3], 'xml_signed': row[4],
|
||||
'uuid_fiscal': row[5], 'status': row[6],
|
||||
'retry_count': row[7], 'provisional_folio': row[8],
|
||||
'error_message': row[9], 'cancel_motive': row[10],
|
||||
'cancel_replacement_uuid': row[11],
|
||||
'created_at': str(row[12]) if row[12] else None,
|
||||
'stamped_at': str(row[13]) if row[13] else None,
|
||||
'external_id': row[14],
|
||||
}
|
||||
|
||||
cur.close()
|
||||
@@ -261,20 +284,17 @@ def trigger_process_queue():
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
tenant_config = _get_tenant_config(cur)
|
||||
horux_url = tenant_config.get('horux_api_url')
|
||||
horux_key = tenant_config.get('horux_api_key')
|
||||
|
||||
if not horux_url or not horux_key:
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
if not tenant_config.get('facturapi_key'):
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': 'Horux API not configured'}), 400
|
||||
return jsonify({'error': 'Facturapi key not configured'}), 400
|
||||
|
||||
# Reset eligible failed items first
|
||||
reset_count = retry_failed(conn)
|
||||
|
||||
# Process the queue
|
||||
result = process_queue(conn, horux_url, horux_key)
|
||||
result = process_queue(conn, tenant_config)
|
||||
result['retries_reset'] = reset_count
|
||||
|
||||
cur.close()
|
||||
@@ -316,11 +336,10 @@ def cancel_invoice(cfdi_id):
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
tenant_config = _get_tenant_config(cur)
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
result = cancel_cfdi(
|
||||
conn, cfdi_id, motive, replacement_uuid,
|
||||
tenant_config.get('horux_api_url'),
|
||||
tenant_config.get('horux_api_key'),
|
||||
tenant_config=tenant_config,
|
||||
)
|
||||
|
||||
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id,
|
||||
@@ -362,7 +381,7 @@ def get_sale_pdf(sale_id):
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Sale not found'}), 404
|
||||
|
||||
tenant_config = _get_tenant_config(cur)
|
||||
tenant_config = _get_issuer_config(cur, sale.get('branch_id'))
|
||||
customer = _get_customer(cur, sale.get('customer_id'))
|
||||
|
||||
# Check if there's a stamped CFDI
|
||||
@@ -397,3 +416,249 @@ def get_sale_pdf(sale_id):
|
||||
'customer': customer,
|
||||
'cfdi': cfdi_info,
|
||||
})
|
||||
|
||||
|
||||
@invoicing_bp.route('/stats', methods=['GET'])
|
||||
@require_auth('invoicing.read')
|
||||
def api_invoicing_stats():
|
||||
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE type = 'ingreso' AND status IN ('pending', 'stamped', 'retry')) as facturas,
|
||||
COUNT(*) FILTER (WHERE type = 'egreso' AND status IN ('pending', 'stamped', 'retry')) as notas_credito,
|
||||
COUNT(*) FILTER (WHERE type = 'pago' AND status IN ('pending', 'stamped', 'retry')) as complementos,
|
||||
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelaciones
|
||||
FROM cfdi_queue
|
||||
""")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'facturas': row[0] or 0,
|
||||
'notas_credito': row[1] or 0,
|
||||
'complementos': row[2] or 0,
|
||||
'cancelaciones': row[3] or 0,
|
||||
})
|
||||
|
||||
|
||||
@invoicing_bp.route('/global-invoice', methods=['POST'])
|
||||
@require_auth('invoicing.create')
|
||||
def generate_global_invoice():
|
||||
"""Generate a monthly global invoice for cash sales.
|
||||
|
||||
Body: {
|
||||
year: int (default current year),
|
||||
month: int (default current month),
|
||||
branch_id: int (optional)
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
now = datetime.now()
|
||||
year = data.get('year', now.year)
|
||||
month = data.get('month', now.month)
|
||||
branch_id = data.get('branch_id')
|
||||
|
||||
try:
|
||||
year = int(year)
|
||||
month = int(month)
|
||||
if month < 1 or month > 12:
|
||||
return jsonify({'error': 'month must be 1-12'}), 400
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'year and month must be integers'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
tenant_config = _get_issuer_config(cur, branch_id)
|
||||
if not tenant_config['rfc']:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Tenant RFC not configured'}), 400
|
||||
|
||||
from services.global_invoice import generate_global_invoice
|
||||
result = generate_global_invoice(
|
||||
conn, tenant_config, year, month,
|
||||
branch_id=branch_id,
|
||||
employee_id=getattr(g, 'employee_id', None)
|
||||
)
|
||||
|
||||
if 'error' in result:
|
||||
cur.close(); conn.close()
|
||||
return jsonify(result), 400
|
||||
|
||||
log_action(conn, 'GLOBAL_INVOICE_CREATE', 'cfdi_queue', result['id'],
|
||||
new_value={'year': year, 'month': month, 'sales_count': result['sales_count']})
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify(result), 201
|
||||
|
||||
|
||||
@invoicing_bp.route('/global-invoice/<int:cfdi_id>', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
def get_global_invoice(cfdi_id):
|
||||
"""Get status and linked sales of a global invoice."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
from services.global_invoice import get_global_invoice_status
|
||||
result = get_global_invoice_status(conn, cfdi_id)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if not result:
|
||||
return jsonify({'error': 'Global invoice not found'}), 404
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@invoicing_bp.route('/global-invoice/eligible-sales', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
def get_eligible_sales_for_global():
|
||||
"""Preview sales that would be included in a global invoice.
|
||||
|
||||
Query params: year, month, branch_id
|
||||
"""
|
||||
now = datetime.now()
|
||||
year = request.args.get('year', now.year, type=int)
|
||||
month = request.args.get('month', now.month, type=int)
|
||||
branch_id = request.args.get('branch_id', type=int)
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
from services.global_invoice import get_eligible_sales
|
||||
sales = get_eligible_sales(conn, year, month, branch_id)
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'year': year, 'month': month,
|
||||
'count': len(sales),
|
||||
'total': sum(s['total'] for s in sales),
|
||||
'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} for s in sales],
|
||||
})
|
||||
|
||||
|
||||
# ─── Facturapi extras ───────────────────────────────
|
||||
|
||||
@invoicing_bp.route('/facturapi/status', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
def facturapi_status():
|
||||
"""Return Facturapi organization status for the tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
status = facturapi_service.get_org_status(tenant_config)
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@invoicing_bp.route('/facturapi/setup', methods=['POST'])
|
||||
@require_auth('invoicing.create')
|
||||
def facturapi_setup():
|
||||
"""Create or link a Facturapi organization for this tenant.
|
||||
|
||||
Requires FACTURAPI_USER_KEY environment variable.
|
||||
Stores cfdi_facturapi_org_id and cfdi_facturapi_key in tenant_config.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
if not tenant_config.get('rfc'):
|
||||
return jsonify({'error': 'Tenant RFC not configured'}), 400
|
||||
|
||||
result = facturapi_service.create_organization(tenant_config)
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value)
|
||||
VALUES ('cfdi_facturapi_org_id', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (result['org_id'],))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value)
|
||||
VALUES ('cfdi_facturapi_key', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (result['api_key'],))
|
||||
|
||||
log_action(conn, 'FACTURAPI_SETUP', 'tenant_config', None,
|
||||
new_value={'org_id': result['org_id']})
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'org_id': result['org_id'],
|
||||
'message': 'Facturapi organization created. Complete pending steps in Facturapi dashboard.',
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@invoicing_bp.route('/facturapi/download/<int:cfdi_id>/<doc_type>', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
def facturapi_download(cfdi_id, doc_type):
|
||||
"""Download PDF or XML for a stamped CFDI from Facturapi.
|
||||
|
||||
doc_type: 'pdf' | 'xml'
|
||||
"""
|
||||
if doc_type not in ('pdf', 'xml'):
|
||||
return jsonify({'error': "doc_type must be 'pdf' or 'xml'"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
|
||||
""", (cfdi_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'CFDI not found'}), 404
|
||||
|
||||
external_id, uuid_fiscal, status = row
|
||||
if status != 'stamped' or not external_id:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'CFDI is not stamped or has no external id'}), 400
|
||||
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
if doc_type == 'pdf':
|
||||
content = facturapi_service.download_pdf(tenant_config, external_id)
|
||||
mime = 'application/pdf'
|
||||
filename = f'cfdi_{uuid_fiscal or external_id}.pdf'
|
||||
else:
|
||||
content = facturapi_service.download_xml(tenant_config, external_id)
|
||||
mime = 'application/xml'
|
||||
filename = f'cfdi_{uuid_fiscal or external_id}.xml'
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
from flask import Response
|
||||
return Response(
|
||||
content,
|
||||
mimetype=mime,
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
@@ -190,6 +190,16 @@ def bodegas_with_part(part_id):
|
||||
return _with_master(_do)
|
||||
|
||||
|
||||
@marketplace_bp.route('/inventory/listing/<int:wi_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
def bodegas_with_listing(wi_id):
|
||||
"""Return bodegas stocking a specific seller listing (wi_id)."""
|
||||
def _do(master):
|
||||
data = mkt.get_bodegas_with_listing(master, wi_id)
|
||||
return jsonify({'data': data, 'count': len(data)})
|
||||
return _with_master(_do)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PURCHASE ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
703
pos/blueprints/marketplace_external_bp.py
Normal file
703
pos/blueprints/marketplace_external_bp.py
Normal file
@@ -0,0 +1,703 @@
|
||||
"""MercadoLibre external marketplace REST endpoints.
|
||||
|
||||
Routes:
|
||||
Config
|
||||
GET /pos/api/marketplace-ext/config
|
||||
POST /pos/api/marketplace-ext/connect
|
||||
DELETE /pos/api/marketplace-ext/connect
|
||||
GET /pos/api/marketplace-ext/categories
|
||||
|
||||
Listings
|
||||
GET /pos/api/marketplace-ext/listings
|
||||
POST /pos/api/marketplace-ext/listings
|
||||
POST /pos/api/marketplace-ext/listings/<id>/sync
|
||||
POST /pos/api/marketplace-ext/listings/<id>/pause
|
||||
POST /pos/api/marketplace-ext/listings/<id>/activate
|
||||
DELETE /pos/api/marketplace-ext/listings/<id>
|
||||
|
||||
Orders
|
||||
GET /pos/api/marketplace-ext/orders
|
||||
GET /pos/api/marketplace-ext/orders/<id>
|
||||
POST /pos/api/marketplace-ext/orders/<id>/convert
|
||||
|
||||
Webhook (public)
|
||||
POST /pos/api/marketplace-ext/webhook/meli
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from services import marketplace_external_service as meli_svc
|
||||
|
||||
|
||||
def _get_public_base_url() -> str:
|
||||
"""Build the tenant's public base URL from request headers (handles reverse proxy)."""
|
||||
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
|
||||
host = request.headers.get("X-Forwarded-Host", request.host)
|
||||
|
||||
# Cloudflare specific header
|
||||
cf_visitor = request.headers.get("CF-Visitor")
|
||||
if cf_visitor and '"scheme":"https"' in cf_visitor:
|
||||
proto = "https"
|
||||
|
||||
# Force https for production domain if we detect http behind a TLS terminator
|
||||
if proto == "http" and ("nexusautoparts.com.mx" in host or request.headers.get("X-Forwarded-Ssl") == "on"):
|
||||
proto = "https"
|
||||
|
||||
return f"{proto}://{host}/"
|
||||
from services.meli_service import MeliService, MeliAuthError
|
||||
|
||||
marketplace_ext_bp = Blueprint(
|
||||
"marketplace_ext", __name__, url_prefix="/pos/api/marketplace-ext"
|
||||
)
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _require_meli_manage():
|
||||
if not has_permission("marketplace.manage"):
|
||||
return jsonify({"error": "Missing permission: marketplace.manage"}), 403
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CONFIG
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@marketplace_ext_bp.route("/config", methods=["GET"])
|
||||
@require_auth()
|
||||
def get_config():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
# Never return tokens to frontend
|
||||
safe = {
|
||||
k: v for k, v in cfg.items()
|
||||
if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret")
|
||||
}
|
||||
safe["connected"] = bool(cfg.get("meli_access_token"))
|
||||
return jsonify(safe)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/connect", methods=["POST"])
|
||||
@require_auth()
|
||||
def connect_meli():
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
code = data.get("code")
|
||||
client_id = data.get("client_id")
|
||||
client_secret = data.get("client_secret")
|
||||
redirect_uri = data.get("redirect_uri", "")
|
||||
|
||||
if not code or not client_id or not client_secret:
|
||||
return jsonify({"error": "code, client_id and client_secret required"}), 400
|
||||
|
||||
try:
|
||||
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)
|
||||
except MeliAuthError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
user_id = token_data.get("user_id")
|
||||
|
||||
# Validate token by fetching user
|
||||
svc = MeliService(access_token)
|
||||
try:
|
||||
user = svc.get_user()
|
||||
except MeliAuthError as e:
|
||||
return jsonify({"error": f"Invalid token: {e}"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
meli_svc.save_meli_config(conn, {
|
||||
"meli_access_token": access_token,
|
||||
"meli_refresh_token": refresh_token,
|
||||
"meli_user_id": str(user_id or user.get("id")),
|
||||
"meli_site_id": user.get("site_id", "MLM"),
|
||||
"meli_enabled": "true",
|
||||
"meli_client_id": client_id,
|
||||
"meli_client_secret": client_secret,
|
||||
})
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"user_id": user_id or user.get("id"),
|
||||
"nickname": user.get("nickname"),
|
||||
"site_id": user.get("site_id"),
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/connect", methods=["DELETE"])
|
||||
@require_auth()
|
||||
def disconnect_meli():
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
meli_svc.delete_meli_config(conn)
|
||||
return jsonify({"ok": True})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/categories", methods=["GET"])
|
||||
@require_auth()
|
||||
def search_categories():
|
||||
q = request.args.get("q", "")
|
||||
site_id = request.args.get("site_id", "MLM")
|
||||
if not q or len(q) < 2:
|
||||
return jsonify({"categories": []})
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||
result = svc.search_categories(site_id, q)
|
||||
return jsonify({"categories": result})
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# LISTINGS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@marketplace_ext_bp.route("/listings", methods=["GET"])
|
||||
@require_auth()
|
||||
def list_listings():
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
status = request.args.get("status")
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status)
|
||||
return jsonify(result)
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings", methods=["POST"])
|
||||
@require_auth()
|
||||
def create_listings():
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
inventory_ids = data.get("inventory_ids", [])
|
||||
category_id = data.get("category_id")
|
||||
listing_type = data.get("listing_type", "gold_special")
|
||||
shipping_mode = data.get("shipping_mode", "me2")
|
||||
custom_data = data.get("custom_data", {})
|
||||
|
||||
if not inventory_ids:
|
||||
return jsonify({"error": "inventory_ids required"}), 400
|
||||
if not category_id:
|
||||
return jsonify({"error": "category_id required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.publish_items(
|
||||
conn,
|
||||
inventory_ids=inventory_ids,
|
||||
meli_category_id=category_id,
|
||||
listing_type_id=listing_type,
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
)
|
||||
return jsonify(result), 201
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/import-existing", methods=["POST"])
|
||||
@require_auth()
|
||||
def import_existing_listings():
|
||||
"""Import all existing MercadoLibre listings for the connected seller."""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.import_existing_listings(conn)
|
||||
return jsonify(result), 200
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/inventory-check", methods=["POST"])
|
||||
@require_auth()
|
||||
def inventory_check():
|
||||
"""Check local pre-flight status for ML publishing (duplicates, stock, price, image)."""
|
||||
data = request.get_json() or {}
|
||||
inventory_ids = data.get("inventory_ids", [])
|
||||
if not inventory_ids:
|
||||
return jsonify({"error": "inventory_ids required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.check_inventory_ml_status(conn, inventory_ids, base_url=_get_public_base_url())
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/sync-stock", methods=["POST"])
|
||||
@require_auth()
|
||||
def sync_stock_to_meli():
|
||||
"""Process pending stock updates to MercadoLibre."""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.process_meli_sync_queue(conn)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/categories/<category_id>/attributes", methods=["GET"])
|
||||
@require_auth()
|
||||
def category_attributes(category_id):
|
||||
"""Get required attributes for a MercadoLibre category."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||
attrs = svc.get_category_attributes(category_id)
|
||||
# Filter to required attributes only for the UI
|
||||
required = [a for a in attrs if a.get("tags", {}).get("required")]
|
||||
return jsonify({"attributes": required, "all": attrs})
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/validate", methods=["POST"])
|
||||
@require_auth()
|
||||
def validate_listings():
|
||||
"""Validate items payload against ML /items/validate without creating them."""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
inventory_ids = data.get("inventory_ids", [])
|
||||
category_id = data.get("category_id")
|
||||
listing_type = data.get("listing_type", "gold_special")
|
||||
shipping_mode = data.get("shipping_mode", "me2")
|
||||
custom_data = data.get("custom_data", {})
|
||||
|
||||
if not inventory_ids:
|
||||
return jsonify({"error": "inventory_ids required"}), 400
|
||||
if not category_id:
|
||||
return jsonify({"error": "category_id required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.validate_items(
|
||||
conn,
|
||||
inventory_ids=inventory_ids,
|
||||
meli_category_id=category_id,
|
||||
listing_type_id=listing_type,
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/async", methods=["POST"])
|
||||
@require_auth()
|
||||
def create_listings_async():
|
||||
"""Enqueue ML publishing as a Celery background task."""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
inventory_ids = data.get("inventory_ids", [])
|
||||
category_id = data.get("category_id")
|
||||
listing_type = data.get("listing_type", "gold_special")
|
||||
shipping_mode = data.get("shipping_mode", "me2")
|
||||
custom_data = data.get("custom_data", {})
|
||||
|
||||
if not inventory_ids:
|
||||
return jsonify({"error": "inventory_ids required"}), 400
|
||||
if not category_id:
|
||||
return jsonify({"error": "category_id required"}), 400
|
||||
|
||||
try:
|
||||
from tasks import publish_meli_items_task
|
||||
task = publish_meli_items_task.delay(
|
||||
g.tenant_id,
|
||||
inventory_ids=inventory_ids,
|
||||
category_id=category_id,
|
||||
listing_type=listing_type,
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
)
|
||||
return jsonify({"task_id": task.id, "status": "queued"}), 202
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/async/<task_id>", methods=["GET"])
|
||||
@require_auth()
|
||||
def get_async_listing_status(task_id):
|
||||
"""Get status of an async ML publishing task."""
|
||||
try:
|
||||
from celery.result import AsyncResult
|
||||
from app import celery as celery_app
|
||||
result = AsyncResult(task_id, app=celery_app)
|
||||
if result.ready():
|
||||
return jsonify({"status": "done", "result": result.result or {}})
|
||||
return jsonify({"status": "pending"})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/<int:listing_id>/sync", methods=["POST"])
|
||||
@require_auth()
|
||||
def sync_listing(listing_id):
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.sync_listing(conn, listing_id)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/<int:listing_id>/pause", methods=["POST"])
|
||||
@require_auth()
|
||||
def pause_listing(listing_id):
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.pause_listing(conn, listing_id)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/<int:listing_id>/activate", methods=["POST"])
|
||||
@require_auth()
|
||||
def activate_listing(listing_id):
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.activate_listing(conn, listing_id)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/<int:listing_id>", methods=["DELETE"])
|
||||
@require_auth()
|
||||
def delete_listing(listing_id):
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.close_listing(conn, listing_id)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/<int:listing_id>/permanent", methods=["DELETE"])
|
||||
@require_auth()
|
||||
def delete_listing_permanent(listing_id):
|
||||
"""Hard-delete a closed listing from the local DB."""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.delete_listing_permanently(conn, listing_id)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# QUESTIONS & ANSWERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@marketplace_ext_bp.route("/questions", methods=["GET"])
|
||||
@require_auth()
|
||||
def list_questions():
|
||||
"""List questions from local DB. Query param: ?status=unanswered"""
|
||||
status = request.args.get("status")
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
items = meli_svc.list_local_questions(conn, status=status)
|
||||
return jsonify({"items": items})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/questions/sync", methods=["POST"])
|
||||
@require_auth()
|
||||
def sync_questions():
|
||||
"""Force sync questions from ML for all active listings."""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.sync_questions(conn)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/questions/<int:question_id>/answer", methods=["POST"])
|
||||
@require_auth()
|
||||
def answer_question(question_id):
|
||||
"""Answer a buyer question via ML API."""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
text = data.get("text", "").strip()
|
||||
if not text:
|
||||
return jsonify({"error": "Answer text is required"}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.answer_question(conn, question_id, text)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@marketplace_ext_bp.route("/orders/sync", methods=["POST"])
|
||||
@require_auth()
|
||||
def sync_orders():
|
||||
"""Manually trigger sync of MercadoLibre orders."""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.fetch_and_save_orders(conn)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/orders", methods=["GET"])
|
||||
@require_auth()
|
||||
def list_orders():
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
status = request.args.get("status")
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.get_orders(conn, page=page, per_page=per_page, status=status)
|
||||
return jsonify(result)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/orders/<int:order_id>", methods=["GET"])
|
||||
@require_auth()
|
||||
def get_order(order_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.get_order_detail(conn, order_id)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/orders/<int:order_id>/convert", methods=["POST"])
|
||||
@require_auth("pos.sell")
|
||||
def convert_order(order_id):
|
||||
data = request.get_json() or {}
|
||||
register_id = data.get("register_id")
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.convert_order_to_sale(
|
||||
conn, order_id, employee_id=g.employee_id, register_id=register_id
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/orders/<int:order_id>/status", methods=["POST"])
|
||||
@require_auth()
|
||||
def update_order_status_route(order_id):
|
||||
data = request.get_json() or {}
|
||||
new_status = data.get("status")
|
||||
if not new_status:
|
||||
return jsonify({"error": "status required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.update_order_status(conn, order_id, new_status)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# WEBHOOK (public — no auth)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@marketplace_ext_bp.route("/webhook/meli", methods=["POST"])
|
||||
def meli_webhook():
|
||||
"""Receive MercadoLibre notifications.
|
||||
|
||||
ML sends a lightweight payload with topic + resource URL.
|
||||
We ack immediately and enqueue Celery for async processing.
|
||||
"""
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
topic = data.get("topic", "")
|
||||
resource = data.get("resource", "")
|
||||
user_id = data.get("user_id")
|
||||
|
||||
# Resolve tenant by meli_user_id
|
||||
tenant_id = None
|
||||
if user_id:
|
||||
try:
|
||||
mconn = get_master_conn()
|
||||
mcur = mconn.cursor()
|
||||
mcur.execute(
|
||||
"""
|
||||
SELECT t.id FROM tenants t
|
||||
JOIN tenant_config c ON c.key = 'meli_user_id' AND c.value = %s
|
||||
WHERE t.is_active = true
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(user_id),),
|
||||
)
|
||||
row = mcur.fetchone()
|
||||
if row:
|
||||
tenant_id = row[0]
|
||||
mcur.close()
|
||||
mconn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if tenant_id and topic:
|
||||
try:
|
||||
from tasks import process_meli_webhook_task
|
||||
process_meli_webhook_task.delay(tenant_id, topic, resource)
|
||||
except Exception as e:
|
||||
print(f"[ML Webhook] Failed to enqueue task: {e}")
|
||||
|
||||
return jsonify({"ok": True})
|
||||
@@ -6,15 +6,18 @@ that validates input, calls the engine, and returns JSON responses.
|
||||
"""
|
||||
|
||||
import json
|
||||
import jwt
|
||||
from datetime import datetime, date, timedelta
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask import Blueprint, request, jsonify, g, render_template_string
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.pos_engine import (
|
||||
process_sale, cancel_sale, calculate_totals,
|
||||
get_price_for_customer, get_margin_info
|
||||
)
|
||||
from services.inventory_engine import get_stock
|
||||
from services.audit import log_action
|
||||
from config import JWT_SECRET
|
||||
|
||||
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
||||
|
||||
@@ -32,7 +35,7 @@ def _enrich_items(cur, items, customer_id=None):
|
||||
# Batch fetch all inventory items in one query
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||
tax_rate, branch_id
|
||||
tax_rate
|
||||
FROM inventory WHERE id = ANY(%s) AND is_active = true
|
||||
""", (inv_ids,))
|
||||
inv_map = {r[0]: r for r in cur.fetchall()}
|
||||
@@ -73,7 +76,6 @@ def _enrich_items(cur, items, customer_id=None):
|
||||
'unit_cost': float(inv[3]) if inv[3] else 0,
|
||||
'discount_pct': discount_pct,
|
||||
'tax_rate': tax_rate,
|
||||
'branch_id': inv[8],
|
||||
})
|
||||
return enriched
|
||||
|
||||
@@ -101,6 +103,19 @@ def create_sale():
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
# Verify stock availability per item for the active branch
|
||||
branch_id = data.get('branch_id', g.branch_id)
|
||||
for item in data.get('items', []):
|
||||
inv_id = item.get('inventory_id')
|
||||
qty = int(item.get('quantity', 1))
|
||||
if inv_id:
|
||||
available = get_stock(conn, inv_id, branch_id)
|
||||
if available < qty:
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
sale = process_sale(conn, data)
|
||||
conn.commit()
|
||||
@@ -217,6 +232,83 @@ def list_sales():
|
||||
})
|
||||
|
||||
|
||||
@pos_bp.route('/historical-sales', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def list_historical_sales():
|
||||
"""List imported historical sales (read-only reference).
|
||||
|
||||
Query params:
|
||||
date_from: YYYY-MM-DD
|
||||
date_to: YYYY-MM-DD
|
||||
customer: partial customer name
|
||||
page: int (default 1)
|
||||
per_page: int (default 50, max 200)
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
|
||||
date_from = request.args.get('date_from')
|
||||
date_to = request.args.get('date_to')
|
||||
customer = request.args.get('customer')
|
||||
|
||||
if date_from:
|
||||
where_clauses.append("sale_date >= %s")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
where_clauses.append("sale_date <= %s")
|
||||
params.append(date_to)
|
||||
if customer:
|
||||
where_clauses.append("customer_name ILIKE %s")
|
||||
params.append(f"%{customer}%")
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"SELECT count(*) FROM historical_sales WHERE {where}", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT id, external_document_id, document_no, sale_date, customer_name,
|
||||
total, subtotal, amount_paid, payment_method, discount, balance,
|
||||
raw_payment_code
|
||||
FROM historical_sales
|
||||
WHERE {where}
|
||||
ORDER BY sale_date DESC, id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, (page - 1) * per_page])
|
||||
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
rows.append({
|
||||
'id': r[0],
|
||||
'external_document_id': r[1],
|
||||
'document_no': r[2],
|
||||
'sale_date': str(r[3]) if r[3] else None,
|
||||
'customer_name': r[4],
|
||||
'total': float(r[5]) if r[5] else 0,
|
||||
'subtotal': float(r[6]) if r[6] else 0,
|
||||
'amount_paid': float(r[7]) if r[7] else 0,
|
||||
'payment_method': r[8],
|
||||
'discount': float(r[9]) if r[9] else 0,
|
||||
'balance': float(r[10]) if r[10] else 0,
|
||||
'raw_payment_code': r[11],
|
||||
})
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
return jsonify({
|
||||
'data': rows,
|
||||
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
|
||||
})
|
||||
|
||||
|
||||
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def get_sale(sale_id):
|
||||
@@ -485,6 +577,16 @@ def create_quotation():
|
||||
currency, exchange_rate
|
||||
))
|
||||
|
||||
# Reserve stock for quotation
|
||||
from services.quote_reservation import reserve_for_quotation, get_quotation_items_for_reservation
|
||||
try:
|
||||
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
reserve_for_quotation(conn, quot_id, reservation_items, employee_id=g.employee_id)
|
||||
except Exception as res_err:
|
||||
# Log but don't fail the quote creation
|
||||
import logging
|
||||
logging.getLogger('pos').warning(f'Quote reservation failed for #{quot_id}: {res_err}')
|
||||
|
||||
log_action(conn, 'QUOTATION_CREATE', 'quotation', quot_id,
|
||||
new_value={'total': totals['total'], 'items_count': len(items)})
|
||||
|
||||
@@ -766,6 +868,270 @@ def get_quotation(quot_id):
|
||||
return jsonify(quot)
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>', methods=['PUT'])
|
||||
@require_auth('pos.sell')
|
||||
def update_quotation(quot_id):
|
||||
"""Replace all items in an existing active quotation.
|
||||
|
||||
Body: { items: [...], customer_id, notes, valid_days, currency, exchange_rate }
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
items = data.get('items', [])
|
||||
if not items:
|
||||
return jsonify({'error': 'No items provided'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT id, status FROM quotations WHERE id = %s", (quot_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
if row[1] != 'active':
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Quotation is {row[1]}, cannot edit'}), 400
|
||||
|
||||
try:
|
||||
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
||||
except ValueError as e:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
totals = calculate_totals(enriched)
|
||||
valid_days = int(data.get('valid_days', 7))
|
||||
valid_until = (date.today() + timedelta(days=valid_days)).isoformat()
|
||||
|
||||
from services.currency import get_exchange_rate
|
||||
currency = data.get('currency', 'MXN')
|
||||
if currency not in ('MXN', 'USD'):
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Unsupported currency: {currency}'}), 400
|
||||
exchange_rate = data.get('exchange_rate')
|
||||
if currency != 'MXN' and exchange_rate is None:
|
||||
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
|
||||
exchange_rate = float(exchange_rate) if exchange_rate else 1.0
|
||||
|
||||
try:
|
||||
# Release old reservations before deleting items
|
||||
from services.quote_reservation import (
|
||||
release_quotation_reservation,
|
||||
reserve_for_quotation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
old_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if old_items:
|
||||
release_quotation_reservation(conn, quot_id, old_items, employee_id=g.employee_id)
|
||||
|
||||
# Delete old items
|
||||
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
|
||||
|
||||
# Update header
|
||||
cur.execute("""
|
||||
UPDATE quotations
|
||||
SET customer_id = %s, subtotal = %s, tax_total = %s, total = %s,
|
||||
valid_until = %s, notes = %s, currency = %s, exchange_rate = %s,
|
||||
employee_id = %s
|
||||
WHERE id = %s
|
||||
""", (
|
||||
data.get('customer_id'), totals['subtotal'], totals['tax_total'],
|
||||
totals['total'], valid_until, data.get('notes'),
|
||||
currency, exchange_rate, g.employee_id, quot_id
|
||||
))
|
||||
|
||||
# Insert new items
|
||||
for item in totals['items']:
|
||||
line_subtotal = round(
|
||||
item['unit_price'] * item['quantity'] * (1 - item['discount_pct'] / 100), 2
|
||||
)
|
||||
cur.execute("""
|
||||
INSERT INTO quotation_items
|
||||
(quotation_id, inventory_id, part_number, name, quantity,
|
||||
unit_price, discount_pct, tax_rate, subtotal, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (
|
||||
quot_id, item['inventory_id'], item.get('part_number', ''),
|
||||
item.get('name', ''), item['quantity'], item['unit_price'],
|
||||
item['discount_pct'], item['tax_rate'], line_subtotal,
|
||||
currency, exchange_rate
|
||||
))
|
||||
|
||||
# Reserve stock for new items
|
||||
new_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if new_items:
|
||||
reserve_for_quotation(conn, quot_id, new_items, employee_id=g.employee_id)
|
||||
|
||||
log_action(conn, 'QUOTATION_UPDATE', 'quotation', quot_id,
|
||||
new_value={'total': totals['total'], 'items_count': len(items)})
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Quotation updated', 'id': quot_id, 'total': totals['total']})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>', methods=['PATCH'])
|
||||
@require_auth('pos.sell')
|
||||
def patch_quotation(quot_id):
|
||||
"""Update quotation header fields without touching items."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT id, status FROM quotations WHERE id = %s", (quot_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
|
||||
fields = []
|
||||
params = []
|
||||
if 'customer_id' in data:
|
||||
fields.append('customer_id = %s')
|
||||
params.append(data['customer_id'])
|
||||
if 'notes' in data:
|
||||
fields.append('notes = %s')
|
||||
params.append(data['notes'])
|
||||
if 'valid_until' in data:
|
||||
fields.append('valid_until = %s')
|
||||
params.append(data['valid_until'])
|
||||
if 'status' in data and data['status'] in ('active', 'cancelled', 'expired'):
|
||||
fields.append('status = %s')
|
||||
params.append(data['status'])
|
||||
|
||||
if not fields:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'No changes'}), 200
|
||||
|
||||
params.append(quot_id)
|
||||
cur.execute(f"UPDATE quotations SET {', '.join(fields)} WHERE id = %s", params)
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Quotation updated'})
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>/share', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def share_quotation(quot_id):
|
||||
"""Generate a public JWT token for viewing this quotation."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, valid_until, status FROM quotations WHERE id = %s", (quot_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close(); conn.close()
|
||||
if not row:
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
if row[2] != 'active':
|
||||
return jsonify({'error': 'Only active quotations can be shared'}), 400
|
||||
|
||||
valid_until = row[1] or (date.today() + timedelta(days=7))
|
||||
if isinstance(valid_until, str):
|
||||
valid_until = datetime.strptime(valid_until, '%Y-%m-%d').date()
|
||||
|
||||
payload = {
|
||||
'type': 'public_quote',
|
||||
'quot_id': quot_id,
|
||||
'tenant_id': g.tenant_id,
|
||||
'exp': datetime.combine(valid_until, datetime.max.time()),
|
||||
}
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
||||
public_url = request.host_url.rstrip('/') + f'/public/quote/{token}'
|
||||
return jsonify({'token': token, 'url': public_url})
|
||||
|
||||
|
||||
@pos_bp.route('/public/quote/<token>', methods=['GET'])
|
||||
def public_quote(token):
|
||||
"""Unauthenticated public view of a quotation."""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||
if payload.get('type') != 'public_quote':
|
||||
return jsonify({'error': 'Invalid token type'}), 400
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Quote expired'}), 410
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 400
|
||||
|
||||
# Resolve tenant db
|
||||
from tenant_db import get_tenant_conn
|
||||
conn = get_tenant_conn(payload['tenant_id'])
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
|
||||
q.created_at, q.notes, q.customer_id, q.currency, q.exchange_rate,
|
||||
c.name as customer_name, c.phone as customer_phone, c.email as customer_email,
|
||||
e.name as employee_name
|
||||
FROM quotations q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
LEFT JOIN employees e ON q.employee_id = e.id
|
||||
WHERE q.id = %s
|
||||
""", (payload['quot_id'],))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
|
||||
cols = ['id', 'subtotal', 'tax_total', 'total', 'valid_until', 'created_at',
|
||||
'notes', 'customer_id', 'currency', 'exchange_rate', 'customer_name',
|
||||
'customer_phone', 'customer_email', 'employee_name']
|
||||
quot = dict(zip(cols, row))
|
||||
for k in ('subtotal', 'tax_total', 'total', 'exchange_rate'):
|
||||
if quot.get(k) is not None:
|
||||
quot[k] = float(quot[k])
|
||||
|
||||
cur.execute("""
|
||||
SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
|
||||
FROM quotation_items WHERE quotation_id = %s ORDER BY id
|
||||
""", (payload['quot_id'],))
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'part_number': r[0], 'name': r[1], 'quantity': r[2],
|
||||
'unit_price': float(r[3]) if r[3] else 0,
|
||||
'discount_pct': float(r[4]) if r[4] else 0,
|
||||
'tax_rate': float(r[5]) if r[5] else 0,
|
||||
'subtotal': float(r[6]) if r[6] else 0,
|
||||
})
|
||||
cur.close(); conn.close()
|
||||
|
||||
html = render_template_string(PUBLIC_QUOTE_TEMPLATE,
|
||||
quot=quot, items=items, host=request.host_url.rstrip('/'),
|
||||
token=token)
|
||||
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||
|
||||
|
||||
@pos_bp.route('/public/quote/<token>/accept', methods=['POST'])
|
||||
def public_quote_accept(token):
|
||||
"""Customer accepts a public quote."""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||
if payload.get('type') != 'public_quote':
|
||||
return jsonify({'error': 'Invalid token type'}), 400
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Quote expired'}), 410
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 400
|
||||
|
||||
conn = get_tenant_conn(payload['tenant_id'])
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT status FROM quotations WHERE id = %s", (payload['quot_id'],))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
if row[0] != 'active':
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation is no longer active'}), 400
|
||||
|
||||
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s",
|
||||
(payload['quot_id'],))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Cotizacion aceptada. Un asesor se pondra en contacto contigo.'})
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>/pdf', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def get_quotation_pdf(quot_id):
|
||||
@@ -1004,6 +1370,19 @@ def convert_quotation(quot_id):
|
||||
WHERE id = %s
|
||||
""", (sale['id'], quot_id))
|
||||
|
||||
# Convert reservation to actual sale
|
||||
from services.quote_reservation import (
|
||||
convert_quotation_reservation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
try:
|
||||
res_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if res_items:
|
||||
convert_quotation_reservation(conn, quot_id, res_items, sale_id=sale['id'], employee_id=g.employee_id)
|
||||
except Exception as res_err:
|
||||
import logging
|
||||
logging.getLogger('pos').warning(f'Quote conversion reservation failed for #{quot_id}: {res_err}')
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify(sale), 201
|
||||
@@ -1034,11 +1413,76 @@ def cancel_quotation(quot_id):
|
||||
return jsonify({'error': f'Quotation is already {quot[1]}'}), 400
|
||||
|
||||
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (quot_id,))
|
||||
|
||||
# Release reserved stock
|
||||
from services.quote_reservation import (
|
||||
release_quotation_reservation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
try:
|
||||
res_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if res_items:
|
||||
release_quotation_reservation(conn, quot_id, res_items, employee_id=g.employee_id)
|
||||
except Exception as res_err:
|
||||
import logging
|
||||
logging.getLogger('pos').warning(f'Quote release on cancel failed for #{quot_id}: {res_err}')
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Quotation cancelled'})
|
||||
|
||||
|
||||
@pos_bp.route('/internal/check-expired-quotations', methods=['POST'])
|
||||
def check_expired_quotations():
|
||||
"""Cron endpoint: mark active quotations as expired when valid_until < today.
|
||||
|
||||
Can be called internally by systemd timer or Celery beat.
|
||||
Requires a secret header INTERNAL_API_KEY for safety.
|
||||
Body (optional): { tenant_id: int } — if omitted, uses g.tenant_id.
|
||||
"""
|
||||
from config import INTERNAL_API_KEY
|
||||
if INTERNAL_API_KEY and request.headers.get('X-Internal-Key') != INTERNAL_API_KEY:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
tenant_id = data.get('tenant_id') or getattr(g, 'tenant_id', None)
|
||||
if not tenant_id:
|
||||
return jsonify({'error': 'tenant_id required'}), 400
|
||||
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE quotations
|
||||
SET status = 'expired'
|
||||
WHERE status = 'active'
|
||||
AND valid_until < CURRENT_DATE
|
||||
RETURNING id
|
||||
""")
|
||||
expired_ids = [r[0] for r in cur.fetchall()]
|
||||
|
||||
# Release reservations for expired quotes
|
||||
from services.quote_reservation import (
|
||||
release_quotation_reservation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
for qid in expired_ids:
|
||||
try:
|
||||
res_items = get_quotation_items_for_reservation(conn, qid)
|
||||
if res_items:
|
||||
release_quotation_reservation(conn, qid, res_items)
|
||||
except Exception as res_err:
|
||||
import logging
|
||||
logging.getLogger('pos').warning(f'Quote release on expiry failed for #{qid}: {res_err}')
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({
|
||||
'expired': len(expired_ids),
|
||||
'ids': expired_ids,
|
||||
'tenant_id': tenant_id,
|
||||
})
|
||||
|
||||
|
||||
# ─── Layaways (Apartados) ────────────────────────
|
||||
|
||||
@pos_bp.route('/layaways', methods=['POST'])
|
||||
@@ -1510,6 +1954,14 @@ def complete_layaway(layaway_id):
|
||||
new_value={'sale_id': sale['id'], 'total': total})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# WhatsApp learning hook (non-blocking)
|
||||
try:
|
||||
from services.wa_learning import check_learning_resolution
|
||||
check_learning_resolution(sale['id'], cust_id, conn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify(sale), 201
|
||||
|
||||
@@ -1967,3 +2419,109 @@ def print_ticket(sale_id):
|
||||
raw = generate_ticket(sale_data, business_info, width=width)
|
||||
return Response(raw, mimetype='application/octet-stream',
|
||||
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
|
||||
|
||||
|
||||
# ─── Public Quote HTML Template ─────────────────────────────────────────────
|
||||
|
||||
PUBLIC_QUOTE_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cotizacion #{{ quot.id }}</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#111;padding:16px;line-height:1.5}
|
||||
.card{max-width:640px;margin:0 auto;background:#fff;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,0.08);overflow:hidden}
|
||||
.header{background:linear-gradient(135deg,#1f2937,#374151);color:#fff;padding:28px 24px;text-align:center}
|
||||
.header h1{font-size:22px;font-weight:700;margin-bottom:6px}
|
||||
.header p{font-size:13px;opacity:.85}
|
||||
.body{padding:24px}
|
||||
.meta{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;font-size:13px;color:#4b5563}
|
||||
.meta div{background:#f9fafb;padding:10px 12px;border-radius:8px}
|
||||
.meta strong{color:#111;display:block;font-size:12px;text-transform:uppercase;letter-spacing:.4px;margin-bottom:2px}
|
||||
table{width:100%;border-collapse:collapse;font-size:14px;margin-bottom:16px}
|
||||
th{text-align:left;padding:10px 8px;background:#f3f4f6;color:#374151;font-size:11px;text-transform:uppercase;letter-spacing:.4px}
|
||||
td{padding:12px 8px;border-bottom:1px solid #e5e7eb;vertical-align:top}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.part{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#6b7280}
|
||||
.qty{text-align:center}
|
||||
.price{text-align:right;font-weight:600}
|
||||
.totals{border-top:2px solid #e5e7eb;padding-top:16px;text-align:right;font-size:14px}
|
||||
.totals div{margin-bottom:4px;color:#4b5563}
|
||||
.totals .big{font-size:22px;font-weight:800;color:#111;margin-top:8px}
|
||||
.actions{padding:0 24px 24px;text-align:center}
|
||||
.btn{display:inline-block;width:100%;padding:14px 20px;border-radius:10px;border:none;font-size:16px;font-weight:700;cursor:pointer;transition:transform .1s}
|
||||
.btn-primary{background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff}
|
||||
.btn-primary:hover{transform:translateY(-1px)}
|
||||
.btn-primary:active{transform:translateY(0)}
|
||||
.btn-disabled{background:#e5e7eb;color:#9ca3af;cursor:not-allowed}
|
||||
.footer{text-align:center;padding:16px;font-size:12px;color:#9ca3af}
|
||||
.badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:11px;font-weight:700;text-transform:uppercase}
|
||||
.badge-active{background:#d1fae5;color:#065f46}
|
||||
.badge-expired{background:#fee2e2;color:#991b1b}
|
||||
@media(min-width:480px){.meta{grid-template-columns:repeat(3,1fr)}.btn{width:auto;min-width:280px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>Cotizacion #{{ quot.id }}</h1>
|
||||
<p>{{ host }}</p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="meta">
|
||||
<div><strong>Cliente</strong>{{ quot.customer_name or 'Publico general' }}</div>
|
||||
<div><strong>Fecha</strong>{{ quot.created_at[:10] if quot.created_at else '—' }}</div>
|
||||
<div><strong>Vigencia</strong>{{ quot.valid_until or '—' }} <span class="badge badge-{{ 'active' if quot.status == 'active' else 'expired' }}">{{ quot.status }}</span></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Descripcion</th><th class="qty">Cant</th><th class="price">P. Unit</th><th class="price">Subtotal</th></tr></thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight:600">{{ it.name }}</div>
|
||||
<div class="part">{{ it.part_number }}</div>
|
||||
</td>
|
||||
<td class="qty">{{ it.quantity }}</td>
|
||||
<td class="price">${{ "{:,.2f}".format(it.unit_price) }}</td>
|
||||
<td class="price">${{ "{:,.2f}".format(it.subtotal) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals">
|
||||
<div>Subtotal: ${{ "{:,.2f}".format(quot.subtotal) }}</div>
|
||||
<div>IVA: ${{ "{:,.2f}".format(quot.tax_total) }}</div>
|
||||
<div class="big">Total: ${{ "{:,.2f}".format(quot.total) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{% if quot.status == 'active' %}
|
||||
<button class="btn btn-primary" id="acceptBtn" onclick="acceptQuote()">Aceptar cotizacion</button>
|
||||
{% else %}
|
||||
<button class="btn btn-disabled" disabled>Cotizacion no disponible</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="footer">
|
||||
Precios sujetos a cambio sin previo aviso. Vigencia limitada.
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function acceptQuote(){
|
||||
var btn=document.getElementById('acceptBtn');
|
||||
btn.disabled=true;btn.textContent='Procesando...';
|
||||
fetch('/public/quote/{{ token }}/accept',{method:'POST'})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(d.error){alert('Error: '+d.error);btn.disabled=false;btn.textContent='Aceptar cotizacion';}
|
||||
else{btn.textContent='Cotizacion aceptada';btn.className='btn btn-disabled';alert(d.message);}
|
||||
})
|
||||
.catch(function(){alert('Error de red');btn.disabled=false;btn.textContent='Aceptar cotizacion';});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
106
pos/blueprints/public_bp.py
Normal file
106
pos/blueprints/public_bp.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Public blueprint — unauthenticated routes for shared content.
|
||||
|
||||
These routes live outside the /pos/api prefix so they can be accessed
|
||||
by customers without login.
|
||||
"""
|
||||
import jwt
|
||||
from flask import Blueprint, request, jsonify, render_template_string
|
||||
from tenant_db import get_tenant_conn
|
||||
from config import JWT_SECRET
|
||||
from blueprints.pos_bp import PUBLIC_QUOTE_TEMPLATE
|
||||
|
||||
public_bp = Blueprint('public', __name__)
|
||||
|
||||
|
||||
@public_bp.route('/public/quote/<token>', methods=['GET'])
|
||||
def public_quote(token):
|
||||
"""Unauthenticated public view of a quotation."""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||
if payload.get('type') != 'public_quote':
|
||||
return jsonify({'error': 'Invalid token type'}), 400
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Quote expired'}), 410
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 400
|
||||
|
||||
conn = get_tenant_conn(payload['tenant_id'])
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
|
||||
q.created_at, q.notes, q.customer_id, q.currency, q.exchange_rate,
|
||||
q.status,
|
||||
c.name as customer_name, c.phone as customer_phone, c.email as customer_email,
|
||||
e.name as employee_name
|
||||
FROM quotations q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
LEFT JOIN employees e ON q.employee_id = e.id
|
||||
WHERE q.id = %s
|
||||
""", (payload['quot_id'],))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
|
||||
cols = ['id', 'subtotal', 'tax_total', 'total', 'valid_until', 'created_at',
|
||||
'notes', 'customer_id', 'currency', 'exchange_rate', 'status',
|
||||
'customer_name', 'customer_phone', 'customer_email', 'employee_name']
|
||||
quot = dict(zip(cols, row))
|
||||
for k in ('subtotal', 'tax_total', 'total', 'exchange_rate'):
|
||||
if quot.get(k) is not None:
|
||||
quot[k] = float(quot[k])
|
||||
if quot.get('created_at'):
|
||||
quot['created_at'] = str(quot['created_at'])
|
||||
if quot.get('valid_until'):
|
||||
quot['valid_until'] = str(quot['valid_until'])
|
||||
|
||||
cur.execute("""
|
||||
SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
|
||||
FROM quotation_items WHERE quotation_id = %s ORDER BY id
|
||||
""", (payload['quot_id'],))
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'part_number': r[0], 'name': r[1], 'quantity': r[2],
|
||||
'unit_price': float(r[3]) if r[3] else 0,
|
||||
'discount_pct': float(r[4]) if r[4] else 0,
|
||||
'tax_rate': float(r[5]) if r[5] else 0,
|
||||
'subtotal': float(r[6]) if r[6] else 0,
|
||||
})
|
||||
cur.close(); conn.close()
|
||||
|
||||
html = render_template_string(PUBLIC_QUOTE_TEMPLATE,
|
||||
quot=quot, items=items, host=request.host_url.rstrip('/'),
|
||||
token=token)
|
||||
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||
|
||||
|
||||
@public_bp.route('/public/quote/<token>/accept', methods=['POST'])
|
||||
def public_quote_accept(token):
|
||||
"""Customer accepts a public quote."""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||
if payload.get('type') != 'public_quote':
|
||||
return jsonify({'error': 'Invalid token type'}), 400
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Quote expired'}), 410
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 400
|
||||
|
||||
conn = get_tenant_conn(payload['tenant_id'])
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT status FROM quotations WHERE id = %s", (payload['quot_id'],))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
if row[0] != 'active':
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation is no longer active'}), 400
|
||||
|
||||
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s",
|
||||
(payload['quot_id'],))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Cotizacion aceptada. Un asesor se pondra en contacto contigo.'})
|
||||
538
pos/blueprints/supplier_catalog_bp.py
Normal file
538
pos/blueprints/supplier_catalog_bp.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""Supplier Catalog Blueprint — parts from suppliers with vehicle compatibility.
|
||||
|
||||
Independent from inventory. Supports:
|
||||
- Browse by supplier/category
|
||||
- Search by text or vehicle (MYE or make/model/year)
|
||||
- Part detail with compatibilities and interchanges
|
||||
- Bulk import via Excel
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import date
|
||||
from flask import Blueprint, request, jsonify, g, render_template
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from tenant_db import get_master_conn
|
||||
from middleware import require_auth
|
||||
|
||||
supplier_catalog_bp = Blueprint('supplier_catalog', __name__, url_prefix='/pos/api/supplier-catalog')
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_master_conn():
|
||||
return get_master_conn()
|
||||
|
||||
|
||||
def _json_response(data, status=200):
|
||||
return jsonify(data), status
|
||||
|
||||
|
||||
# ─── Brands ────────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/brands', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def list_brands():
|
||||
"""Return distinct makes (vehicle brands) present in the supplier catalog."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT DISTINCT make, COUNT(*) as cnt
|
||||
FROM supplier_catalog_compat
|
||||
WHERE make IS NOT NULL AND make != ''
|
||||
GROUP BY make
|
||||
ORDER BY make ASC
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'brands': [{'name': r[0], 'count': r[1]} for r in rows]})
|
||||
|
||||
|
||||
# ─── Search ────────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/search', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def search_items():
|
||||
"""Search supplier catalog by text and/or vehicle."""
|
||||
q = (request.args.get('q') or '').strip()
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
make = (request.args.get('make') or '').strip()
|
||||
model = (request.args.get('model') or '').strip()
|
||||
year = request.args.get('year', type=int)
|
||||
supplier = (request.args.get('supplier') or '').strip()
|
||||
category = (request.args.get('category') or '').strip()
|
||||
page = max(1, request.args.get('page', 1, type=int))
|
||||
per_page = min(100, request.args.get('per_page', 30, type=int))
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Build query dynamically
|
||||
where_parts = ["sc.is_active = true"]
|
||||
params = []
|
||||
|
||||
if supplier:
|
||||
where_parts.append("sc.supplier_name = %s")
|
||||
params.append(supplier)
|
||||
if category:
|
||||
where_parts.append("sc.category = %s")
|
||||
params.append(category)
|
||||
|
||||
# Text search on SKU, name, or interchange part_number
|
||||
if q:
|
||||
where_parts.append("""
|
||||
(sc.sku ILIKE %s OR sc.name ILIKE %s
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM supplier_catalog_interchange sci2
|
||||
WHERE sci2.catalog_id = sc.id AND sci2.part_number ILIKE %s
|
||||
))
|
||||
""")
|
||||
like_q = f'%{q}%'
|
||||
params.extend([like_q, like_q, like_q])
|
||||
|
||||
# Vehicle filter
|
||||
vehicle_join = ""
|
||||
if mye_id:
|
||||
vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id"
|
||||
where_parts.append("scc.model_year_engine_id = %s")
|
||||
params.append(mye_id)
|
||||
elif make or model or year:
|
||||
vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id"
|
||||
if make:
|
||||
where_parts.append("scc.make ILIKE %s")
|
||||
params.append(f'%{make}%')
|
||||
if model:
|
||||
where_parts.append("scc.model ILIKE %s")
|
||||
params.append(f'%{model}%')
|
||||
if year:
|
||||
where_parts.append("scc.year = %s")
|
||||
params.append(year)
|
||||
|
||||
where_sql = " AND ".join(where_parts)
|
||||
|
||||
# Count total
|
||||
count_sql = f"""
|
||||
SELECT COUNT(DISTINCT sc.id)
|
||||
FROM supplier_catalog sc
|
||||
{vehicle_join}
|
||||
WHERE {where_sql}
|
||||
"""
|
||||
cur.execute(count_sql, params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Fetch page
|
||||
fetch_sql = f"""
|
||||
SELECT DISTINCT
|
||||
sc.id, sc.supplier_name, sc.sku, sc.name,
|
||||
sc.category, sc.description, sc.image_url
|
||||
FROM supplier_catalog sc
|
||||
{vehicle_join}
|
||||
WHERE {where_sql}
|
||||
ORDER BY sc.name ASC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
cur.execute(fetch_sql, params + [per_page, offset])
|
||||
rows = cur.fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
'id': r[0],
|
||||
'supplier_name': r[1],
|
||||
'sku': r[2],
|
||||
'name': r[3],
|
||||
'category': r[4],
|
||||
'description': r[5],
|
||||
'image_url': r[6],
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({
|
||||
'data': items,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total': total,
|
||||
'total_pages': (total + per_page - 1) // per_page,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# ─── Item Detail ───────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/items/<int:item_id>', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def get_item_detail(item_id):
|
||||
"""Return full detail for a supplier catalog item including compat + interchanges."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, supplier_name, sku, name, category, description, image_url, created_at
|
||||
FROM supplier_catalog WHERE id = %s AND is_active = true
|
||||
""", (item_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Item not found'}), 404
|
||||
|
||||
item = {
|
||||
'id': row[0],
|
||||
'supplier_name': row[1],
|
||||
'sku': row[2],
|
||||
'name': row[3],
|
||||
'category': row[4],
|
||||
'description': row[5],
|
||||
'image_url': row[6],
|
||||
'created_at': str(row[7]) if row[7] else None,
|
||||
}
|
||||
|
||||
# Compatibilities — deduplicate by (make, model, year, engine) because
|
||||
# the same vehicle may map to multiple MYE ids (especially when engine
|
||||
# text is empty from the supplier catalog).
|
||||
cur.execute("""
|
||||
SELECT make, model, year, engine, model_year_engine_id, source
|
||||
FROM supplier_catalog_compat
|
||||
WHERE catalog_id = %s
|
||||
ORDER BY make, model, year, engine
|
||||
""", (item_id,))
|
||||
seen_compat = set()
|
||||
compatibilities = []
|
||||
for r in cur.fetchall():
|
||||
key = (r[0], r[1], r[2], r[3])
|
||||
if key in seen_compat:
|
||||
continue
|
||||
seen_compat.add(key)
|
||||
compatibilities.append({
|
||||
'make': r[0], 'model': r[1], 'year': r[2], 'engine': r[3],
|
||||
'model_year_engine_id': r[4], 'source': r[5]
|
||||
})
|
||||
item['compatibilities'] = compatibilities
|
||||
|
||||
# Interchanges
|
||||
cur.execute("""
|
||||
SELECT brand, part_number
|
||||
FROM supplier_catalog_interchange
|
||||
WHERE catalog_id = %s
|
||||
ORDER BY brand, part_number
|
||||
""", (item_id,))
|
||||
item['interchanges'] = [
|
||||
{'brand': r[0], 'part_number': r[1]}
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify(item)
|
||||
|
||||
|
||||
# ─── Categories ────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/categories', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def list_categories():
|
||||
"""Return distinct categories with counts."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT category, COUNT(*) as cnt
|
||||
FROM supplier_catalog
|
||||
WHERE is_active = true
|
||||
GROUP BY category
|
||||
ORDER BY cnt DESC
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'categories': [{'name': r[0], 'count': r[1]} for r in rows]})
|
||||
|
||||
|
||||
# ─── Suppliers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/suppliers', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def list_suppliers():
|
||||
"""Return distinct suppliers with counts."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT supplier_name, COUNT(*) as cnt
|
||||
FROM supplier_catalog
|
||||
WHERE is_active = true
|
||||
GROUP BY supplier_name
|
||||
ORDER BY supplier_name ASC
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'suppliers': [{'name': r[0], 'count': r[1]} for r in rows]})
|
||||
|
||||
|
||||
# ─── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth('inventory.edit')
|
||||
def delete_item(item_id):
|
||||
"""Soft-delete a supplier catalog item."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE supplier_catalog SET is_active = false WHERE id = %s", (item_id,))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
# ─── Prices ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_latest_prices(master_conn, tenant_id, catalog_ids):
|
||||
"""Return a dict catalog_id -> price row for the latest active price per item."""
|
||||
if not catalog_ids:
|
||||
return {}
|
||||
cur = master_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT DISTINCT ON (catalog_id)
|
||||
catalog_id, price, currency, effective_from, effective_to
|
||||
FROM supplier_catalog_prices
|
||||
WHERE tenant_id = %s AND catalog_id = ANY(%s) AND is_active = true
|
||||
AND (effective_to IS NULL OR effective_to >= CURRENT_DATE)
|
||||
ORDER BY catalog_id, effective_from DESC
|
||||
""", (tenant_id, list(catalog_ids)))
|
||||
prices = {}
|
||||
for r in cur.fetchall():
|
||||
prices[r[0]] = {
|
||||
'price': float(r[1]) if r[1] is not None else None,
|
||||
'currency': r[2] or 'MXN',
|
||||
'effective_from': str(r[3]) if r[3] else None,
|
||||
'effective_to': str(r[4]) if r[4] else None,
|
||||
}
|
||||
cur.close()
|
||||
return prices
|
||||
|
||||
|
||||
@supplier_catalog_bp.route('/prices', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def list_prices():
|
||||
"""List active supplier prices for the current tenant."""
|
||||
supplier = (request.args.get('supplier') or '').strip()
|
||||
q = (request.args.get('q') or '').strip()
|
||||
page = max(1, request.args.get('page', 1, type=int))
|
||||
per_page = min(200, request.args.get('per_page', 50, type=int))
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
where_parts = ["sc.is_active = true", "scp.tenant_id = %s"]
|
||||
params = [g.tenant_id]
|
||||
|
||||
if supplier:
|
||||
where_parts.append("sc.supplier_name = %s")
|
||||
params.append(supplier)
|
||||
if q:
|
||||
where_parts.append("(sc.sku ILIKE %s OR sc.name ILIKE %s)")
|
||||
like_q = f'%{q}%'
|
||||
params.extend([like_q, like_q])
|
||||
|
||||
where_sql = " AND ".join(where_parts)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(DISTINCT sc.id)
|
||||
FROM supplier_catalog sc
|
||||
JOIN supplier_catalog_prices scp ON scp.catalog_id = sc.id
|
||||
WHERE {where_sql}
|
||||
AND scp.is_active = true
|
||||
AND (scp.effective_to IS NULL OR scp.effective_to >= CURRENT_DATE)
|
||||
""", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT DISTINCT ON (sc.id)
|
||||
sc.id, sc.supplier_name, sc.sku, sc.name, sc.category,
|
||||
scp.price, scp.currency, scp.effective_from, scp.effective_to
|
||||
FROM supplier_catalog sc
|
||||
JOIN supplier_catalog_prices scp ON scp.catalog_id = sc.id
|
||||
WHERE {where_sql}
|
||||
AND scp.is_active = true
|
||||
AND (scp.effective_to IS NULL OR scp.effective_to >= CURRENT_DATE)
|
||||
ORDER BY sc.id, scp.effective_from DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, offset])
|
||||
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'catalog_id': r[0],
|
||||
'supplier_name': r[1],
|
||||
'sku': r[2],
|
||||
'name': r[3],
|
||||
'category': r[4],
|
||||
'price': float(r[5]) if r[5] is not None else None,
|
||||
'currency': r[6] or 'MXN',
|
||||
'effective_from': str(r[7]) if r[7] else None,
|
||||
'effective_to': str(r[8]) if r[8] else None,
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({
|
||||
'data': items,
|
||||
'pagination': {'page': page, 'per_page': per_page, 'total': total,
|
||||
'total_pages': (total + per_page - 1) // per_page}
|
||||
})
|
||||
|
||||
|
||||
@supplier_catalog_bp.route('/prices/template', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def download_price_template():
|
||||
"""Return a CSV template for uploading supplier prices."""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['supplier_name', 'sku', 'price', 'currency', 'effective_from'])
|
||||
writer.writerow(['YOKOMITSU', 'DENK070A', '1250.00', 'MXN', '2026-01-01'])
|
||||
output.seek(0)
|
||||
return (output.getvalue(), 200, {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="supplier_prices_template.csv"'
|
||||
})
|
||||
|
||||
|
||||
def _read_upload_file(file_storage):
|
||||
"""Read CSV or Excel upload and return list of dict rows."""
|
||||
filename = (file_storage.filename or '').lower()
|
||||
content = file_storage.read()
|
||||
if filename.endswith('.csv'):
|
||||
text = content.decode('utf-8-sig')
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
return [row for row in reader]
|
||||
if filename.endswith(('.xlsx', '.xls')):
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError as e:
|
||||
raise RuntimeError('openpyxl no instalado; sube CSV o instala openpyxl') from e
|
||||
wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True)
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
return []
|
||||
headers = [str(c).strip().lower() if c else '' for c in rows[0]]
|
||||
return [
|
||||
dict(zip(headers, row))
|
||||
for row in rows[1:] if any(cell is not None and str(cell).strip() for cell in row)
|
||||
]
|
||||
raise ValueError('Formato no soportado. Usa CSV o Excel (.xlsx)')
|
||||
|
||||
|
||||
@supplier_catalog_bp.route('/prices/upload', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def upload_prices():
|
||||
"""Bulk upload/upsert supplier prices for the current tenant.
|
||||
|
||||
Expected columns: supplier_name, sku, price, [currency], [effective_from]
|
||||
"""
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'Archivo requerido'}), 400
|
||||
file_storage = request.files['file']
|
||||
if not file_storage or not file_storage.filename:
|
||||
return jsonify({'error': 'Archivo requerido'}), 400
|
||||
|
||||
try:
|
||||
rows = _read_upload_file(file_storage)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
if not rows:
|
||||
return jsonify({'error': 'El archivo esta vacio o no tiene filas validas'}), 400
|
||||
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Build a lookup of supplier+sku -> catalog_id
|
||||
# We expect all rows to refer to existing catalog items.
|
||||
normalized_rows = []
|
||||
errors = []
|
||||
for idx, row in enumerate(rows, start=2):
|
||||
supplier = str(row.get('supplier_name') or '').strip()
|
||||
sku = str(row.get('sku') or '').strip()
|
||||
price_raw = row.get('price')
|
||||
currency = str(row.get('currency') or 'MXN').strip().upper() or 'MXN'
|
||||
eff_from_raw = row.get('effective_from')
|
||||
|
||||
if not supplier or not sku:
|
||||
errors.append(f'Fila {idx}: supplier_name y sku son requeridos')
|
||||
continue
|
||||
|
||||
try:
|
||||
price = float(str(price_raw).replace(',', '').strip())
|
||||
except Exception:
|
||||
errors.append(f'Fila {idx}: precio invalido para {supplier}/{sku}')
|
||||
continue
|
||||
|
||||
eff_from = date.today()
|
||||
if eff_from_raw:
|
||||
try:
|
||||
eff_from = date.fromisoformat(str(eff_from_raw).strip())
|
||||
except Exception:
|
||||
errors.append(f'Fila {idx}: effective_from invalido (use YYYY-MM-DD)')
|
||||
continue
|
||||
|
||||
normalized_rows.append((supplier, sku, price, currency, eff_from))
|
||||
|
||||
if errors:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Errores de validacion', 'details': errors}), 400
|
||||
|
||||
# Bulk lookup catalog IDs
|
||||
catalog_lookup = {}
|
||||
for supplier, sku, *_ in normalized_rows:
|
||||
catalog_lookup[(supplier, sku)] = None
|
||||
|
||||
if catalog_lookup:
|
||||
keys = list(catalog_lookup.keys())
|
||||
# Batch query using unnest
|
||||
cur.execute("""
|
||||
SELECT supplier_name, sku, id
|
||||
FROM supplier_catalog
|
||||
WHERE is_active = true
|
||||
AND (supplier_name, sku) = ANY(%s)
|
||||
""", (keys,))
|
||||
for r in cur.fetchall():
|
||||
catalog_lookup[(r[0], r[1])] = r[2]
|
||||
|
||||
upserts = []
|
||||
for idx, (supplier, sku, price, currency, eff_from) in enumerate(normalized_rows, start=2):
|
||||
catalog_id = catalog_lookup.get((supplier, sku))
|
||||
if not catalog_id:
|
||||
errors.append(f'Fila {idx}: SKU {supplier}/{sku} no existe en el catalogo')
|
||||
continue
|
||||
upserts.append((g.tenant_id, catalog_id, price, currency, eff_from))
|
||||
|
||||
if errors:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Errores de validacion', 'details': errors}), 400
|
||||
|
||||
inserted = 0
|
||||
updated = 0
|
||||
for tenant_id, catalog_id, price, currency, eff_from in upserts:
|
||||
# Try update existing row with same (tenant_id, catalog_id, effective_from)
|
||||
cur.execute("""
|
||||
UPDATE supplier_catalog_prices
|
||||
SET price = %s, currency = %s, is_active = true, updated_at = NOW()
|
||||
WHERE tenant_id = %s AND catalog_id = %s AND effective_from = %s
|
||||
RETURNING id
|
||||
""", (price, currency, tenant_id, catalog_id, eff_from))
|
||||
if cur.fetchone():
|
||||
updated += 1
|
||||
else:
|
||||
cur.execute("""
|
||||
INSERT INTO supplier_catalog_prices
|
||||
(tenant_id, catalog_id, price, currency, effective_from, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, true)
|
||||
""", (tenant_id, catalog_id, price, currency, eff_from))
|
||||
inserted += 1
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'processed': len(upserts),
|
||||
'inserted': inserted,
|
||||
'updated': updated,
|
||||
})
|
||||
105
pos/blueprints/supplier_portal_bp.py
Normal file
105
pos/blueprints/supplier_portal_bp.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Supplier Portal Blueprint — Demand insights for vendors.
|
||||
|
||||
Allows suppliers to view demand by zone, part type, and branch.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import json
|
||||
|
||||
supplier_portal_bp = Blueprint('supplier_portal', __name__, url_prefix='/pos/api/supplier-portal')
|
||||
|
||||
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
|
||||
class DecimalEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
if isinstance(o, Decimal):
|
||||
return float(o)
|
||||
return super().default(o)
|
||||
|
||||
|
||||
@supplier_portal_bp.route('/demand', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_demand():
|
||||
"""Aggregated demand by zone, part group, and time range."""
|
||||
days = request.args.get('days', 30, type=int)
|
||||
branch_id = request.args.get('branch_id', type=int)
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
try:
|
||||
params = [since]
|
||||
filters = "s.created_at >= %s"
|
||||
if branch_id:
|
||||
filters += " AND s.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
|
||||
cur.execute(
|
||||
f"""SELECT b.name as branch_name,
|
||||
COUNT(DISTINCT s.id) as orders,
|
||||
SUM(si.quantity) as qty_requested,
|
||||
COALESCE(SUM(si.subtotal), 0) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id
|
||||
LEFT JOIN branches b ON s.branch_id = b.id
|
||||
WHERE {filters}
|
||||
GROUP BY b.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 100""", tuple(params)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return jsonify({
|
||||
'since': since.isoformat(),
|
||||
'days': days,
|
||||
'demand': [
|
||||
{'branch': row[0] or 'Sin sucursal',
|
||||
'orders': row[1], 'quantity': row[2],
|
||||
'revenue': float(row[3]) if row[3] is not None else 0}
|
||||
for row in rows
|
||||
]
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@supplier_portal_bp.route('/top-parts', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_top_parts():
|
||||
"""Top moving parts for suppliers to restock."""
|
||||
days = request.args.get('days', 30, type=int)
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
try:
|
||||
cur.execute(
|
||||
"""SELECT si.part_number, si.name,
|
||||
SUM(si.quantity) as sold, COALESCE(SUM(si.subtotal), 0) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id
|
||||
WHERE s.created_at >= %s
|
||||
GROUP BY si.part_number, si.name
|
||||
ORDER BY sold DESC
|
||||
LIMIT 50""", (since,)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return jsonify({
|
||||
'since': since.isoformat(),
|
||||
'parts': [
|
||||
{'part_number': row[0], 'name': row[1],
|
||||
'sold': row[2], 'revenue': float(row[3]) if row[3] is not None else 0}
|
||||
for row in rows
|
||||
]
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Blueprint for background task management (Celery)."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from auth import require_auth
|
||||
from middleware import require_auth
|
||||
from tasks import warm_vehicle_cache_task, generate_report_task
|
||||
|
||||
tasks_bp = Blueprint('tasks', __name__, url_prefix='/pos/api/tasks')
|
||||
|
||||
|
||||
@tasks_bp.route('/warm-cache', methods=['POST'])
|
||||
@require_auth
|
||||
@require_auth()
|
||||
def enqueue_warm_cache():
|
||||
"""Enqueue vehicle cache warming task."""
|
||||
task = warm_vehicle_cache_task.apply_async()
|
||||
@@ -16,7 +16,7 @@ def enqueue_warm_cache():
|
||||
|
||||
|
||||
@tasks_bp.route('/report', methods=['POST'])
|
||||
@require_auth
|
||||
@require_auth()
|
||||
def enqueue_report():
|
||||
"""Enqueue report generation task."""
|
||||
data = request.get_json() or {}
|
||||
|
||||
@@ -13,15 +13,145 @@ Endpoints:
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from services import whatsapp_service
|
||||
from config import WHATSAPP_BRIDGE_URL, WHATSAPP_BRIDGE_KEY
|
||||
from datetime import datetime
|
||||
|
||||
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
||||
|
||||
|
||||
def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn):
|
||||
def _get_whatsapp_config(conn):
|
||||
"""Read WhatsApp bridge configuration from tenant_config.
|
||||
Falls back to global server config (config.py / env vars) when tenant
|
||||
has no explicit WhatsApp settings. This allows the shared bridge to work
|
||||
out of the box for all tenants.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'")
|
||||
config = {row[0]: row[1] for row in cur.fetchall()}
|
||||
cur.close()
|
||||
|
||||
bridge_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL or ''
|
||||
bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or ''
|
||||
enabled_raw = config.get('whatsapp_enabled', '').lower()
|
||||
if enabled_raw == 'true':
|
||||
enabled = True
|
||||
elif enabled_raw == 'false':
|
||||
enabled = False
|
||||
else:
|
||||
# No explicit tenant setting: auto-enable if a bridge URL is configured
|
||||
enabled = bool(bridge_url)
|
||||
|
||||
return {
|
||||
'bridge_url': bridge_url,
|
||||
'bridge_key': bridge_key,
|
||||
'enabled': enabled,
|
||||
'phone_number': config.get('whatsapp_phone_number', ''),
|
||||
}
|
||||
|
||||
|
||||
def _get_branch_phone(tenant_conn, branch_id=None):
|
||||
"""Obtener teléfono de la sucursal."""
|
||||
if not tenant_conn:
|
||||
return '(pendiente)'
|
||||
try:
|
||||
cur = tenant_conn.cursor()
|
||||
if branch_id:
|
||||
cur.execute("SELECT phone FROM branches WHERE id = %s", (branch_id,))
|
||||
row = cur.fetchone()
|
||||
if row and row[0]:
|
||||
cur.close()
|
||||
return row[0]
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'tenant_phone'")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return row[0] if row and row[0] else '(pendiente)'
|
||||
except Exception as e:
|
||||
print(f"[WA-SM] get_branch_phone error: {e}")
|
||||
return '(pendiente)'
|
||||
|
||||
|
||||
def _resolve_mye_ids(vehicle, master_conn):
|
||||
"""Return list of MYE ids matching vehicle brand/model/year text."""
|
||||
if not master_conn or not vehicle:
|
||||
return []
|
||||
brand = vehicle.get('brand', '').strip()
|
||||
model = vehicle.get('model', '').strip()
|
||||
year = str(vehicle.get('year', '')).strip()
|
||||
if not brand and not model:
|
||||
return []
|
||||
cur = master_conn.cursor()
|
||||
clauses = []
|
||||
params = []
|
||||
if brand:
|
||||
clauses.append("b.name_brand ILIKE %s")
|
||||
params.append(f'%{brand}%')
|
||||
if model:
|
||||
clauses.append("m.name_model ILIKE %s")
|
||||
params.append(f'%{model}%')
|
||||
if year and year.isdigit():
|
||||
clauses.append("y.year_car = %s")
|
||||
params.append(int(year))
|
||||
if not clauses:
|
||||
cur.close()
|
||||
return []
|
||||
cur.execute(f"""
|
||||
SELECT mye.id_mye
|
||||
FROM model_year_engine mye
|
||||
JOIN models m ON m.id_model = mye.model_id
|
||||
JOIN brands b ON b.id_brand = m.brand_id
|
||||
JOIN years y ON y.id_year = mye.year_id
|
||||
WHERE {' AND '.join(clauses)}
|
||||
LIMIT 50
|
||||
""", tuple(params))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _get_conversation_history(phone, tenant_conn, limit=4):
|
||||
"""Fetch recent messages for *phone* to give the AI conversation context.
|
||||
|
||||
Includes both user and assistant messages, truncated to keep token count low.
|
||||
The most recent message (the one currently being processed) is excluded.
|
||||
"""
|
||||
if not tenant_conn or not phone:
|
||||
return []
|
||||
try:
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT direction, message_text
|
||||
FROM whatsapp_messages
|
||||
WHERE phone = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET 1
|
||||
""", (phone, limit))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
# Reverse so oldest-first (chronological) for the LLM
|
||||
history = []
|
||||
for direction, text in reversed(rows):
|
||||
if not text:
|
||||
continue
|
||||
role = "assistant" if direction == "outgoing" else "user"
|
||||
# Truncate assistant replies more aggressively (they contain JSON/tables)
|
||||
max_len = 200 if role == "assistant" else 300
|
||||
truncated = text[:max_len] + ('...' if len(text) > max_len else '')
|
||||
history.append({"role": role, "content": truncated})
|
||||
return history
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Failed to load conversation history: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=None):
|
||||
"""Search the refaccionaria's LOCAL inventory and build a WhatsApp reply.
|
||||
|
||||
If *vehicle* is provided and we have a master_conn, we first look up the
|
||||
MYE ids for that vehicle and JOIN through inventory_vehicle_compat so we
|
||||
only show parts that are known to fit the user's car.
|
||||
|
||||
Returns:
|
||||
(formatted_text, first_part_dict) — first_part_dict is used by the
|
||||
quotation system to know what to add when the user says "cotizar".
|
||||
@@ -31,101 +161,125 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn):
|
||||
return None, None
|
||||
|
||||
try:
|
||||
# Translate common English search terms to Spanish for local inventory
|
||||
# (the AI sends search_query in English, but local inventory names
|
||||
# are often in Spanish)
|
||||
from services.translations import PART_TRANSLATIONS
|
||||
search_terms = [search_query]
|
||||
# Add the Spanish translation if we have one
|
||||
for en, es in PART_TRANSLATIONS.items():
|
||||
if en.upper() in search_query.upper():
|
||||
search_terms.append(es)
|
||||
break
|
||||
|
||||
# Build ILIKE conditions for all search terms
|
||||
conditions = []
|
||||
params = []
|
||||
for term in search_terms:
|
||||
conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)")
|
||||
like = f'%{term}%'
|
||||
params.extend([like, like, like])
|
||||
# Split search_query by '|' into individual terms
|
||||
raw_terms = [t.strip() for t in (search_query or '').split('|') if t.strip()]
|
||||
if not raw_terms:
|
||||
raw_terms = [search_query] if search_query else []
|
||||
|
||||
where_search = ' OR '.join(conditions)
|
||||
# Translate each term to Spanish if possible
|
||||
search_terms = set()
|
||||
for term in raw_terms:
|
||||
search_terms.add(term)
|
||||
# Check if any English translation matches
|
||||
for en, es in PART_TRANSLATIONS.items():
|
||||
if en.upper() == term.upper():
|
||||
search_terms.add(es)
|
||||
break
|
||||
# Also check if the term contains an English word
|
||||
if en.upper() in term.upper():
|
||||
search_terms.add(term.upper().replace(en.upper(), es))
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(f"""
|
||||
SELECT i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
|
||||
COALESCE(s.stock, 0) AS stock,
|
||||
i.unit, i.location
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(quantity) AS stock
|
||||
FROM inventory_operations
|
||||
GROUP BY inventory_id
|
||||
) s ON s.inventory_id = i.id
|
||||
WHERE i.is_active = TRUE
|
||||
AND ({where_search})
|
||||
ORDER BY
|
||||
COALESCE(s.stock, 0) > 0 DESC,
|
||||
i.name
|
||||
LIMIT 10
|
||||
""", params)
|
||||
search_terms = list(search_terms)
|
||||
if not search_terms:
|
||||
return None, None
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
# Vehicle-aware filtering
|
||||
mye_ids = _resolve_mye_ids(vehicle, master_conn)
|
||||
|
||||
if not rows:
|
||||
return ('❌ No tenemos esa parte en inventario actualmente.\n'
|
||||
'_Puedes preguntar por otra parte o visitarnos en tienda._'), None
|
||||
def _do_search(use_compat=True):
|
||||
"""Run inventory search. Returns list of rows."""
|
||||
conditions = []
|
||||
params = []
|
||||
for term in search_terms:
|
||||
conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)")
|
||||
like = f'%{term}%'
|
||||
params.extend([like, like, like])
|
||||
|
||||
# Split into in-stock and out-of-stock
|
||||
in_stock = [r for r in rows if r[6] > 0]
|
||||
out_stock = [r for r in rows if r[6] <= 0]
|
||||
where_search = ' OR '.join(conditions)
|
||||
compat_clause = ""
|
||||
if use_compat and mye_ids:
|
||||
compat_clause = f"AND i.id IN (SELECT inventory_id FROM inventory_vehicle_compat WHERE model_year_engine_id IN ({','.join(['%s']*len(mye_ids))}))"
|
||||
params.extend(mye_ids)
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
|
||||
COALESCE(s.stock, 0) AS stock,
|
||||
i.unit, i.location
|
||||
FROM inventory i
|
||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||
WHERE i.is_active = TRUE
|
||||
AND ({where_search})
|
||||
{compat_clause}
|
||||
ORDER BY
|
||||
COALESCE(s.stock, 0) > 0 DESC,
|
||||
i.name
|
||||
LIMIT 10
|
||||
""", params)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return rows
|
||||
|
||||
# 1. Try with vehicle compatibility filter
|
||||
rows = _do_search(use_compat=True)
|
||||
compat_filter_applied = bool(mye_ids)
|
||||
|
||||
# 2. If no results with compatibility, try WITHOUT filter
|
||||
fallback_rows = []
|
||||
if not rows and mye_ids:
|
||||
fallback_rows = _do_search(use_compat=False)
|
||||
|
||||
if not rows and not fallback_rows:
|
||||
# Nothing found in local inventory — let the AI's original response stand.
|
||||
# The webhook will append a soft note instead of replacing the message.
|
||||
return None, None
|
||||
|
||||
# Use fallback rows if primary search returned nothing
|
||||
using_fallback = False
|
||||
if not rows and fallback_rows:
|
||||
rows = fallback_rows
|
||||
using_fallback = True
|
||||
|
||||
in_stock = [r for r in rows if r[7] > 0]
|
||||
out_stock = [r for r in rows if r[7] <= 0]
|
||||
|
||||
# Build the first-part dict for quotation tracking
|
||||
# Use the first in-stock part, or first out-of-stock if none available
|
||||
best = in_stock[0] if in_stock else (out_stock[0] if out_stock else None)
|
||||
first_part = None
|
||||
if best:
|
||||
first_part = {
|
||||
'inventory_id': None, # we'd need the id — fetch it
|
||||
'part_number': best[0],
|
||||
'name': best[1],
|
||||
'brand': best[2] or '',
|
||||
'price': float(best[3]) if best[3] else 0,
|
||||
'inventory_id': best[0],
|
||||
'part_number': best[1],
|
||||
'name': best[2],
|
||||
'brand': best[3] or '',
|
||||
'price': float(best[4]) if best[4] else 0,
|
||||
'tax_rate': 0.16,
|
||||
'stock': best[6],
|
||||
'unit': best[7] or 'PZA',
|
||||
'stock': best[7],
|
||||
'unit': best[8] or 'PZA',
|
||||
}
|
||||
# Fetch the inventory ID for the quotation item FK
|
||||
try:
|
||||
cur2 = tenant_conn.cursor()
|
||||
cur2.execute("SELECT id FROM inventory WHERE part_number = %s AND is_active = TRUE LIMIT 1",
|
||||
(best[0],))
|
||||
inv_row = cur2.fetchone()
|
||||
if inv_row:
|
||||
first_part['inventory_id'] = inv_row[0]
|
||||
cur2.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lines = []
|
||||
|
||||
if using_fallback:
|
||||
lines.append("⚠️ *No encontré partes verificadas para tu vehículo, pero sí tengo estas opciones generales:*")
|
||||
lines.append("")
|
||||
|
||||
if in_stock:
|
||||
lines.append('✅ *Tenemos en stock:*')
|
||||
lines.append('')
|
||||
for r in in_stock:
|
||||
part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||||
inv_id, part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||||
brand_str = f'*{brand}*' if brand else ''
|
||||
price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio'
|
||||
lines.append(f' • {brand_str} {name}')
|
||||
lines.append(f' #{part_num} — {price_str} ({stock} {unit or "pzas"} disponibles)')
|
||||
lines.append('')
|
||||
else:
|
||||
elif out_stock:
|
||||
lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*')
|
||||
lines.append('')
|
||||
for r in out_stock[:5]:
|
||||
part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||||
inv_id, part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||||
brand_str = f'*{brand}*' if brand else ''
|
||||
price_str = f'${float(p1):,.2f}' if p1 else ''
|
||||
lines.append(f' • {brand_str} {name} #{part_num} {price_str}')
|
||||
@@ -143,42 +297,61 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn):
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Enrichment error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None, None
|
||||
return None, None
|
||||
|
||||
|
||||
@whatsapp_bp.route('/status', methods=['GET'])
|
||||
@require_auth()
|
||||
def status():
|
||||
return jsonify(whatsapp_service.get_status())
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cfg = _get_whatsapp_config(conn)
|
||||
conn.close()
|
||||
if not cfg['enabled'] or not cfg['bridge_url']:
|
||||
return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'})
|
||||
return jsonify(whatsapp_service.get_status(bridge_url=cfg['bridge_url']))
|
||||
|
||||
|
||||
@whatsapp_bp.route('/qr', methods=['GET'])
|
||||
@require_auth()
|
||||
def qr():
|
||||
return jsonify(whatsapp_service.get_qr())
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cfg = _get_whatsapp_config(conn)
|
||||
conn.close()
|
||||
if not cfg['enabled'] or not cfg['bridge_url']:
|
||||
return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'})
|
||||
return jsonify(whatsapp_service.get_qr(bridge_url=cfg['bridge_url']))
|
||||
|
||||
|
||||
@whatsapp_bp.route('/connect', methods=['POST'])
|
||||
@require_auth()
|
||||
def connect():
|
||||
return jsonify(whatsapp_service.connect())
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cfg = _get_whatsapp_config(conn)
|
||||
conn.close()
|
||||
if not cfg['enabled'] or not cfg['bridge_url']:
|
||||
return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400
|
||||
return jsonify(whatsapp_service.connect(bridge_url=cfg['bridge_url']))
|
||||
|
||||
|
||||
@whatsapp_bp.route('/logout', methods=['POST'])
|
||||
@require_auth()
|
||||
def logout():
|
||||
return jsonify(whatsapp_service.logout())
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cfg = _get_whatsapp_config(conn)
|
||||
conn.close()
|
||||
if not cfg['enabled'] or not cfg['bridge_url']:
|
||||
return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400
|
||||
return jsonify(whatsapp_service.logout(bridge_url=cfg['bridge_url']))
|
||||
|
||||
|
||||
@whatsapp_bp.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
"""Receive messages from Baileys bridge (public, no auth).
|
||||
|
||||
Flow:
|
||||
1. Persist the incoming message to the tenant's whatsapp_messages log.
|
||||
2. Build inventory context for the AI (what this tenant has in stock).
|
||||
3. Ask the chatbot for a reply, enriched with that context.
|
||||
4. Send the reply back via the Baileys bridge.
|
||||
Nuevo flujo: máquina de estados estructurada.
|
||||
"""
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
|
||||
@@ -189,208 +362,228 @@ def webhook():
|
||||
if not msg.get('phone') or msg.get('from_me'):
|
||||
return jsonify({'ok': True})
|
||||
|
||||
# Reuse one tenant connection for the whole webhook path — we need it
|
||||
# for persistence AND for the inventory-context lookup.
|
||||
# TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives.
|
||||
tenant_id = 11
|
||||
phone = msg['phone']
|
||||
reply_to = msg.get('sender_pn') or msg.get('jid') or phone
|
||||
text = msg.get('text', '')
|
||||
media_kind = msg.get('media_kind', 'text')
|
||||
|
||||
# Audio transcription (voice notes)
|
||||
if media_kind == 'audio' and msg.get('media_base64'):
|
||||
try:
|
||||
from services.whisper_local import transcribe_audio_base64
|
||||
transcript = transcribe_audio_base64(
|
||||
msg['media_base64'],
|
||||
mimetype=msg.get('media_mimetype') or 'audio/ogg',
|
||||
)
|
||||
if transcript:
|
||||
text = transcript
|
||||
print(f"[WA-SM] Voice note transcribed: {transcript[:100]}")
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[WA-SM] Whisper transcription failed: {e}")
|
||||
|
||||
# Location message: if current state expects it, store coordinates
|
||||
if media_kind == 'location' and msg.get('latitude') is not None:
|
||||
text = f"Ubicación: {msg['latitude']},{msg['longitude']}"
|
||||
|
||||
# Image without caption: provide a default text so the state machine can handle it
|
||||
if media_kind == 'image' and not text:
|
||||
text = "(imagen)"
|
||||
|
||||
|
||||
# Resolve tenant
|
||||
tenant_id = request.args.get('tenant_id', type=int)
|
||||
if not tenant_id:
|
||||
try:
|
||||
mconn = get_master_conn()
|
||||
mcur = mconn.cursor()
|
||||
mcur.execute("""
|
||||
SELECT id, db_name FROM tenants
|
||||
WHERE is_active = true
|
||||
ORDER BY id
|
||||
""")
|
||||
tenants = mcur.fetchall()
|
||||
mcur.close()
|
||||
mconn.close()
|
||||
# Find first tenant with whatsapp_enabled in their config
|
||||
for tid, db_name in tenants:
|
||||
try:
|
||||
from tenant_db import get_tenant_conn_by_dbname
|
||||
tconn = get_tenant_conn_by_dbname(db_name)
|
||||
tcur = tconn.cursor()
|
||||
tcur.execute(
|
||||
"SELECT value FROM tenant_config WHERE key = 'whatsapp_enabled'"
|
||||
)
|
||||
row = tcur.fetchone()
|
||||
tcur.close()
|
||||
tconn.close()
|
||||
if row and row[0].lower() == 'true':
|
||||
tenant_id = tid
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
tenant_id = None
|
||||
|
||||
tenant_conn = None
|
||||
inventory_context = None
|
||||
master_conn = None
|
||||
|
||||
try:
|
||||
tenant_conn = get_tenant_conn(tenant_id)
|
||||
master_conn = get_master_conn()
|
||||
wa_config = _get_whatsapp_config(tenant_conn)
|
||||
|
||||
# 1. Log the incoming message (with contact display name)
|
||||
# Deduplicate by wa_message_id
|
||||
wa_message_id = msg.get('message_id')
|
||||
if wa_message_id:
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("SELECT 1 FROM whatsapp_messages WHERE wa_message_id = %s LIMIT 1", (wa_message_id,))
|
||||
if cur.fetchone():
|
||||
cur.close()
|
||||
return jsonify({'ok': True})
|
||||
cur.close()
|
||||
|
||||
# 1. Log incoming message
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
|
||||
VALUES (%s, 'incoming', %s, %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", (msg['phone'], msg['text'], msg['message_id'], msg.get('push_name') or None))
|
||||
""", (phone, text, wa_message_id, msg.get('push_name')))
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
|
||||
# 2. Build inventory context once per webhook call so the chatbot
|
||||
# can say things like "tengo 5 Bosch BP-123 por $450".
|
||||
try:
|
||||
from services.ai_chat import get_inventory_context
|
||||
inventory_context = get_inventory_context(tenant_conn)
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] inventory_context failed: {e}")
|
||||
inventory_context = None
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] tenant connection failed: {e}")
|
||||
# 2. Load session state
|
||||
from services.wa_state_machine import get_session, save_session, process_message, StateContext
|
||||
session = get_session(tenant_conn, phone)
|
||||
|
||||
# 3. Dispatch by media kind + quotation commands
|
||||
reply = None
|
||||
reply_to = msg.get('jid') or msg['phone']
|
||||
media_kind = msg.get('media_kind', 'text')
|
||||
clean_phone = msg.get('phone', '')
|
||||
# 3. Check session expiry (30 minutes)
|
||||
current_state = session.get('state', 'idle')
|
||||
state_data = session.get('state_data', {})
|
||||
last_updated = session.get('updated_at')
|
||||
|
||||
# ── Check for quotation commands FIRST (before AI) ──
|
||||
if media_kind == 'text' and msg.get('text'):
|
||||
from services.wa_quotation import (
|
||||
detect_quote_intent, get_open_quotation, create_quotation,
|
||||
add_item_to_quotation, get_quotation_detail, format_quotation_wa,
|
||||
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part,
|
||||
)
|
||||
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone))
|
||||
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open)
|
||||
|
||||
if intent == 'add':
|
||||
last_part = get_last_shown_part(clean_phone)
|
||||
if not last_part:
|
||||
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
|
||||
elif tenant_conn:
|
||||
qid = get_open_quotation(tenant_conn, clean_phone)
|
||||
if not qid:
|
||||
qid = create_quotation(tenant_conn, clean_phone)
|
||||
add_item_to_quotation(tenant_conn, qid, last_part, quantity=qty or 1)
|
||||
detail = get_quotation_detail(tenant_conn, qid)
|
||||
item_count = len(detail['items']) if detail else 0
|
||||
reply = (
|
||||
f'✅ *{last_part.get("name", "")}* × {qty or 1} agregado a tu cotización.\n'
|
||||
f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n'
|
||||
f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._'
|
||||
)
|
||||
|
||||
elif intent == 'send':
|
||||
if tenant_conn:
|
||||
qid = get_open_quotation(tenant_conn, clean_phone)
|
||||
if qid:
|
||||
detail = get_quotation_detail(tenant_conn, qid)
|
||||
reply = format_quotation_wa(detail)
|
||||
if not reply:
|
||||
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
|
||||
else:
|
||||
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
|
||||
|
||||
elif intent == 'clear':
|
||||
if tenant_conn:
|
||||
clear_quotation(tenant_conn, clean_phone)
|
||||
reply = '🗑️ Cotización limpiada. Pregunta por partes para empezar una nueva.'
|
||||
|
||||
elif intent == 'confirm':
|
||||
if tenant_conn:
|
||||
qid = confirm_quotation(tenant_conn, clean_phone)
|
||||
if qid:
|
||||
reply = (
|
||||
f'✅ *Pedido confirmado!*\n\n'
|
||||
f'Tu cotización #{qid} fue registrada.\n'
|
||||
f'Nos pondremos en contacto contigo para coordinar la entrega/recolección.\n\n'
|
||||
f'¡Gracias por tu compra! 🙏'
|
||||
)
|
||||
else:
|
||||
reply = '⚠️ No tienes una cotización abierta para confirmar.'
|
||||
|
||||
if intent is not None:
|
||||
# It was a quote command — send reply and skip the AI
|
||||
if reply:
|
||||
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
|
||||
# Clean up and return early
|
||||
if tenant_conn:
|
||||
try: tenant_conn.close()
|
||||
except Exception: pass
|
||||
return jsonify({'ok': True})
|
||||
|
||||
try:
|
||||
if media_kind == 'image' and msg.get('media_base64'):
|
||||
from services.ai_chat import chat_with_image
|
||||
# Prompt: use the caption if provided, else default to
|
||||
# "identify this part" which chat_with_image handles gracefully.
|
||||
prompt = msg.get('text') or 'Identifica esta parte automotriz y sugiere terminos de busqueda.'
|
||||
ai_resp = chat_with_image(
|
||||
user_message=prompt,
|
||||
image_base64=msg['media_base64'],
|
||||
inventory_context=inventory_context,
|
||||
)
|
||||
reply = ai_resp.get('message', '') or ''
|
||||
print(f"[WA-AI] Image from {reply_to}: {reply[:80]}...")
|
||||
|
||||
elif media_kind == 'audio' and msg.get('media_base64'):
|
||||
# Voice note handling — transcribe first, then chat().
|
||||
# See services.whisper_local for the transcriber.
|
||||
if last_updated and hasattr(last_updated, 'strftime'):
|
||||
# PostgreSQL returns datetime objects (often timezone-aware)
|
||||
from datetime import timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
if last_updated.tzinfo is None:
|
||||
now = now.replace(tzinfo=None)
|
||||
elapsed = (now - last_updated).total_seconds()
|
||||
if elapsed > 1800:
|
||||
current_state = 'idle'
|
||||
state_data = {'customer_id': state_data.get('customer_id')}
|
||||
elif last_updated and isinstance(last_updated, str):
|
||||
from datetime import datetime as dt
|
||||
try:
|
||||
from services.whisper_local import transcribe_audio_base64
|
||||
transcript = transcribe_audio_base64(
|
||||
msg['media_base64'],
|
||||
mimetype=msg.get('media_mimetype') or 'audio/ogg',
|
||||
parsed = dt.fromisoformat(last_updated.replace('Z', '+00:00'))
|
||||
elapsed = (dt.now(dt.now().astimezone().tzinfo) - parsed).total_seconds()
|
||||
if elapsed > 1800:
|
||||
current_state = 'idle'
|
||||
state_data = {'customer_id': state_data.get('customer_id')}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Global reset commands work from any state
|
||||
if text and text.strip().lower() in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar', 'menu', 'menú'):
|
||||
current_state = 'idle'
|
||||
state_data = {'customer_id': state_data.get('customer_id')}
|
||||
|
||||
# Abandoned quotation follow-up
|
||||
try:
|
||||
from services.part_kits import should_send_followup
|
||||
followup = should_send_followup(phone, tenant_conn)
|
||||
if followup:
|
||||
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
|
||||
cur_fu = tenant_conn.cursor()
|
||||
cur_fu.execute(
|
||||
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
|
||||
(phone, followup)
|
||||
)
|
||||
except ImportError:
|
||||
transcript = None
|
||||
print("[WA-AI] whisper_local not installed — voice notes skipped")
|
||||
except Exception as e:
|
||||
transcript = None
|
||||
print(f"[WA-AI] Whisper transcription failed: {e}")
|
||||
tenant_conn.commit()
|
||||
cur_fu.close()
|
||||
except Exception as fu_err:
|
||||
print(f"[WA-SM] Follow-up send failed: {fu_err}")
|
||||
|
||||
if transcript:
|
||||
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
|
||||
from services.ai_chat import chat
|
||||
ai_resp = chat(transcript, inventory_context=inventory_context)
|
||||
reply = ai_resp.get('message', '') or ''
|
||||
# Prefix the reply so the sender knows we understood the voice note
|
||||
if reply:
|
||||
reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}'
|
||||
else:
|
||||
reply = ('Recibi tu nota de voz pero no pude transcribirla. '
|
||||
'Puedes escribirme el mensaje?')
|
||||
# 4. Build context
|
||||
context = StateContext(
|
||||
tenant_conn=tenant_conn,
|
||||
master_conn=master_conn,
|
||||
wa_config=wa_config,
|
||||
tenant_id=tenant_id,
|
||||
phone=phone,
|
||||
media_kind=media_kind,
|
||||
media_base64=msg.get('media_base64'),
|
||||
push_name=msg.get('push_name'),
|
||||
)
|
||||
|
||||
elif msg.get('text'):
|
||||
# Plain text message — standard chatbot flow
|
||||
from services.ai_chat import chat
|
||||
ai_resp = chat(msg['text'], inventory_context=inventory_context)
|
||||
reply = ai_resp.get('message', '') or ''
|
||||
# 5. Process through state machine
|
||||
reply, next_state, next_state_data = process_message(
|
||||
phone=phone,
|
||||
text=text,
|
||||
current_state=current_state,
|
||||
state_data=state_data,
|
||||
context=context,
|
||||
)
|
||||
|
||||
# Enrich: if the AI returned a search_query, look up real parts
|
||||
# from the catalog and append them to the WhatsApp reply.
|
||||
search_q = ai_resp.get('search_query')
|
||||
vehicle = ai_resp.get('vehicle')
|
||||
if search_q and reply:
|
||||
try:
|
||||
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn)
|
||||
if enrichment:
|
||||
reply = reply + '\n\n' + enrichment
|
||||
# Track the found part so "cotizar" can add it
|
||||
if found_part:
|
||||
from services.wa_quotation import set_last_shown_part
|
||||
set_last_shown_part(clean_phone, found_part)
|
||||
except Exception as enrich_err:
|
||||
print(f"[WA-AI] Enrichment failed: {enrich_err}")
|
||||
# 5b. Si el estado transicionó sin mensaje, procesar el siguiente inmediatamente
|
||||
# (algunos estados solo hacen transiciones y delegan el mensaje al siguiente estado)
|
||||
loop_guard = 0
|
||||
while reply is None and loop_guard < 5:
|
||||
loop_guard += 1
|
||||
reply, next_state, next_state_data = process_message(
|
||||
phone=phone,
|
||||
text=text,
|
||||
current_state=next_state,
|
||||
state_data=next_state_data,
|
||||
context=context,
|
||||
)
|
||||
|
||||
# Send reply if we produced one
|
||||
# 6. Save new state
|
||||
save_session(tenant_conn, phone, next_state, next_state_data)
|
||||
|
||||
# 7. Send reply
|
||||
if reply:
|
||||
result = whatsapp_service.send_message(reply_to, reply)
|
||||
print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}")
|
||||
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
||||
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
|
||||
|
||||
# Save the bot's reply to DB so it shows in the WhatsApp UI
|
||||
if tenant_conn:
|
||||
try:
|
||||
cur2 = tenant_conn.cursor()
|
||||
cur2.execute("""
|
||||
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
||||
VALUES (%s, 'outgoing', %s)
|
||||
""", (msg['phone'], reply))
|
||||
tenant_conn.commit()
|
||||
cur2.close()
|
||||
except Exception as db_err:
|
||||
print(f"[WA-AI] Failed to save bot reply to DB: {db_err}")
|
||||
# Log outgoing
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
||||
VALUES (%s, 'outgoing', %s)
|
||||
""", (phone, reply))
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}")
|
||||
|
||||
# 4. Clean up the connection
|
||||
if tenant_conn is not None:
|
||||
print(f"[WA-SM] Webhook error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
# Fallback: enviar mensaje de error genérico
|
||||
try:
|
||||
tenant_conn.close()
|
||||
if tenant_conn:
|
||||
phone_branch = _get_branch_phone(tenant_conn, None)
|
||||
fallback = (
|
||||
"Estoy teniendo problemas técnicos en este momento. 😕\n\n"
|
||||
f"Por favor llámanos directamente al {phone_branch}."
|
||||
)
|
||||
whatsapp_service.send_message(reply_to, fallback, bridge_url=wa_config.get('bridge_url'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
finally:
|
||||
if tenant_conn:
|
||||
try:
|
||||
tenant_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if master_conn:
|
||||
try:
|
||||
master_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
@@ -403,11 +596,17 @@ def send():
|
||||
if not phone or not message:
|
||||
return jsonify({'error': 'phone and message required'}), 400
|
||||
|
||||
result = whatsapp_service.send_message(phone, message)
|
||||
# Load tenant WhatsApp config
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cfg = _get_whatsapp_config(conn)
|
||||
if not cfg['enabled'] or not cfg['bridge_url']:
|
||||
conn.close()
|
||||
return jsonify({'error': 'WhatsApp not configured for this tenant'}), 400
|
||||
|
||||
result = whatsapp_service.send_message(phone, message, bridge_url=cfg['bridge_url'])
|
||||
|
||||
# Save outgoing message
|
||||
try:
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
||||
@@ -415,9 +614,10 @@ def send():
|
||||
""", (phone, message))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
86
pos/blueprints/whatsapp_cloud_bp.py
Normal file
86
pos/blueprints/whatsapp_cloud_bp.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""WhatsApp Business API (Meta Cloud) Blueprint.
|
||||
|
||||
Replaces Baileys webhook for scalable production messaging.
|
||||
Stubs ready for Meta Cloud API credentials.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from functools import wraps
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
whatsapp_cloud_bp = Blueprint('whatsapp_cloud', __name__, url_prefix='/pos/api/whatsapp-cloud')
|
||||
|
||||
|
||||
from middleware import require_auth
|
||||
|
||||
|
||||
_mock_messages = {}
|
||||
|
||||
|
||||
@whatsapp_cloud_bp.route('/webhook', methods=['GET', 'POST'])
|
||||
def webhook():
|
||||
"""Meta Cloud API webhook verification and message reception."""
|
||||
if request.method == 'GET':
|
||||
# Verification challenge
|
||||
mode = request.args.get('hub.mode')
|
||||
token = request.args.get('hub.verify_token')
|
||||
challenge = request.args.get('hub.challenge')
|
||||
# In production: verify token against configured VERIFY_TOKEN
|
||||
if mode == 'subscribe' and challenge:
|
||||
return challenge, 200
|
||||
return jsonify({'error': 'Verification failed'}), 403
|
||||
|
||||
# POST — incoming messages
|
||||
data = request.get_json() or {}
|
||||
# In production: process entries, messages, statuses
|
||||
return jsonify({'received': True, 'entries': len(data.get('entry', []))}), 200
|
||||
|
||||
|
||||
@whatsapp_cloud_bp.route('/messages', methods=['POST'])
|
||||
@require_auth()
|
||||
def send_message():
|
||||
"""Send a message via Meta Cloud API."""
|
||||
data = request.get_json() or {}
|
||||
to = data.get('to')
|
||||
body = data.get('body')
|
||||
template = data.get('template')
|
||||
|
||||
if not to or (not body and not template):
|
||||
return jsonify({'error': 'to and body/template are required'}), 400
|
||||
|
||||
msg_id = str(uuid.uuid4())
|
||||
_mock_messages[msg_id] = {
|
||||
'id': msg_id,
|
||||
'to': to,
|
||||
'body': body,
|
||||
'template': template,
|
||||
'status': 'sent',
|
||||
'sent_at': datetime.utcnow().isoformat(),
|
||||
}
|
||||
return jsonify(_mock_messages[msg_id]), 201
|
||||
|
||||
|
||||
@whatsapp_cloud_bp.route('/templates', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_templates():
|
||||
"""List approved message templates."""
|
||||
return jsonify({
|
||||
'templates': [
|
||||
{'name': 'order_ready', 'language': 'es_MX', 'category': 'UTILITY', 'status': 'APPROVED'},
|
||||
{'name': 'payment_reminder', 'language': 'es_MX', 'category': 'UTILITY', 'status': 'APPROVED'},
|
||||
{'name': 'welcome_message', 'language': 'es_MX', 'category': 'MARKETING', 'status': 'PENDING'},
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@whatsapp_cloud_bp.route('/status', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_status():
|
||||
"""Check Meta Cloud API connection status."""
|
||||
return jsonify({
|
||||
'connected': False,
|
||||
'phone_number_id': None,
|
||||
'business_account_id': None,
|
||||
'message_limit': None,
|
||||
'note': 'Configure WHATSAPP_CLOUD_ACCESS_TOKEN and PHONE_NUMBER_ID to connect',
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
"appName": "Nexus POS",
|
||||
"webDir": "www",
|
||||
"server": {
|
||||
"url": "https://nexus.consultoria-as.com/pos",
|
||||
"url": "https://pos.nexusautoparts.com.mx/pos",
|
||||
"cleartext": true
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -43,12 +43,21 @@ if not OPENROUTER_API_KEY:
|
||||
RuntimeWarning
|
||||
)
|
||||
|
||||
# ─── Hermes Agent ──────────────────────────────────────────────────────────
|
||||
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://192.168.10.71:8642/v1")
|
||||
HERMES_API_KEY = os.environ.get("HERMES_API_KEY")
|
||||
if not HERMES_API_KEY:
|
||||
warnings.warn(
|
||||
"HERMES_API_KEY not set. Hermes Agent integration will fall back to OpenRouter.",
|
||||
RuntimeWarning
|
||||
)
|
||||
|
||||
# ─── SMTP ──────────────────────────────────────────────────────────────────
|
||||
SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com')
|
||||
SMTP_PORT = int(os.environ.get('SMTP_PORT', '587'))
|
||||
SMTP_USER = os.environ.get('SMTP_USER', '')
|
||||
SMTP_PASS = os.environ.get('SMTP_PASS', '')
|
||||
SMTP_FROM = os.environ.get('SMTP_FROM', 'noreply@nexusautoparts.com')
|
||||
SMTP_FROM = os.environ.get('SMTP_FROM', 'noreply@nexusautoparts.com.mx')
|
||||
|
||||
# ─── WhatsApp Bridge ───────────────────────────────────────────────────────
|
||||
WHATSAPP_BRIDGE_URL = os.environ.get('WHATSAPP_BRIDGE_URL', 'http://localhost:21465')
|
||||
@@ -75,3 +84,12 @@ MEILI_ENABLED = os.environ.get('MEILI_ENABLED', 'true').lower() == 'true'
|
||||
|
||||
# ─── Catalog OEM Access ────────────────────────────────────────────────────
|
||||
CATALOG_OEM_ENABLED = os.environ.get('CATALOG_OEM_ENABLED', 'false').lower() == 'true'
|
||||
|
||||
# ─── QWEN AI Fitment (private cloud server) ────────────────────────────────
|
||||
QWEN_API_URL = os.environ.get('QWEN_API_URL', '')
|
||||
QWEN_API_KEY = os.environ.get('QWEN_API_KEY', '')
|
||||
QWEN_MODEL = os.environ.get('QWEN_MODEL', 'qwen3.6')
|
||||
|
||||
|
||||
# ─── Internal Cron / Job Security ──────────────────────────────────────────
|
||||
INTERNAL_API_KEY = os.environ.get('INTERNAL_API_KEY', '')
|
||||
|
||||
@@ -5,7 +5,7 @@ bind = "0.0.0.0:5001"
|
||||
# gthread workers handle multiple concurrent requests per worker via threads.
|
||||
# Ideal for I/O-bound Flask apps with DB queries.
|
||||
# 4 workers × 4 threads = 16 concurrent requests.
|
||||
workers = 4
|
||||
workers = 8
|
||||
threads = 4
|
||||
worker_class = "gthread"
|
||||
worker_connections = 1000
|
||||
|
||||
@@ -29,7 +29,7 @@ def require_auth(*required_permissions):
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 401
|
||||
|
||||
if payload.get('type') != 'pos_access':
|
||||
if payload.get('type') not in ('pos_access', 'access'):
|
||||
return jsonify({'error': 'Invalid token type'}), 401
|
||||
|
||||
g.tenant_id = payload['tenant_id']
|
||||
|
||||
@@ -55,7 +55,7 @@ def _lookup_tenant_by_subdomain(subdomain):
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, name FROM tenants WHERE subdomain = %s AND is_active = true",
|
||||
"SELECT id, name FROM tenants WHERE LOWER(subdomain) = %s AND is_active = true",
|
||||
(subdomain,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
@@ -14,9 +14,11 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
MIGRATIONS = {
|
||||
'v1.0': 'v1.0_initial.sql',
|
||||
'v1.1': 'v1.1_pos_tables.sql',
|
||||
'v1.2': 'v1.2_subdomain.sql',
|
||||
'v1.3': 'v1.3_fleet.sql',
|
||||
'v1.4': 'v1.4_whatsapp.sql',
|
||||
'v1.5': 'v1.5_returns.sql',
|
||||
'v1.6': 'v1.6_marketplace.sql',
|
||||
'v1.7': 'v1.7_plates.sql',
|
||||
'v1.8': 'v1.8_performance_indexes.sql',
|
||||
'v1.9': 'v1.9_redis_cache.sql',
|
||||
@@ -33,6 +35,20 @@ MIGRATIONS = {
|
||||
'v3.0': 'v3.0_public_api.sql',
|
||||
'v3.1': 'v3.1_inventory_vehicle_compat.sql',
|
||||
'v3.2': 'v3.2_db_performance.sql',
|
||||
'v3.2.1': 'v3.2_qwen_vehicle_compat.sql',
|
||||
'v3.3': 'v3.3_marketplace_any_part.sql',
|
||||
'v3.3.1': 'v3.3_materialized_view.sql',
|
||||
'v3.4': 'v3.4_meli_integration.sql',
|
||||
'v3.5': 'v3.5_meli_questions.sql',
|
||||
'v3.5.1': 'v3.5_whatsapp_state_machine.sql',
|
||||
'v3.6': 'v3.6_dropshipping.sql',
|
||||
'v3.7': 'v3.7_sku_aliases.sql',
|
||||
'v3.8': 'v3.8_supplier_catalog.sql',
|
||||
'v3.9': 'v3.9_supplier_catalog_prices.sql',
|
||||
'v4.0': 'v4.0_multi_branch.sql',
|
||||
'v4.1': 'v4.1_global_invoice.sql',
|
||||
'v4.2': 'v4.2_meli_sync_queue.sql',
|
||||
'v4.3': 'v4.3_facturapi.sql',
|
||||
}
|
||||
|
||||
|
||||
@@ -61,11 +77,19 @@ def apply_migration(db_name, version):
|
||||
print(f" ERROR: Migration file not found: {filepath}")
|
||||
return False
|
||||
|
||||
with open(filepath) as f:
|
||||
sql = f.read()
|
||||
|
||||
# Skip migrations marked for manual/non-tenant execution
|
||||
first_line = sql.splitlines()[0].strip() if sql.strip() else ''
|
||||
if first_line.startswith(': SKIP') or first_line.startswith('-- : SKIP'):
|
||||
print(f" SKIP (manual/non-tenant migration)")
|
||||
return True
|
||||
|
||||
conn = get_tenant_conn_by_dbname(db_name)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
with open(filepath) as f:
|
||||
cur.execute(f.read())
|
||||
cur.execute(sql)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -386,3 +386,4 @@ CREATE TABLE IF NOT EXISTS tenant_config (
|
||||
|
||||
-- Barcode sequence
|
||||
CREATE SEQUENCE IF NOT EXISTS barcode_seq START 1;
|
||||
|
||||
|
||||
53
pos/migrations/v3.2_qwen_vehicle_compat.sql
Normal file
53
pos/migrations/v3.2_qwen_vehicle_compat.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- v3.2 QWEN Vehicle Compatibility — store unmatched AI vehicles as text
|
||||
-- Allows saving QWEN fitment results even when the vehicle is not in TecDoc.
|
||||
|
||||
-- 1. Allow NULL model_year_engine_id for QWEN vehicles not in master DB
|
||||
ALTER TABLE inventory_vehicle_compat
|
||||
ALTER COLUMN model_year_engine_id DROP NOT NULL;
|
||||
|
||||
-- 2. Add text columns for QWEN vehicle details
|
||||
ALTER TABLE inventory_vehicle_compat
|
||||
ADD COLUMN IF NOT EXISTS make VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS model VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS year INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS engine VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS engine_code VARCHAR(50);
|
||||
|
||||
-- 3. Drop old unique constraint and recreate to handle NULL mye_id
|
||||
-- (PostgreSQL allows multiple NULLs in a UNIQUE constraint)
|
||||
ALTER TABLE inventory_vehicle_compat
|
||||
DROP CONSTRAINT IF EXISTS inventory_vehicle_compat_inventory_id_model_year_engine_id_key;
|
||||
|
||||
ALTER TABLE inventory_vehicle_compat
|
||||
ADD CONSTRAINT inventory_vehicle_compat_unique_match
|
||||
UNIQUE (inventory_id, model_year_engine_id, make, model, year);
|
||||
|
||||
-- 4. Index for fast filtering by inventory + text vehicles
|
||||
CREATE INDEX IF NOT EXISTS idx_ivc_text_vehicle
|
||||
ON inventory_vehicle_compat(inventory_id, make, model, year)
|
||||
WHERE model_year_engine_id IS NULL;
|
||||
|
||||
-- 5. Update view to include new columns
|
||||
DROP VIEW IF EXISTS v_inventory_vehicle_compat;
|
||||
CREATE VIEW v_inventory_vehicle_compat AS
|
||||
SELECT
|
||||
ivc.id,
|
||||
ivc.inventory_id,
|
||||
ivc.model_year_engine_id,
|
||||
ivc.make,
|
||||
ivc.model,
|
||||
ivc.year,
|
||||
ivc.engine,
|
||||
ivc.engine_code,
|
||||
ivc.source,
|
||||
ivc.confidence,
|
||||
ivc.created_at,
|
||||
i.part_number,
|
||||
i.name as item_name,
|
||||
i.brand as item_brand,
|
||||
i.price_1,
|
||||
i.price_2,
|
||||
i.price_3,
|
||||
i.image_url
|
||||
FROM inventory_vehicle_compat ivc
|
||||
JOIN inventory i ON i.id = ivc.inventory_id;
|
||||
93
pos/migrations/v3.3_marketplace_any_part.sql
Normal file
93
pos/migrations/v3.3_marketplace_any_part.sql
Normal file
@@ -0,0 +1,93 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- v3.3 — Marketplace accepts any part number (seller listings)
|
||||
-- Target: nexus_autoparts (master DB) / tenants with warehouse_inventory
|
||||
-- Date: 2026-05-17
|
||||
--
|
||||
-- Makes warehouse_inventory part_id nullable and adds seller-defined
|
||||
-- fields so any seller can list parts that don't exist in the OEM catalog.
|
||||
-- Existing OEM-matched listings are untouched.
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'warehouse_inventory') THEN
|
||||
-- ─── 1. WAREHOUSE_INVENTORY — add seller listing columns ─────────────
|
||||
ALTER TABLE warehouse_inventory
|
||||
ADD COLUMN IF NOT EXISTS seller_part_number VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS seller_part_name VARCHAR(300),
|
||||
ADD COLUMN IF NOT EXISTS seller_category VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS tenant_inventory_id INTEGER;
|
||||
|
||||
-- Make part_id nullable so seller listings (without catalog match) can exist
|
||||
ALTER TABLE warehouse_inventory ALTER COLUMN part_id DROP NOT NULL;
|
||||
|
||||
-- ─── 2. WAREHOUSE_INVENTORY — drop old unique, add partial uniques ───
|
||||
ALTER TABLE warehouse_inventory
|
||||
DROP CONSTRAINT IF EXISTS warehouse_inventory_user_id_part_id_warehouse_location_key;
|
||||
|
||||
DROP INDEX IF EXISTS idx_wi_unique_composite;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_oem
|
||||
ON warehouse_inventory(bodega_id, part_id, warehouse_location)
|
||||
WHERE part_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_seller
|
||||
ON warehouse_inventory(bodega_id, seller_part_number, warehouse_location)
|
||||
WHERE part_id IS NULL;
|
||||
|
||||
-- Ensure every row has either part_id or seller_part_number
|
||||
ALTER TABLE warehouse_inventory
|
||||
DROP CONSTRAINT IF EXISTS chk_wi_part_or_seller;
|
||||
|
||||
ALTER TABLE warehouse_inventory
|
||||
ADD CONSTRAINT chk_wi_part_or_seller
|
||||
CHECK (
|
||||
(part_id IS NOT NULL AND seller_part_number IS NULL)
|
||||
OR
|
||||
(part_id IS NULL AND seller_part_number IS NOT NULL)
|
||||
);
|
||||
|
||||
-- ─── 3. WAREHOUSE_INVENTORY — search indexes ─────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_wi_seller_pn
|
||||
ON warehouse_inventory (bodega_id, seller_part_number)
|
||||
WHERE part_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wi_seller_category
|
||||
ON warehouse_inventory (seller_category)
|
||||
WHERE part_id IS NULL;
|
||||
|
||||
-- GIN index for text search on seller listings
|
||||
CREATE INDEX IF NOT EXISTS idx_wi_seller_search
|
||||
ON warehouse_inventory
|
||||
USING gin (to_tsvector('spanish',
|
||||
COALESCE(seller_part_name, '') || ' ' || COALESCE(seller_part_number, '')
|
||||
))
|
||||
WHERE part_id IS NULL;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'purchase_order_items') THEN
|
||||
-- ─── 4. PURCHASE_ORDER_ITEMS — make part_id nullable ─────────────────
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'purchase_order_items' AND column_name = 'part_id') THEN
|
||||
ALTER TABLE purchase_order_items
|
||||
ALTER COLUMN part_id DROP NOT NULL;
|
||||
END IF;
|
||||
|
||||
-- Add a flag so seller listings can be distinguished in POs
|
||||
ALTER TABLE purchase_order_items
|
||||
ADD COLUMN IF NOT EXISTS is_seller_listing BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ─── 5. Back-compat: ensure existing rows are valid ──────────────────
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'warehouse_inventory') THEN
|
||||
UPDATE warehouse_inventory
|
||||
SET seller_part_number = NULL
|
||||
WHERE part_id IS NOT NULL AND seller_part_number IS NOT NULL;
|
||||
|
||||
UPDATE warehouse_inventory
|
||||
SET part_id = NULL
|
||||
WHERE part_id IS NULL AND seller_part_number IS NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,11 +1,15 @@
|
||||
-- : SKIP
|
||||
-- Migration v3.3: Materialized view part_vehicle_preview
|
||||
-- Purpose: Pre-compute the "most recent vehicle" per part to eliminate
|
||||
-- DISTINCT ON + 4 JOINs over vehicle_parts (254 GB, 2B+ rows) at query time.
|
||||
--
|
||||
-- Notes:
|
||||
-- - CREATE MATERIALIZED VIEW without CONCURRENTLY (first creation).
|
||||
-- - REFRESH MATERIALIZED VIEW CONCURRENTLY is possible after the unique index exists.
|
||||
-- - Run with statement_timeout = 0; this may take hours on first creation.
|
||||
-- NOTE: This migration targets the vehicle_database, not tenant databases.
|
||||
-- The runner skips files marked with ': SKIP' on the first line.
|
||||
-- To apply manually on the vehicle database, run:
|
||||
--
|
||||
-- psql <vehicle_db> -f pos/migrations/v3.3_materialized_view.sql
|
||||
--
|
||||
-- (Remove the ': SKIP' line above before manual execution.)
|
||||
|
||||
SET statement_timeout = 0;
|
||||
|
||||
@@ -26,6 +30,3 @@ ORDER BY vp.part_id, y.year_car DESC;
|
||||
|
||||
CREATE UNIQUE INDEX idx_pvp_part ON part_vehicle_preview(part_id);
|
||||
CREATE INDEX idx_pvp_brand ON part_vehicle_preview(name_brand);
|
||||
|
||||
-- Grant select to application roles if needed
|
||||
-- GRANT SELECT ON part_vehicle_preview TO nexus_app;
|
||||
|
||||
110
pos/migrations/v3.4_meli_integration.sql
Normal file
110
pos/migrations/v3.4_meli_integration.sql
Normal file
@@ -0,0 +1,110 @@
|
||||
-- ============================================================
|
||||
-- v3.4 MercadoLibre Integration
|
||||
-- ============================================================
|
||||
-- Adds tables for external marketplace listings, orders,
|
||||
-- order items, and a generic sync queue.
|
||||
-- All tables live in the tenant DB.
|
||||
-- ============================================================
|
||||
|
||||
-- Listings published on MercadoLibre (extensible to Amazon later)
|
||||
CREATE TABLE IF NOT EXISTS marketplace_listings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
inventory_id INTEGER REFERENCES inventory(id),
|
||||
channel VARCHAR(20) NOT NULL DEFAULT 'mercadolibre',
|
||||
external_item_id VARCHAR(50) NOT NULL,
|
||||
external_status VARCHAR(30) DEFAULT 'active',
|
||||
external_permalink TEXT,
|
||||
title TEXT,
|
||||
meli_category_id VARCHAR(30),
|
||||
publish_price NUMERIC(12,2),
|
||||
last_sync_at TIMESTAMPTZ,
|
||||
sync_errors TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_inventory
|
||||
ON marketplace_listings(inventory_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_external
|
||||
ON marketplace_listings(external_item_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_marketplace_listings_unique
|
||||
ON marketplace_listings(inventory_id, channel) WHERE is_active = true;
|
||||
|
||||
-- Orders received from MercadoLibre
|
||||
CREATE TABLE IF NOT EXISTS marketplace_orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
channel VARCHAR(20) NOT NULL DEFAULT 'mercadolibre',
|
||||
external_order_id VARCHAR(50) NOT NULL UNIQUE,
|
||||
external_status VARCHAR(30) NOT NULL,
|
||||
buyer_name VARCHAR(200),
|
||||
buyer_email VARCHAR(200),
|
||||
buyer_phone VARCHAR(50),
|
||||
buyer_nickname VARCHAR(100),
|
||||
shipping_address JSONB,
|
||||
total_amount NUMERIC(12,2),
|
||||
shipping_cost NUMERIC(12,2),
|
||||
meli_shipping_id VARCHAR(50),
|
||||
nexus_sale_id INTEGER REFERENCES sales(id),
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
notes TEXT,
|
||||
raw_json JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_orders_status
|
||||
ON marketplace_orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_orders_external
|
||||
ON marketplace_orders(external_order_id);
|
||||
|
||||
-- Items inside a marketplace order
|
||||
CREATE TABLE IF NOT EXISTS marketplace_order_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
marketplace_order_id INTEGER REFERENCES marketplace_orders(id) ON DELETE CASCADE,
|
||||
inventory_id INTEGER REFERENCES inventory(id),
|
||||
external_item_id VARCHAR(50),
|
||||
title VARCHAR(300),
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price NUMERIC(12,2),
|
||||
total_price NUMERIC(12,2),
|
||||
listing_id INTEGER REFERENCES marketplace_listings(id)
|
||||
);
|
||||
|
||||
-- Generic sync queue (reusable for future Amazon integration)
|
||||
CREATE TABLE IF NOT EXISTS marketplace_sync_queue (
|
||||
id SERIAL PRIMARY KEY,
|
||||
inventory_id INTEGER REFERENCES inventory(id),
|
||||
channel VARCHAR(20) NOT NULL,
|
||||
action VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
payload JSONB,
|
||||
error_message TEXT,
|
||||
retry_count INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
processed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_sync_queue_pending
|
||||
ON marketplace_sync_queue(status, channel) WHERE status = 'pending';
|
||||
|
||||
-- Add source column to sales to track origin (POS, ML, Amazon, etc.)
|
||||
-- If the column already exists from another migration, do nothing.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sales' AND column_name = 'source'
|
||||
) THEN
|
||||
ALTER TABLE sales ADD COLUMN source VARCHAR(30) DEFAULT 'pos';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sales' AND column_name = 'external_order_id'
|
||||
) THEN
|
||||
ALTER TABLE sales ADD COLUMN external_order_id VARCHAR(50);
|
||||
END IF;
|
||||
END $$;
|
||||
30
pos/migrations/v3.5_meli_questions.sql
Normal file
30
pos/migrations/v3.5_meli_questions.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- ============================================================
|
||||
-- v3.5 MercadoLibre Questions & Answers
|
||||
-- ============================================================
|
||||
-- Adds table for tracking buyer questions on ML listings.
|
||||
-- All tables live in the tenant DB.
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS marketplace_questions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
listing_id INTEGER REFERENCES marketplace_listings(id) ON DELETE SET NULL,
|
||||
external_question_id VARCHAR(50) NOT NULL UNIQUE,
|
||||
external_item_id VARCHAR(50) NOT NULL,
|
||||
question_text TEXT NOT NULL,
|
||||
answer_text TEXT,
|
||||
status VARCHAR(20) DEFAULT 'unanswered', -- unanswered, answered, closed
|
||||
buyer_id VARCHAR(50),
|
||||
buyer_nickname VARCHAR(100),
|
||||
question_date TIMESTAMPTZ,
|
||||
answer_date TIMESTAMPTZ,
|
||||
raw_json JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_status
|
||||
ON marketplace_questions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_listing
|
||||
ON marketplace_questions(listing_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_external
|
||||
ON marketplace_questions(external_question_id);
|
||||
118
pos/migrations/v3.5_whatsapp_state_machine.sql
Normal file
118
pos/migrations/v3.5_whatsapp_state_machine.sql
Normal file
@@ -0,0 +1,118 @@
|
||||
-- : SKIP
|
||||
-- ============================================================
|
||||
-- v3.5 WhatsApp State Machine
|
||||
-- Reorganización del chatbot de AI libre a flujo estructurado
|
||||
--
|
||||
-- NOTE: This migration requires the WhatsApp tables (whatsapp_sessions,
|
||||
-- whatsapp_messages) to be present. Tenant DBs without WhatsApp enabled
|
||||
-- should skip this file.
|
||||
-- Marked with ': SKIP' so the runner skips it unless WhatsApp is configured.
|
||||
-- To apply manually on a tenant with WhatsApp tables:
|
||||
-- psql <tenant_db> -f pos/migrations/v3.5_whatsapp_state_machine.sql
|
||||
-- (Remove the ': SKIP' line above before manual execution.)
|
||||
-- ============================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- 1. Extender whatsapp_sessions con estado y contexto
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') THEN
|
||||
ALTER TABLE whatsapp_sessions
|
||||
ADD COLUMN IF NOT EXISTS state VARCHAR(50) DEFAULT 'idle',
|
||||
ADD COLUMN IF NOT EXISTS state_data JSONB DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id),
|
||||
ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id),
|
||||
ADD COLUMN IF NOT EXISTS learning_cycle INTEGER DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wa_sessions_state ON whatsapp_sessions(state);
|
||||
CREATE INDEX IF NOT EXISTS idx_wa_sessions_customer ON whatsapp_sessions(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wa_sessions_updated ON whatsapp_sessions(updated_at);
|
||||
END IF;
|
||||
|
||||
-- 2. Tabla de vínculo persistente WA ID ↔ Cliente
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers') THEN
|
||||
CREATE TABLE IF NOT EXISTS wa_customer_links (
|
||||
phone VARCHAR(50) PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_wa_link_timestamp()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links;
|
||||
CREATE TRIGGER trg_wa_link_updated
|
||||
BEFORE UPDATE ON wa_customer_links
|
||||
FOR EACH ROW EXECUTE FUNCTION update_wa_link_timestamp();
|
||||
END IF;
|
||||
|
||||
-- 3. Tabla de sesiones de aprendizaje (piezas no resueltas)
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers')
|
||||
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'inventory')
|
||||
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'sales') THEN
|
||||
CREATE TABLE IF NOT EXISTS wa_learning_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
phone VARCHAR(50) NOT NULL,
|
||||
customer_id INTEGER REFERENCES customers(id),
|
||||
description TEXT NOT NULL,
|
||||
offered_parts JSONB DEFAULT '[]',
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
resolved_part_id INTEGER REFERENCES inventory(id),
|
||||
resolution_sale_id INTEGER REFERENCES sales(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wa_learn_phone ON wa_learning_sessions(phone);
|
||||
CREATE INDEX IF NOT EXISTS idx_wa_learn_status ON wa_learning_sessions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_wa_learn_customer ON wa_learning_sessions(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wa_learn_created ON wa_learning_sessions(created_at);
|
||||
END IF;
|
||||
|
||||
-- 4. Tabla de configuración de envío por sucursal
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'branches') THEN
|
||||
CREATE TABLE IF NOT EXISTS branch_delivery_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
branch_id INTEGER NOT NULL UNIQUE REFERENCES branches(id),
|
||||
is_enabled BOOLEAN DEFAULT FALSE,
|
||||
delivery_fee NUMERIC(12,2) DEFAULT 0,
|
||||
free_delivery_threshold NUMERIC(12,2) DEFAULT NULL,
|
||||
coverage_radius_km INTEGER DEFAULT NULL,
|
||||
delivery_hours VARCHAR(100) DEFAULT 'Lun-Vie 9:00-18:00',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- 5. Agregar push_name a whatsapp_messages
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_messages') THEN
|
||||
ALTER TABLE whatsapp_messages
|
||||
ADD COLUMN IF NOT EXISTS push_name VARCHAR(200);
|
||||
END IF;
|
||||
|
||||
-- 6. Migrar datos existentes
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions')
|
||||
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'wa_customer_links')
|
||||
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers') THEN
|
||||
INSERT INTO wa_customer_links (phone, customer_id)
|
||||
SELECT ws.phone, c.id
|
||||
FROM whatsapp_sessions ws
|
||||
JOIN customers c ON c.phone = ws.phone
|
||||
WHERE ws.phone IS NOT NULL AND c.phone IS NOT NULL
|
||||
ON CONFLICT (phone) DO NOTHING;
|
||||
|
||||
UPDATE whatsapp_sessions ws
|
||||
SET customer_id = wcl.customer_id
|
||||
FROM wa_customer_links wcl
|
||||
WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
18
pos/migrations/v3.6_dropshipping.sql
Normal file
18
pos/migrations/v3.6_dropshipping.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- ============================================================
|
||||
-- v3.6 Dropshipping API Integration
|
||||
-- ============================================================
|
||||
-- Adds config keys and webhook targets for external
|
||||
-- dropshipping platforms.
|
||||
-- ============================================================
|
||||
|
||||
-- Webhook targets for dropshipping notifications per tenant
|
||||
CREATE TABLE IF NOT EXISTS dropshipping_webhooks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_type VARCHAR(30) NOT NULL, -- stock_updated, price_updated, sale_made
|
||||
target_url TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dropshipping_webhooks_event
|
||||
ON dropshipping_webhooks(event_type) WHERE is_active = true;
|
||||
22
pos/migrations/v3.7_sku_aliases.sql
Normal file
22
pos/migrations/v3.7_sku_aliases.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- ============================================================
|
||||
-- v3.7 SKU Aliases (multiple SKUs per inventory item)
|
||||
-- ============================================================
|
||||
-- Allows registering 2-3 alternative part numbers/SKUs for the
|
||||
-- same product (e.g. different supplier SKUs).
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_sku_aliases (
|
||||
id SERIAL PRIMARY KEY,
|
||||
inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
|
||||
sku VARCHAR(100) NOT NULL,
|
||||
label VARCHAR(50), -- e.g. "Bodega A", "Proveedor X"
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT inventory_sku_aliases_unique_sku
|
||||
UNIQUE (inventory_id, sku)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_sku_aliases_inventory
|
||||
ON inventory_sku_aliases(inventory_id) WHERE is_active = true;
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_sku_aliases_sku
|
||||
ON inventory_sku_aliases(sku) WHERE is_active = true;
|
||||
63
pos/migrations/v3.8_supplier_catalog.sql
Normal file
63
pos/migrations/v3.8_supplier_catalog.sql
Normal file
@@ -0,0 +1,63 @@
|
||||
-- v3.8 — Supplier Catalog tables
|
||||
-- Adds supplier_catalog, supplier_catalog_compat, and supplier_catalog_interchange
|
||||
-- to support multi-supplier parts injection into the vehicle catalog.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS supplier_catalog (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
supplier_name VARCHAR(255) NOT NULL,
|
||||
sku VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
category VARCHAR(255),
|
||||
description TEXT,
|
||||
image_url TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS supplier_catalog_tenant_id_supplier_name_sku_category_key
|
||||
ON supplier_catalog (tenant_id, supplier_name, sku, category);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sc_supplier
|
||||
ON supplier_catalog (tenant_id, supplier_name, is_active);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sc_sku
|
||||
ON supplier_catalog (tenant_id, sku, category);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS supplier_catalog_compat (
|
||||
id SERIAL PRIMARY KEY,
|
||||
catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE,
|
||||
make VARCHAR(255),
|
||||
model VARCHAR(255),
|
||||
year INTEGER,
|
||||
engine VARCHAR(255),
|
||||
engine_code VARCHAR(255),
|
||||
model_year_engine_id INTEGER,
|
||||
source VARCHAR(50) DEFAULT 'import',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS supplier_catalog_compat_catalog_id_make_model_year_engine_key
|
||||
ON supplier_catalog_compat (catalog_id, make, model, year, engine);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scc_catalog
|
||||
ON supplier_catalog_compat (catalog_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scc_vehicle
|
||||
ON supplier_catalog_compat (make, model, year);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scc_mye
|
||||
ON supplier_catalog_compat (model_year_engine_id);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS supplier_catalog_interchange (
|
||||
id SERIAL PRIMARY KEY,
|
||||
catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE,
|
||||
brand VARCHAR(255),
|
||||
part_number VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sci_catalog
|
||||
ON supplier_catalog_interchange (catalog_id);
|
||||
42
pos/migrations/v3.9_supplier_catalog_prices.sql
Normal file
42
pos/migrations/v3.9_supplier_catalog_prices.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- : SKIP
|
||||
-- v3.9_supplier_catalog_prices.sql
|
||||
-- Per-tenant supplier pricing for items in the master supplier_catalog.
|
||||
-- This table lives in the master DB and is joined by tenant_id.
|
||||
-- Apply manually to the master database.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS supplier_catalog_prices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE,
|
||||
price NUMERIC(12,2) NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'MXN',
|
||||
effective_from DATE DEFAULT CURRENT_DATE,
|
||||
effective_to DATE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, catalog_id, effective_from)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_catalog_prices_tenant_catalog
|
||||
ON supplier_catalog_prices(tenant_id, catalog_id, effective_from DESC)
|
||||
WHERE is_active = true;
|
||||
|
||||
-- Index for quick "latest active price" lookups per tenant+item.
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_catalog_prices_lookup
|
||||
ON supplier_catalog_prices(tenant_id, catalog_id, effective_from DESC, is_active);
|
||||
|
||||
-- Trigger to keep updated_at current on row changes.
|
||||
CREATE OR REPLACE FUNCTION update_supplier_catalog_prices_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_supplier_catalog_prices_updated_at ON supplier_catalog_prices;
|
||||
CREATE TRIGGER trg_supplier_catalog_prices_updated_at
|
||||
BEFORE UPDATE ON supplier_catalog_prices
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_supplier_catalog_prices_updated_at();
|
||||
253
pos/migrations/v4.0_multi_branch.sql
Normal file
253
pos/migrations/v4.0_multi_branch.sql
Normal file
@@ -0,0 +1,253 @@
|
||||
-- v4.0_multi_branch.sql
|
||||
-- Multi-branch overhaul: branch fiscal data + shared inventory with per-branch stock.
|
||||
-- WARNING: this migration restructures inventory data. A full DB backup is required.
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 1. BRANCHES: fiscal fields + main flag
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE branches
|
||||
ADD COLUMN IF NOT EXISTS is_main BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS rfc VARCHAR(13),
|
||||
ADD COLUMN IF NOT EXISTS razon_social VARCHAR(300),
|
||||
ADD COLUMN IF NOT EXISTS regimen_fiscal VARCHAR(10),
|
||||
ADD COLUMN IF NOT EXISTS cp VARCHAR(5),
|
||||
ADD COLUMN IF NOT EXISTS direccion_fiscal TEXT,
|
||||
ADD COLUMN IF NOT EXISTS serie_cfdi VARCHAR(10) DEFAULT 'A',
|
||||
ADD COLUMN IF NOT EXISTS folio_inicio INTEGER DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS folio_actual INTEGER DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS email VARCHAR(200);
|
||||
|
||||
-- Ensure at least one branch is marked main (the first one created).
|
||||
DO $$
|
||||
DECLARE
|
||||
main_branch_id INTEGER;
|
||||
branch_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO branch_count FROM branches;
|
||||
|
||||
IF branch_count = 0 THEN
|
||||
INSERT INTO branches (name, is_main)
|
||||
VALUES ('Principal', TRUE);
|
||||
ELSE
|
||||
SELECT id INTO main_branch_id FROM branches ORDER BY id LIMIT 1;
|
||||
|
||||
UPDATE branches SET is_main = FALSE;
|
||||
UPDATE branches SET is_main = TRUE WHERE id = main_branch_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Constraint: only one main branch per tenant.
|
||||
-- Because this runs inside a single tenant DB, a simple partial unique index is enough.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_branches_single_main
|
||||
ON branches (is_main)
|
||||
WHERE is_main = TRUE;
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 2. INVENTORY STOCK: new per-branch stock table
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_stock (
|
||||
id SERIAL PRIMARY KEY,
|
||||
inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
|
||||
branch_id INTEGER NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
|
||||
stock INTEGER DEFAULT 0,
|
||||
location VARCHAR(50),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(inventory_id, branch_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_stock_branch ON inventory_stock(branch_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_stock_inventory ON inventory_stock(inventory_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_inventory_stock_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_inventory_stock_updated_at ON inventory_stock;
|
||||
CREATE TRIGGER trg_inventory_stock_updated_at
|
||||
BEFORE UPDATE ON inventory_stock
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_inventory_stock_updated_at();
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 3. INVENTORY: make branch_id nullable + prepare for consolidation
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Drop the old unique constraint that forces one record per (branch, part_number).
|
||||
DROP INDEX IF EXISTS idx_inventory_branch_part;
|
||||
|
||||
-- Make branch_id nullable so we can have master records without a branch.
|
||||
ALTER TABLE inventory ALTER COLUMN branch_id DROP NOT NULL;
|
||||
|
||||
-- Add unique constraint on part_number at tenant level so a product exists once.
|
||||
-- If duplicates still exist this will fail, so we consolidate below first.
|
||||
-- We create it at the end of this migration after deduplication.
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 4. DATA MIGRATION: consolidate duplicated inventory rows by part_number
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Build a mapping: for each duplicated part_number, choose the master record.
|
||||
-- Master = record belonging to the main branch; fallback = oldest id.
|
||||
CREATE TEMP TABLE _inventory_master_map AS
|
||||
SELECT DISTINCT ON (part_number)
|
||||
id AS master_id,
|
||||
part_number
|
||||
FROM inventory
|
||||
ORDER BY part_number,
|
||||
CASE WHEN branch_id = (SELECT id FROM branches WHERE is_main = TRUE LIMIT 1) THEN 0 ELSE 1 END,
|
||||
id ASC;
|
||||
|
||||
-- Create temp table of duplicates (all rows that are NOT the master for their part_number).
|
||||
CREATE TEMP TABLE _inventory_duplicates AS
|
||||
SELECT i.id AS duplicate_id, m.master_id
|
||||
FROM inventory i
|
||||
JOIN _inventory_master_map m ON i.part_number = m.part_number
|
||||
WHERE i.id <> m.master_id;
|
||||
|
||||
|
||||
|
||||
-- Compute per-duplicate stock and insert into inventory_stock against master_id + duplicate's branch.
|
||||
INSERT INTO inventory_stock (inventory_id, branch_id, stock, location)
|
||||
SELECT
|
||||
dups.master_id,
|
||||
dups.branch_id,
|
||||
GREATEST(0, COALESCE(stock_by_dup.stock, 0))::int,
|
||||
dups.location
|
||||
FROM (
|
||||
SELECT d.master_id, d.duplicate_id, i.branch_id, i.location
|
||||
FROM _inventory_duplicates d
|
||||
JOIN inventory i ON i.id = d.duplicate_id
|
||||
) dups
|
||||
JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(quantity), 0) AS stock
|
||||
FROM inventory_operations
|
||||
WHERE inventory_id = dups.duplicate_id AND branch_id = dups.branch_id
|
||||
) stock_by_dup ON TRUE
|
||||
ON CONFLICT (inventory_id, branch_id) DO UPDATE
|
||||
SET stock = inventory_stock.stock + EXCLUDED.stock;
|
||||
|
||||
-- Also migrate stock from master records themselves (they were already in inventory.branch_id).
|
||||
INSERT INTO inventory_stock (inventory_id, branch_id, stock, location)
|
||||
SELECT
|
||||
i.id,
|
||||
i.branch_id,
|
||||
GREATEST(0, COALESCE(stock_by_inv.stock, 0))::int,
|
||||
i.location
|
||||
FROM inventory i
|
||||
JOIN _inventory_master_map m ON i.id = m.master_id
|
||||
JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(quantity), 0) AS stock
|
||||
FROM inventory_operations
|
||||
WHERE inventory_id = i.id AND branch_id = i.branch_id
|
||||
) stock_by_inv ON TRUE
|
||||
WHERE i.branch_id IS NOT NULL
|
||||
ON CONFLICT (inventory_id, branch_id) DO UPDATE
|
||||
SET stock = EXCLUDED.stock;
|
||||
|
||||
-- Handle inventory_stock_summary specially: it has PK on inventory_id.
|
||||
-- If master already has a summary row, add duplicate's stock and remove duplicate row.
|
||||
-- Otherwise repoint the duplicate row to master.
|
||||
UPDATE inventory_stock_summary s
|
||||
SET stock = s.stock + d.stock
|
||||
FROM (
|
||||
SELECT duplicate_id, master_id, stock FROM inventory_stock_summary ss
|
||||
JOIN _inventory_duplicates m ON ss.inventory_id = m.duplicate_id
|
||||
WHERE EXISTS (SELECT 1 FROM inventory_stock_summary sm WHERE sm.inventory_id = m.master_id)
|
||||
) d
|
||||
WHERE s.inventory_id = d.master_id;
|
||||
|
||||
DELETE FROM inventory_stock_summary
|
||||
WHERE inventory_id IN (
|
||||
SELECT m.duplicate_id
|
||||
FROM _inventory_duplicates m
|
||||
WHERE EXISTS (SELECT 1 FROM inventory_stock_summary sm WHERE sm.inventory_id = m.master_id)
|
||||
);
|
||||
|
||||
UPDATE inventory_stock_summary
|
||||
SET inventory_id = m.master_id
|
||||
FROM _inventory_duplicates m
|
||||
WHERE inventory_id = m.duplicate_id;
|
||||
|
||||
-- Update FK references from duplicate inventory rows to master inventory rows.
|
||||
-- We use dynamic SQL to update every known referencing table.
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
fk_sql TEXT;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT
|
||||
tc.table_name,
|
||||
kcu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND ccu.table_name = 'inventory'
|
||||
AND tc.table_name <> 'inventory_stock_summary'
|
||||
LOOP
|
||||
fk_sql := format(
|
||||
'UPDATE %I SET %I = m.master_id FROM _inventory_duplicates m WHERE %I = m.duplicate_id',
|
||||
rec.table_name, rec.column_name, rec.column_name
|
||||
);
|
||||
EXECUTE fk_sql;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Delete duplicate inventory rows now that FKs are repointed.
|
||||
DELETE FROM inventory
|
||||
WHERE id IN (SELECT duplicate_id FROM _inventory_duplicates);
|
||||
|
||||
-- Clean up master records: remove branch_id so they become shared catalog items.
|
||||
UPDATE inventory SET branch_id = NULL WHERE branch_id IS NOT NULL;
|
||||
|
||||
-- Now safe to enforce uniqueness at tenant level.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_part_unique ON inventory (part_number);
|
||||
|
||||
-- Clean temp tables.
|
||||
DROP TABLE IF EXISTS _inventory_master_map;
|
||||
DROP TABLE IF EXISTS _inventory_duplicates;
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 5. CFDI_QUEUE: allow sale_id to be NULL for global invoices (Phase 3 prep)
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE cfdi_queue ALTER COLUMN sale_id DROP NOT NULL;
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 6. TRIGGER: Keep inventory_stock in sync with inventory_operations
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_inventory_stock()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Skip operations that are not tied to a specific branch.
|
||||
-- Per-branch stock tracking requires a branch_id; without it we can't
|
||||
-- assign the stock to any location.
|
||||
IF NEW.branch_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
INSERT INTO inventory_stock (inventory_id, branch_id, stock)
|
||||
VALUES (NEW.inventory_id, NEW.branch_id, NEW.quantity)
|
||||
ON CONFLICT (inventory_id, branch_id) DO UPDATE
|
||||
SET stock = inventory_stock.stock + EXCLUDED.stock,
|
||||
updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_update_inventory_stock ON inventory_operations;
|
||||
CREATE TRIGGER trg_update_inventory_stock
|
||||
AFTER INSERT ON inventory_operations
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_inventory_stock();
|
||||
17
pos/migrations/v4.1_global_invoice.sql
Normal file
17
pos/migrations/v4.1_global_invoice.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- v4.1 — Global Invoice (Factura Global Mensual)
|
||||
-- Supports grouping cash sales (<= $2,000) into a single monthly CFDI.
|
||||
|
||||
-- Link global invoices to their constituent sales
|
||||
CREATE TABLE IF NOT EXISTS global_invoice_sales (
|
||||
global_invoice_id INTEGER NOT NULL REFERENCES cfdi_queue(id) ON DELETE CASCADE,
|
||||
sale_id INTEGER NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (global_invoice_id, sale_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_global ON global_invoice_sales(global_invoice_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_sale ON global_invoice_sales(sale_id);
|
||||
|
||||
-- Track which sales have been included in any global invoice
|
||||
-- (quick lookup without joining global_invoice_sales)
|
||||
ALTER TABLE sales ADD COLUMN IF NOT EXISTS global_invoiced_at TIMESTAMPTZ;
|
||||
CREATE INDEX IF NOT EXISTS idx_sales_global_invoiced_at ON sales(global_invoiced_at) WHERE global_invoiced_at IS NULL;
|
||||
14
pos/migrations/v4.2_meli_sync_queue.sql
Normal file
14
pos/migrations/v4.2_meli_sync_queue.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- v4.2 — MercadoLibre sync queue for stock synchronization
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meli_sync_queue (
|
||||
id SERIAL PRIMARY KEY,
|
||||
inventory_id INTEGER NOT NULL REFERENCES inventory(id),
|
||||
action VARCHAR(20) NOT NULL DEFAULT 'stock_update',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
processed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_meli_sync_pending ON meli_sync_queue(status, created_at) WHERE status = 'pending';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user