Compare commits
113 Commits
f78d4c9b44
...
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 |
@@ -57,6 +57,13 @@ 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
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -87,3 +87,7 @@ 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
|
||||
@@ -195,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,76 @@ 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)
|
||||
# ============================================================================
|
||||
|
||||
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']
|
||||
@@ -55,6 +55,20 @@ services:
|
||||
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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
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."
|
||||
@@ -2,6 +2,16 @@ 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:
|
||||
|
||||
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-29
|
||||
**Versión DB:** v3.2
|
||||
**Fecha:** 2026-06-11
|
||||
**Versión DB:** v4.1
|
||||
**Tests:** 73/73 pasando (pytest)
|
||||
**Commit:** `2b73c2c`
|
||||
|
||||
---
|
||||
|
||||
@@ -100,6 +101,25 @@
|
||||
| **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
|
||||
@@ -149,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
|
||||
@@ -174,6 +199,51 @@ METABASE_URL=http://localhost:3000
|
||||
| — | **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` |
|
||||
|
||||
## FASE 7: Precios de Proveedor + Multi-sucursal + Factura Global
|
||||
|
||||
**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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -190,7 +260,7 @@ METABASE_URL=http://localhost:3000
|
||||
| 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 | No iniciado |
|
||||
| 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
|
||||
|
||||
|
||||
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
|
||||
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`
|
||||
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,100 +1,63 @@
|
||||
# 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;
|
||||
}
|
||||
|
||||
upstream nexus_dashboard {
|
||||
server 127.0.0.1:5000;
|
||||
}
|
||||
|
||||
upstream nexus_quart {
|
||||
server 127.0.0.1:5002;
|
||||
}
|
||||
|
||||
# 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;
|
||||
|
||||
# Main site (no subdomain)
|
||||
# ─── 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)$ {
|
||||
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 / {
|
||||
# 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;
|
||||
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;
|
||||
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)
|
||||
@@ -104,21 +67,68 @@ server {
|
||||
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 5s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Rate limit login endpoint
|
||||
location /pos/api/auth/login {
|
||||
limit_req zone=pos_login burst=5 nodelay;
|
||||
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_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
# ─── 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_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,8 @@
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
}
|
||||
|
||||
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"]
|
||||
31
pos/app.py
31
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)
|
||||
|
||||
@@ -104,6 +113,12 @@ def create_app():
|
||||
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():
|
||||
@@ -122,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')
|
||||
@@ -174,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,
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -12,6 +12,7 @@ dashboard_stats_bp = Blueprint('dashboard_stats', __name__, url_prefix='/pos/api
|
||||
|
||||
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
|
||||
class DecimalEncoder(json.JSONEncoder):
|
||||
@@ -25,83 +26,95 @@ class DecimalEncoder(json.JSONEncoder):
|
||||
@require_auth()
|
||||
def get_stats():
|
||||
"""Summary stats for today and this month."""
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
today = datetime.utcnow().date()
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
# Sales today
|
||||
today_sales = db.execute(
|
||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) = %s""", (today,)
|
||||
).fetchone()
|
||||
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
|
||||
month_sales = db.execute(
|
||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
|
||||
).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
|
||||
top_products = db.execute(
|
||||
"""SELECT p.name, SUM(si.quantity) as qty, SUM(si.total) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id_sale
|
||||
JOIN parts p ON si.part_id = p.id_part
|
||||
WHERE DATE(s.created_at) = %s
|
||||
GROUP BY p.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 5""", (today,)
|
||||
).fetchall()
|
||||
# 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)
|
||||
hourly = db.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,)
|
||||
).fetchall()
|
||||
hourly_map = {row['hour']: {'count': row['count'], 'total': row['total']} for row in hourly}
|
||||
# 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['count'],
|
||||
'sales_total': today_sales['total'],
|
||||
},
|
||||
'month': {
|
||||
'sales_count': month_sales['count'],
|
||||
'sales_total': month_sales['total'],
|
||||
},
|
||||
'top_products': [
|
||||
{'name': row['name'], 'quantity': row['qty'], 'revenue': row['revenue']}
|
||||
for row in top_products
|
||||
],
|
||||
'hourly_sales': [
|
||||
{'hour': h, 'count': hourly_map.get(h, {}).get('count', 0),
|
||||
'total': hourly_map.get(h, {}).get('total', 0)}
|
||||
for h in range(24)
|
||||
],
|
||||
}, cls=DecimalEncoder)
|
||||
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."""
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
today = datetime.utcnow().date()
|
||||
rows = db.execute(
|
||||
"""SELECT e.name, COUNT(s.id_sale) as sales, COALESCE(SUM(s.total), 0) as total
|
||||
FROM sales s
|
||||
JOIN employees e ON s.employee_id = e.id_employee
|
||||
WHERE DATE(s.created_at) = %s
|
||||
GROUP BY e.name
|
||||
ORDER BY total DESC""", (today,)
|
||||
).fetchall()
|
||||
return jsonify({
|
||||
'employees': [
|
||||
{'name': row['name'], 'sales': row['sales'], 'total': row['total']}
|
||||
for row in rows
|
||||
]
|
||||
}, cls=DecimalEncoder)
|
||||
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()
|
||||
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,
|
||||
})
|
||||
@@ -12,6 +12,7 @@ supplier_portal_bp = Blueprint('supplier_portal', __name__, url_prefix='/pos/api
|
||||
|
||||
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
|
||||
class DecimalEncoder(json.JSONEncoder):
|
||||
@@ -26,48 +27,47 @@ class DecimalEncoder(json.JSONEncoder):
|
||||
def get_demand():
|
||||
"""Aggregated demand by zone, part group, and time range."""
|
||||
days = request.args.get('days', 30, type=int)
|
||||
group_id = request.args.get('group_id', type=int)
|
||||
branch_id = request.args.get('branch_id', type=int)
|
||||
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
params = [since]
|
||||
filters = "s.created_at >= %s"
|
||||
if group_id:
|
||||
filters += " AND p.group_id = %s"
|
||||
params.append(group_id)
|
||||
if branch_id:
|
||||
filters += " AND s.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
try:
|
||||
params = [since]
|
||||
filters = "s.created_at >= %s"
|
||||
if branch_id:
|
||||
filters += " AND s.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
|
||||
rows = db.execute(
|
||||
f"""SELECT g.name as group_name, b.name as branch_name,
|
||||
COUNT(DISTINCT s.id_sale) as orders,
|
||||
SUM(si.quantity) as qty_requested,
|
||||
COALESCE(SUM(si.total), 0) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id_sale
|
||||
JOIN parts p ON si.part_id = p.id_part
|
||||
JOIN part_groups g ON p.group_id = g.id_group
|
||||
LEFT JOIN branches b ON s.branch_id = b.id_branch
|
||||
WHERE {filters}
|
||||
GROUP BY g.name, b.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 100""", tuple(params)
|
||||
).fetchall()
|
||||
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': [
|
||||
{'group': row['group_name'], 'branch': row['branch_name'],
|
||||
'orders': row['orders'], 'quantity': row['qty_requested'],
|
||||
'revenue': row['revenue']}
|
||||
for row in rows
|
||||
]
|
||||
}, cls=DecimalEncoder)
|
||||
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'])
|
||||
@@ -75,31 +75,31 @@ def get_demand():
|
||||
def get_top_parts():
|
||||
"""Top moving parts for suppliers to restock."""
|
||||
days = request.args.get('days', 30, type=int)
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
rows = db.execute(
|
||||
"""SELECT p.oem_part_number, p.name, g.name as group_name,
|
||||
SUM(si.quantity) as sold, COALESCE(SUM(si.total), 0) as revenue,
|
||||
COALESCE(SUM(wi.stock_quantity), 0) as current_stock
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id_sale
|
||||
JOIN parts p ON si.part_id = p.id_part
|
||||
JOIN part_groups g ON p.group_id = g.id_group
|
||||
LEFT JOIN warehouse_inventory wi ON p.id_part = wi.part_id
|
||||
WHERE s.created_at >= %s
|
||||
GROUP BY p.oem_part_number, p.name, g.name
|
||||
ORDER BY sold DESC
|
||||
LIMIT 50""", (since,)
|
||||
).fetchall()
|
||||
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': [
|
||||
{'oem': row['oem_part_number'], 'name': row['name'],
|
||||
'group': row['group_name'], 'sold': row['sold'],
|
||||
'revenue': row['revenue'], 'stock': row['current_stock']}
|
||||
for row in rows
|
||||
]
|
||||
}, cls=DecimalEncoder)
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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';
|
||||
42
pos/migrations/v4.3_facturapi.sql
Normal file
42
pos/migrations/v4.3_facturapi.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- v4.3_facturapi.sql
|
||||
-- Migrate CFDI timbrado from Horux360 XML pipeline to Facturapi JSON API.
|
||||
--
|
||||
-- Changes:
|
||||
-- - Rename cfdi_queue.xml_unsigned -> payload_unsigned (stores Facturapi JSON payload)
|
||||
-- - Keep xml_signed for the signed XML returned by Facturapi
|
||||
-- - Add external_id column to store Facturapi invoice id
|
||||
-- - Add facturapi config keys to tenant_config
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 1. CFDI_QUEUE: adapt schema for Facturapi payloads
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'cfdi_queue' AND column_name = 'xml_unsigned'
|
||||
) THEN
|
||||
ALTER TABLE cfdi_queue RENAME COLUMN xml_unsigned TO payload_unsigned;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN cfdi_queue.payload_unsigned IS 'Facturapi JSON payload (previously unsigned XML for Horux)';
|
||||
COMMENT ON COLUMN cfdi_queue.xml_signed IS 'Signed+stamped XML returned by Facturapi';
|
||||
|
||||
ALTER TABLE cfdi_queue ADD COLUMN IF NOT EXISTS external_id VARCHAR(64);
|
||||
COMMENT ON COLUMN cfdi_queue.external_id IS 'Facturapi invoice id';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdi_queue_external_id ON cfdi_queue(external_id);
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 2. TENANT_CONFIG: Facturapi configuration keys
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
INSERT INTO tenant_config (key, value)
|
||||
VALUES
|
||||
('cfdi_facturapi_key', ''),
|
||||
('cfdi_facturapi_org_id', ''),
|
||||
('cfdi_facturapi_customer_sync', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Backward-compat: migrate old Horux keys to comments so they are not used anymore
|
||||
COMMENT ON TABLE tenant_config IS 'tenant_config; old keys cfdi_horux_api_url and cfdi_horux_api_key are deprecated';
|
||||
@@ -8,7 +8,7 @@
|
||||
## Architecture
|
||||
|
||||
The Capacitor app loads the POS from the remote server at
|
||||
`https://nexus.consultoria-as.com/pos`. This means:
|
||||
`https://pos.nexusautoparts.com.mx/pos`. This means:
|
||||
- The app requires internet on first load.
|
||||
- The PWA service worker handles offline caching after that.
|
||||
- No HTML/JS/CSS is bundled into the native binary.
|
||||
|
||||
@@ -7,3 +7,4 @@ gunicorn>=22.0
|
||||
redis>=5.0
|
||||
meilisearch>=0.40
|
||||
orjson
|
||||
facturapi>=1.0
|
||||
|
||||
@@ -3,9 +3,15 @@
|
||||
|
||||
import requests
|
||||
import json
|
||||
from config import OPENROUTER_API_KEY
|
||||
from config import OPENROUTER_API_KEY, HERMES_API_URL, HERMES_API_KEY
|
||||
from config import QWEN_API_URL, QWEN_API_KEY, QWEN_MODEL
|
||||
|
||||
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
HERMES_ENABLED = bool(HERMES_API_KEY and HERMES_API_URL)
|
||||
HERMES_CHAT_URL = (HERMES_API_URL.rstrip('/') + '/chat/completions') if HERMES_API_URL else None
|
||||
|
||||
QWEN_ENABLED = bool(QWEN_API_KEY and QWEN_API_URL)
|
||||
QWEN_CHAT_URL = (QWEN_API_URL.rstrip('/') + '/chat/completions') if QWEN_API_URL else None
|
||||
|
||||
# ⚠️ SOLO MODELOS GRATUITOS — No cambiar a modelos de pago.
|
||||
# El modelo DEBE terminar en ":free" para garantizar costo $0.
|
||||
@@ -24,11 +30,100 @@ FALLBACK_MODELS = [
|
||||
"meta-llama/llama-3.3-70b-instruct:free", # Meta — último fallback
|
||||
]
|
||||
|
||||
# Hermes Agent model (OpenAI-compatible API server)
|
||||
HERMES_MODEL = "hermes-agent"
|
||||
|
||||
def _validate_model(model_id):
|
||||
"""Ensure only free models are used. Raises if model is not free."""
|
||||
"""Ensure only free models are used. Raises if model is not free.
|
||||
|
||||
Skips validation for Hermes Agent and QWEN models (self-hosted / private API).
|
||||
"""
|
||||
if model_id == HERMES_MODEL:
|
||||
return
|
||||
if model_id == QWEN_MODEL:
|
||||
return
|
||||
if not model_id.endswith(':free'):
|
||||
raise ValueError(f"BLOQUEADO: Solo se permiten modelos gratuitos (:free). Modelo '{model_id}' no es gratuito.")
|
||||
|
||||
|
||||
def _post_chat_completion(url, api_key, model_id, messages, max_tokens=800, temperature=0.3, timeout=25):
|
||||
"""Generic OpenAI-compatible chat completion POST.
|
||||
|
||||
Returns the parsed response dict on success, None on failure.
|
||||
"""
|
||||
try:
|
||||
resp = requests.post(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model_id,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
print(f"[AI] Rate limited on {model_id} ({url})")
|
||||
return None
|
||||
if resp.status_code >= 400:
|
||||
print(f"[AI] HTTP {resp.status_code} on {model_id} ({url}): {resp.text[:200]}")
|
||||
return None
|
||||
data = resp.json()
|
||||
choice = data.get("choices", [{}])[0]
|
||||
content = choice.get("message", {}).get("content") or ""
|
||||
content = content.strip()
|
||||
finish = choice.get("finish_reason", "")
|
||||
if not content:
|
||||
print(f"[AI] Empty response from {model_id} (finish={finish})")
|
||||
return None
|
||||
return {"content": content, "finish_reason": finish, "model": model_id}
|
||||
except Exception as e:
|
||||
print(f"[AI] Error with {model_id} ({url}): {e}")
|
||||
return None
|
||||
|
||||
|
||||
SYSTEM_PROMPT_SHORT = """Eres Juan, vendedor estrella de Autopartes Estrada. Llevas 10 años ayudando a mecanicos y dueños de taller. Tu estilo: directo, calido, sin rollos tecnicos. Hablas como un compa que sabe de carros.
|
||||
|
||||
IMPORTANTE: NO prometas stock hasta verificar. Usa "Reviso...", "Busco...", "Déjame checar..." en vez de "Tengo..." a menos que estes 100% seguro.
|
||||
|
||||
Responde SIEMPRE en formato JSON: {"message":"...","search_query":"...","vehicle":{"brand":"...","model":"...","year":...}}
|
||||
|
||||
REGLAS DE VENTA AVANZADAS:
|
||||
1. PRECIO AL FRENTE: Si hay stock, di precio y marca sin rodeos.
|
||||
2. KIT INTELIGENTE: Siempre sugiere 1-2 productos relacionados que se necesitan para el mismo trabajo.
|
||||
- Balatas → "Ya que vas a cambiar balatas, checa si los discos tambien estan gastados. Te armo paquete con descuento."
|
||||
- Alternador → "Mientras cambias alternador, conviene cambiar la banda serpentina para que no se te rompa despues."
|
||||
- Filtro de aceite → "¿Ya tienes filtro de aire y bujias? Para servicio completo conviene cambiar todo junto."
|
||||
3. MANEJO DE OBJECIONES:
|
||||
- "Esta caro" → "Te entiendo. Esta es marca original. Tambien manejo opcion economica. ¿Te mando las dos para comparar?"
|
||||
- "Voy a checar en otro lado" → "Dale, te espero. Guardame este precio. Si encuentras mas barato, mandame foto de la cotizacion y veo si te la mejoro."
|
||||
- "Lo necesito para hoy" / "Urgente" → "Perfecto. Tenemos entrega express en 2-4 horas o puedes pasar directo a la tienda. ¿Te lo armo ya?"
|
||||
- "No se si sea esa" → "No hay problema. Dame los ultimos 4 digitos de tu VIN y te confirmo compatibilidad exacta."
|
||||
- "Solo estoy cotizando" → "Claro, sin compromiso. Te armo la cotizacion y si decides despues, aqui queda guardada."
|
||||
4. CIERRE SUAVE (termina SIEMPRE con pregunta):
|
||||
- "¿Te lo aparto?"
|
||||
- "¿Lo mando a tu taller o lo pasas a recoger?"
|
||||
- "¿Con esto quedas o necesitas algo mas?"
|
||||
- "¿Te armo el paquete completo? Sale mejor que por separado."
|
||||
5. RECONOCIMIENTO DE CLIENTE: Si el contexto dice que compro antes, mencionalo. "Veo que compraste balatas hace 6 meses. ¿Ya es hora de cambiar las del otro eje?"
|
||||
6. DIAGNOSTICO RAPIDO: Si describe sintoma, diagnostica en 1-2 frases y sugiere 2-3 partes mas probables.
|
||||
|
||||
TRADUCCIONES search_query (EN INGLES):
|
||||
Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector, Banda de distribucion=Timing Belt, Tensor=Belt Tensioner, Junta homocinetica=CV Joint, Marcha=Starter Motor, Bateria=Battery, Aceite=Engine Oil, Refrigerante=Coolant.
|
||||
|
||||
FORMATO:
|
||||
- search_query EN INGLES. NUNCA null si pide algo.
|
||||
- vehicle: {"brand":"NISSAN","model":"Frontier","year":2019} marca en MAYUSCULAS.
|
||||
- Multiples partes: "Brake Pad|Brake Disc|Brake Fluid"
|
||||
- Mensaje maximo 4 lineas cortas. Lenguaje natural, nada robotico.
|
||||
- Si ya detectaste vehiculo en conversacion anterior, NO vuelvas a pedirlo.
|
||||
- Termina SIEMPRE con una pregunta de cierre.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes.
|
||||
|
||||
IMPORTANTE: Responde SIEMPRE en formato JSON valido con esta estructura:
|
||||
@@ -131,11 +226,24 @@ def get_inventory_context(tenant_conn, branch_id=None):
|
||||
WHERE {where} AND i.brand IS NOT NULL AND i.brand != ''
|
||||
GROUP BY i.brand
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 15
|
||||
LIMIT 10
|
||||
""", params)
|
||||
brands = cur.fetchall()
|
||||
brand_list = ", ".join(f"{row[0]} ({row[1]})" for row in brands if row[0])
|
||||
|
||||
# Top categories with counts
|
||||
cur.execute(f"""
|
||||
SELECT c.name, COUNT(*) as cnt
|
||||
FROM inventory i
|
||||
JOIN part_categories c ON c.id = i.category_id
|
||||
WHERE {where} AND c.name IS NOT NULL AND c.name != ''
|
||||
GROUP BY c.name
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10
|
||||
""", params)
|
||||
categories = cur.fetchall()
|
||||
category_list = ", ".join(f"{row[0]} ({row[1]})" for row in categories if row[0])
|
||||
|
||||
# Products with low stock (<=3)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) FROM inventory i
|
||||
@@ -148,10 +256,12 @@ def get_inventory_context(tenant_conn, branch_id=None):
|
||||
"CONTEXTO DEL INVENTARIO:",
|
||||
f"Este negocio tiene {total} productos en inventario.",
|
||||
]
|
||||
if category_list:
|
||||
lines.append(f"Categorias principales: {category_list}")
|
||||
if brand_list:
|
||||
lines.append(f"Marcas disponibles: {brand_list}")
|
||||
lines.append(f"Marcas top: {brand_list}")
|
||||
lines.append(f"Productos con stock bajo (<=3 unidades): {low_stock}")
|
||||
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local.")
|
||||
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local. Si no hay stock exacto, sugiere alternativa similar.")
|
||||
|
||||
return "\n".join(lines)
|
||||
except Exception:
|
||||
@@ -161,6 +271,7 @@ def get_inventory_context(tenant_conn, branch_id=None):
|
||||
|
||||
|
||||
VISION_MODEL = "google/gemma-3-27b-it:free"
|
||||
HERMES_VISION_MODEL = "hermes-agent"
|
||||
|
||||
VISION_SYSTEM_PROMPT = """Eres un experto en identificación de autopartes. El usuario te envía una foto de una parte automotriz.
|
||||
Tu trabajo es:
|
||||
@@ -219,54 +330,41 @@ def chat_with_image(user_message, image_base64, conversation_history=None, inven
|
||||
]
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
import time
|
||||
max_retries = 3
|
||||
|
||||
for attempt in range(max_retries):
|
||||
# Vision backends: QWEN only, fallback to OpenRouter if key present
|
||||
backends = []
|
||||
if QWEN_ENABLED:
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
|
||||
if OPENROUTER_API_KEY:
|
||||
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, VISION_MODEL))
|
||||
|
||||
last_error = None
|
||||
for url, key, model_id in backends:
|
||||
_validate_model(model_id)
|
||||
result = _post_chat_completion(url, key, model_id, messages, max_tokens=500, temperature=0.3, timeout=30)
|
||||
if result is None:
|
||||
last_error = "api_error"
|
||||
continue
|
||||
content = result["content"]
|
||||
try:
|
||||
resp = requests.post(
|
||||
OPENROUTER_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": VISION_MODEL,
|
||||
"messages": messages,
|
||||
"max_tokens": 500,
|
||||
"temperature": 0.3,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
wait = (attempt + 1) * 5
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(wait)
|
||||
continue
|
||||
return {"message": "El asistente esta ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
|
||||
try:
|
||||
stripped = content.strip()
|
||||
if stripped.startswith("```"):
|
||||
lines = stripped.split("\n")
|
||||
json_str = "\n".join(lines[1:-1])
|
||||
parsed = json.loads(json_str)
|
||||
else:
|
||||
parsed = json.loads(stripped)
|
||||
stripped = content.strip()
|
||||
if stripped.startswith("```"):
|
||||
lines = stripped.split("\n")
|
||||
json_str = "\n".join(lines[1:-1])
|
||||
parsed = json.loads(json_str)
|
||||
return parsed
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
return {"message": content, "search_query": None, "vehicle": None}
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
return {
|
||||
"message": f"Error al analizar imagen: {str(e)}",
|
||||
"search_query": None,
|
||||
"vehicle": None,
|
||||
}
|
||||
else:
|
||||
parsed = json.loads(stripped)
|
||||
return parsed
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
return {"message": content, "search_query": None, "vehicle": None}
|
||||
|
||||
if last_error == "api_error":
|
||||
return {"message": "El asistente esta ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
|
||||
return {
|
||||
"message": f"Error al analizar imagen: {last_error}",
|
||||
"search_query": None,
|
||||
"vehicle": None,
|
||||
}
|
||||
|
||||
|
||||
def classify_part(part_number):
|
||||
@@ -287,47 +385,32 @@ def classify_part(part_number):
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
|
||||
import time
|
||||
max_retries = 3
|
||||
|
||||
for attempt in range(max_retries):
|
||||
# Backends: QWEN only, fallback to OpenRouter if key present
|
||||
backends = []
|
||||
if QWEN_ENABLED:
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
|
||||
if OPENROUTER_API_KEY:
|
||||
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, MODEL))
|
||||
|
||||
for url, key, model_id in backends:
|
||||
_validate_model(model_id)
|
||||
result = _post_chat_completion(url, key, model_id, messages, max_tokens=300, temperature=0.2, timeout=15)
|
||||
if result is None:
|
||||
continue
|
||||
content = result["content"]
|
||||
try:
|
||||
resp = requests.post(
|
||||
OPENROUTER_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": MODEL,
|
||||
"messages": messages,
|
||||
"max_tokens": 300,
|
||||
"temperature": 0.2,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
wait = (attempt + 1) * 5
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(wait)
|
||||
continue
|
||||
return {"name": None, "brand": None, "vehicle": None, "category": None}
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
|
||||
stripped = content.strip()
|
||||
if stripped.startswith("```"):
|
||||
lines = stripped.split("\n")
|
||||
json_str = "\n".join(lines[1:-1])
|
||||
parsed = json.loads(json_str)
|
||||
return parsed
|
||||
else:
|
||||
parsed = json.loads(stripped)
|
||||
return parsed
|
||||
return parsed
|
||||
except Exception:
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
return {"name": None, "brand": None, "vehicle": None, "category": None}
|
||||
continue
|
||||
return {"name": None, "brand": None, "vehicle": None, "category": None}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -491,74 +574,77 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
||||
|
||||
last_error = None
|
||||
|
||||
# Try each model in the fallback chain on 429 (rate limit)
|
||||
for model_id in FALLBACK_MODELS:
|
||||
_validate_model(model_id) # Block paid models
|
||||
try:
|
||||
resp = requests.post(
|
||||
OPENROUTER_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model_id,
|
||||
"messages": messages,
|
||||
"max_tokens": 800,
|
||||
"temperature": 0.3,
|
||||
},
|
||||
timeout=25,
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
# Build backend list: QWEN first, then OpenRouter fallback
|
||||
backends = []
|
||||
if QWEN_ENABLED:
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 18, SYSTEM_PROMPT_SHORT, 1200))
|
||||
if OPENROUTER_API_KEY:
|
||||
for m in FALLBACK_MODELS:
|
||||
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, m, 25, SYSTEM_PROMPT, 800))
|
||||
|
||||
for url, key, model_id, timeout_sec, sys_prompt, max_tok in backends:
|
||||
_validate_model(model_id)
|
||||
# Use backend-specific system prompt and max_tokens
|
||||
sys_content = sys_prompt
|
||||
if inventory_context:
|
||||
sys_content = sys_prompt + "\n\n" + inventory_context
|
||||
msgs = [{"role": "system", "content": sys_content}]
|
||||
if conversation_history:
|
||||
msgs.extend(conversation_history)
|
||||
msgs.append({"role": "user", "content": user_message})
|
||||
|
||||
# Retry logic: QWEN gets 3 attempts with 2s delay because the API is flaky
|
||||
max_retries = 3 if url == QWEN_CHAT_URL else 1
|
||||
result = None
|
||||
for attempt in range(1, max_retries + 1):
|
||||
result = _post_chat_completion(url, key, model_id, msgs, max_tokens=max_tok, temperature=0.3, timeout=timeout_sec)
|
||||
if result is not None:
|
||||
break
|
||||
if attempt < max_retries:
|
||||
print(f"[AI] QWEN attempt {attempt} failed, retrying in 2s...")
|
||||
_time_chat.sleep(2)
|
||||
|
||||
if result is None:
|
||||
if url == QWEN_CHAT_URL:
|
||||
print(f"[AI] QWEN failed after {max_retries} attempts, trying fallback...")
|
||||
last_error = "qwen_failed"
|
||||
else:
|
||||
print(f"[AI] Rate limited on {model_id}, trying next model...")
|
||||
last_error = "rate_limit"
|
||||
continue
|
||||
if resp.status_code >= 400:
|
||||
print(f"[AI] HTTP {resp.status_code} on {model_id}: {resp.text[:200]}")
|
||||
last_error = f"http_{resp.status_code}"
|
||||
continue
|
||||
data = resp.json()
|
||||
choice = data.get("choices", [{}])[0]
|
||||
content = choice.get("message", {}).get("content", "").strip()
|
||||
finish = choice.get("finish_reason", "")
|
||||
|
||||
if not content:
|
||||
print(f"[AI] Empty response from {model_id} (finish={finish})")
|
||||
last_error = "empty_response"
|
||||
continue
|
||||
|
||||
print(f"[AI] Response from {model_id} (finish={finish}, {len(content)} chars)")
|
||||
|
||||
# Try to parse JSON response
|
||||
try:
|
||||
stripped = content.strip()
|
||||
if stripped.startswith("```"):
|
||||
lines = stripped.split("\n")
|
||||
json_str = "\n".join(lines[1:-1])
|
||||
parsed = json.loads(json_str)
|
||||
else:
|
||||
parsed = json.loads(stripped)
|
||||
# Successful JSON response — cache it
|
||||
if cache_key:
|
||||
_cache_set(cache_key, parsed)
|
||||
return parsed
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
fallback = {"message": content, "search_query": None, "vehicle": None}
|
||||
# Cache the fallback too — the model gave us a real answer,
|
||||
# it just wasn't JSON. Next hit saves the API call.
|
||||
if cache_key:
|
||||
_cache_set(cache_key, fallback)
|
||||
return fallback
|
||||
except Exception as e:
|
||||
print(f"[AI] Error with {model_id}: {e}")
|
||||
last_error = str(e)
|
||||
continue
|
||||
|
||||
content = result["content"]
|
||||
finish = result["finish_reason"]
|
||||
print(f"[AI] Response from {model_id} (finish={finish}, {len(content)} chars)")
|
||||
|
||||
# Try to parse JSON response
|
||||
try:
|
||||
stripped = content.strip()
|
||||
if stripped.startswith("```"):
|
||||
lines = stripped.split("\n")
|
||||
json_str = "\n".join(lines[1:-1])
|
||||
parsed = json.loads(json_str)
|
||||
else:
|
||||
parsed = json.loads(stripped)
|
||||
# Successful JSON response — cache it
|
||||
if cache_key:
|
||||
_cache_set(cache_key, parsed)
|
||||
return parsed
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
fallback = {"message": content, "search_query": None, "vehicle": None}
|
||||
# Cache the fallback too — the model gave us a real answer,
|
||||
# it just wasn't JSON. Next hit saves the API call.
|
||||
if cache_key:
|
||||
_cache_set(cache_key, fallback)
|
||||
return fallback
|
||||
|
||||
# All models exhausted — DON'T cache errors, we want retries next time
|
||||
if last_error == "rate_limit":
|
||||
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
|
||||
if last_error == "qwen_failed":
|
||||
return {"message": "El asistente tardó mucho en responder. Intenta de nuevo en un momento.", "search_query": None, "vehicle": None}
|
||||
return {
|
||||
"message": f"Error de conexion: {last_error}",
|
||||
"message": "El asistente no está disponible en este momento. Intenta de nuevo en unos segundos.",
|
||||
"search_query": None,
|
||||
"vehicle": None,
|
||||
}
|
||||
|
||||
157
pos/services/catalog_import_service.py
Normal file
157
pos/services/catalog_import_service.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Bulk catalog import service.
|
||||
|
||||
Imports products into inventory with optional vehicle compatibilities
|
||||
and SKU aliases. Can auto-generate vehicle fitment via QWEN AI if
|
||||
compatibilities are not provided.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def import_products(
|
||||
tenant_conn,
|
||||
products: list[dict],
|
||||
branch_id: int,
|
||||
auto_generate_compat: bool = False,
|
||||
employee_id: Optional[int] = None,
|
||||
):
|
||||
"""Import a list of products into inventory.
|
||||
|
||||
Each product dict may contain:
|
||||
- sku (str) *required
|
||||
- name (str) *required
|
||||
- brand (str)
|
||||
- description (str)
|
||||
- cost (float)
|
||||
- price (float)
|
||||
- stock (int)
|
||||
- location (str)
|
||||
- sku_aliases (list[dict]) [{"sku": str, "label": str}]
|
||||
- vehicles (list[dict]) [{"make", "model", "year", "engine", "engine_code"}]
|
||||
|
||||
Returns {"imported": N, "failed": [{"sku": ..., "error": ...}], "compat_generated": M}
|
||||
"""
|
||||
cur = tenant_conn.cursor()
|
||||
imported = 0
|
||||
failed = []
|
||||
compat_generated = 0
|
||||
|
||||
for idx, p in enumerate(products):
|
||||
sku = (p.get("sku") or "").strip()
|
||||
name = (p.get("name") or "").strip()
|
||||
if not sku or not name:
|
||||
failed.append({"index": idx, "sku": sku, "error": "sku and name are required"})
|
||||
continue
|
||||
|
||||
brand = (p.get("brand") or "").strip() or None
|
||||
description = (p.get("description") or "").strip() or None
|
||||
cost = float(p.get("cost") or 0)
|
||||
price = float(p.get("price") or 0)
|
||||
stock = int(p.get("stock") or 0)
|
||||
location = (p.get("location") or "").strip() or None
|
||||
barcode = (p.get("barcode") or "").strip() or None
|
||||
|
||||
try:
|
||||
# Check for duplicate SKU in same branch
|
||||
cur.execute(
|
||||
"SELECT id FROM inventory WHERE part_number = %s AND branch_id = %s AND is_active = true",
|
||||
(sku, branch_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
# Update existing item instead of creating new
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE inventory
|
||||
SET name = %s, brand = %s, description = %s, cost = %s, price_1 = %s,
|
||||
location = %s, barcode = COALESCE(%s, barcode), updated_at = NOW()
|
||||
WHERE part_number = %s AND branch_id = %s AND is_active = true
|
||||
RETURNING id
|
||||
""",
|
||||
(name, brand, description, cost, price, location, barcode, sku, branch_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
item_id = row[0]
|
||||
else:
|
||||
# Insert new item
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory
|
||||
(branch_id, part_number, barcode, name, description, brand,
|
||||
unit, cost, price_1, price_2, price_3, tax_rate,
|
||||
min_stock, max_stock, location, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, true)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
branch_id, sku, barcode, name, description, brand,
|
||||
"PZA", cost, price, price, price, 0.16,
|
||||
0, 0, location,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
item_id = row[0]
|
||||
|
||||
# Record initial stock if provided
|
||||
if stock > 0:
|
||||
from services.inventory_engine import record_initial
|
||||
record_initial(tenant_conn, item_id, branch_id, stock, cost)
|
||||
|
||||
# Insert SKU aliases
|
||||
aliases = p.get("sku_aliases") or []
|
||||
for alias in aliases:
|
||||
alias_sku = (alias.get("sku") or "").strip()
|
||||
label = (alias.get("label") or "").strip() or None
|
||||
if alias_sku:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (inventory_id, sku) DO UPDATE SET
|
||||
is_active = true, label = EXCLUDED.label
|
||||
""",
|
||||
(item_id, alias_sku, label),
|
||||
)
|
||||
|
||||
# Insert manual vehicle compatibilities
|
||||
vehicles = p.get("vehicles") or []
|
||||
for v in vehicles:
|
||||
make = (v.get("make") or "").strip()
|
||||
model = (v.get("model") or "").strip()
|
||||
year = v.get("year")
|
||||
engine = (v.get("engine") or "").strip() or None
|
||||
engine_code = (v.get("engine_code") or "").strip() or None
|
||||
if make and model and year:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory_vehicle_compat
|
||||
(inventory_id, make, model, year, engine, engine_code, source, model_year_engine_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'manual', NULL)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(item_id, make, model, year, engine, engine_code),
|
||||
)
|
||||
|
||||
tenant_conn.commit()
|
||||
imported += 1
|
||||
|
||||
# Auto-generate compat via QWEN if requested and no vehicles provided
|
||||
if auto_generate_compat and not vehicles:
|
||||
try:
|
||||
from services.qwen_fitment import get_vehicle_fitment
|
||||
from services.inventory_vehicle_compat import save_qwen_fitment
|
||||
fitment = get_vehicle_fitment(sku, name, brand or "")
|
||||
inserted = save_qwen_fitment(tenant_conn, item_id, fitment)
|
||||
compat_generated += inserted
|
||||
except Exception as qe:
|
||||
logger.warning("QWEN auto-match failed for %s: %s", sku, qe)
|
||||
|
||||
except Exception as e:
|
||||
tenant_conn.rollback()
|
||||
logger.warning("Import failed for sku=%s: %s", sku, e)
|
||||
failed.append({"index": idx, "sku": sku, "error": str(e)})
|
||||
|
||||
cur.close()
|
||||
return {"imported": imported, "failed": failed, "compat_generated": compat_generated}
|
||||
@@ -30,28 +30,41 @@ OEM_BRANDS_NA = (
|
||||
)
|
||||
|
||||
# ─── Local mode — brands actually stocked by Mexican bodegas ────────────────
|
||||
# Popular Mexican market passenger cars + light trucks. Edit as needed.
|
||||
# All brands with vehicles >= 1980 relevant to Mexico, USA and Canada.
|
||||
# Covers passenger cars, light trucks, and common commercial vehicles.
|
||||
LOCAL_BODEGA_BRANDS = (
|
||||
'NISSAN', # Tsuru, Sentra, Versa, March, Tiida, Navara
|
||||
'VW', # Jetta, Pointer, Vento, Gol, Polo, Beetle
|
||||
'CHEVROLET', # Aveo, Chevy, Spark, Beat, Sonic, Sail
|
||||
'FORD', # Fiesta, Focus, EcoSport, Ranger, Figo
|
||||
'TOYOTA', # Corolla, Yaris, Hilux, Avanza, Tacoma
|
||||
'HONDA', # Civic, City, CR-V, Fit, HR-V
|
||||
'DODGE', # Attitude, Neon, Journey
|
||||
'CHRYSLER',
|
||||
'RAM', # Pickups
|
||||
'HYUNDAI', # Accent, Grand i10, Tucson, Elantra
|
||||
'KIA', # Rio, Forte, Sportage, Sorento
|
||||
'MAZDA', # 2, 3, CX-5, CX-30
|
||||
'MITSUBISHI', # Lancer, L200, Outlander
|
||||
'RENAULT', # Logan, Sandero, Duster, Stepway
|
||||
'SEAT', # Ibiza, Leon, Arona
|
||||
'FIAT', # Uno, Palio, Mobi
|
||||
'SUZUKI', # Swift, Vitara, Ignis, Ertiga
|
||||
'JEEP', # Compass, Wrangler, Grand Cherokee, Renegade
|
||||
'GMC', # Sierra, Terrain
|
||||
'BUICK', # Encore, Enclave (GM)
|
||||
# ─── Americanas ───
|
||||
'BUICK', 'CADILLAC', 'CHEVROLET', 'CHRYSLER', 'DODGE', 'FORD', 'GMC',
|
||||
'HUMMER', 'JEEP', 'LINCOLN', 'MERCURY', 'OLDSMOBILE', 'PONTIAC', 'RAM',
|
||||
'SATURN', 'TESLA', 'RIVIAN', 'LUCID', 'POLARIS',
|
||||
# ─── Japonesas ───
|
||||
'ACURA', 'DAIHATSU', 'HONDA', 'INFINITI', 'ISUZU', 'LEXUS', 'MAZDA',
|
||||
'MITSUBISHI', 'NISSAN', 'SCION', 'SUBARU', 'SUZUKI', 'TOYOTA',
|
||||
# ─── Coreanas ───
|
||||
'GENESIS', 'HYUNDAI', 'KIA', 'KG MOBILITY',
|
||||
# ─── Alemanas ───
|
||||
'ALPINA', 'AUDI', 'BMW', 'BRABUS', 'MAYBACH', 'MERCEDES-BENZ', 'MINI',
|
||||
'OPEL', 'PORSCHE', 'SMART', 'VW',
|
||||
# ─── Inglesas / UK ───
|
||||
'ASTON MARTIN', 'BENTLEY', 'JAGUAR', 'LAND ROVER', 'LOTUS', 'MG',
|
||||
'MORGAN', 'ROLLS-ROYCE',
|
||||
# ─── Italianas ───
|
||||
'ABARTH', 'ALFA ROMEO', 'FERRARI', 'FIAT', 'LAMBORGHINI', 'MASERATI',
|
||||
# ─── Francesas ───
|
||||
'ALPINE', 'CITROËN', 'DS', 'PEUGEOT', 'RENAULT',
|
||||
# ─── Suecas ───
|
||||
'SAAB', 'VOLVO',
|
||||
# ─── Española ───
|
||||
'SEAT',
|
||||
# ─── Chinas con presencia en MX ───
|
||||
'BAIC', 'BYD', 'CHANGAN', 'CHERY', 'DFSK', 'GEELY', 'GREAT WALL',
|
||||
'HAVAL', 'JAC', 'JAECOO', 'JETOUR', 'JETTA', 'JMC', 'MAXUS', 'MG',
|
||||
'OMODA',
|
||||
# ─── Indias ───
|
||||
'MAHINDRA', 'TATA',
|
||||
# ─── Camiones / comerciales ───
|
||||
'DAEWOO', 'FARGO', 'FREIGHTLINER', 'INNOCENTI', 'INTERNATIONAL',
|
||||
'IVECO', 'LANCIA', 'MAN', 'SKODA',
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -464,3 +464,130 @@ def build_pago_xml(payment, tenant_config, customer, original_uuid):
|
||||
|
||||
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
||||
pretty_print=True).decode('utf-8')
|
||||
|
||||
|
||||
def build_global_invoice_xml(sales, tenant_config, year, month):
|
||||
"""Build CFDI 4.0 XML for a monthly global invoice (Factura Global).
|
||||
|
||||
Groups multiple cash sales (PUE, <= $2,000 each, no individual CFDI)
|
||||
into a single CFDI tipo Ingreso with InformacionGlobal.
|
||||
|
||||
Args:
|
||||
sales: list of dicts with keys:
|
||||
id, subtotal, discount_total, tax_total, total,
|
||||
items: [{name, quantity, unit_price, discount_amount,
|
||||
tax_rate, tax_amount, subtotal,
|
||||
clave_prod_serv, clave_unidad}]
|
||||
tenant_config: dict with keys:
|
||||
rfc, razon_social, regimen_fiscal, cp, serie (optional)
|
||||
year: int, e.g. 2026
|
||||
month: int, e.g. 6
|
||||
|
||||
Returns:
|
||||
str: XML string (unsigned, ready for Horux)
|
||||
"""
|
||||
nsmap = {
|
||||
'cfdi': CFDI_NS,
|
||||
'xsi': XSI_NS,
|
||||
}
|
||||
|
||||
# Aggregate totals
|
||||
total_subtotal = Decimal('0')
|
||||
total_discount = Decimal('0')
|
||||
total_tax = Decimal('0')
|
||||
total_total = Decimal('0')
|
||||
for sale in sales:
|
||||
total_subtotal += _to_dec(sale.get('subtotal', 0))
|
||||
total_discount += _to_dec(sale.get('discount_total', 0))
|
||||
total_tax += _to_dec(sale.get('tax_total', 0))
|
||||
total_total += _to_dec(sale.get('total', 0))
|
||||
|
||||
root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap)
|
||||
root.set(f'{{{XSI_NS}}}schemaLocation', CFDI_SCHEMA_LOCATION)
|
||||
root.set('Version', '4.0')
|
||||
root.set('Serie', tenant_config.get('serie', 'FG'))
|
||||
root.set('Folio', f'{year}{month:02d}')
|
||||
root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))
|
||||
root.set('FormaPago', '01') # Efectivo (most common for global)
|
||||
root.set('SubTotal', _format_amount(total_subtotal))
|
||||
|
||||
if total_discount > 0:
|
||||
root.set('Descuento', _format_amount(total_discount))
|
||||
|
||||
root.set('Moneda', 'MXN')
|
||||
root.set('Total', _format_amount(total_total))
|
||||
root.set('TipoDeComprobante', 'I') # Ingreso
|
||||
root.set('Exportacion', '01')
|
||||
root.set('MetodoPago', 'PUE')
|
||||
root.set('LugarExpedicion', tenant_config.get('cp', '00000'))
|
||||
|
||||
# InformacionGlobal (monthly global invoice)
|
||||
info_global = _make_element(root, 'InformacionGlobal')
|
||||
info_global.set('Periodicidad', '04') # Mensual
|
||||
info_global.set('Meses', f'{month:02d}')
|
||||
info_global.set('Anio', str(year))
|
||||
|
||||
# Emisor
|
||||
emisor = _make_element(root, 'Emisor')
|
||||
emisor.set('Rfc', tenant_config['rfc'])
|
||||
emisor.set('Nombre', tenant_config['razon_social'])
|
||||
emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601'))
|
||||
|
||||
# Receptor: Publico en general
|
||||
receptor = _make_element(root, 'Receptor')
|
||||
receptor.set('Rfc', RFC_PUBLICO_GENERAL)
|
||||
receptor.set('Nombre', 'PUBLICO EN GENERAL')
|
||||
receptor.set('DomicilioFiscalReceptor', tenant_config.get('cp', '00000'))
|
||||
receptor.set('RegimenFiscalReceptor', '616')
|
||||
receptor.set('UsoCFDI', 'S01')
|
||||
|
||||
# Conceptos: one per sale item (simplified)
|
||||
conceptos = _make_element(root, 'Conceptos')
|
||||
|
||||
for sale in sales:
|
||||
for item in sale.get('items', []):
|
||||
qty = int(item.get('quantity', 1))
|
||||
unit_price = _to_dec(item.get('unit_price', 0))
|
||||
discount_amount = _to_dec(item.get('discount_amount', 0))
|
||||
tax_rate = _to_dec(item.get('tax_rate', '0.16'))
|
||||
tax_amount = _to_dec(item.get('tax_amount', 0))
|
||||
|
||||
importe = (unit_price * qty).quantize(TWO, ROUND_HALF_UP)
|
||||
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
concepto = _make_element(conceptos, 'Concepto')
|
||||
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||
concepto.set('NoIdentificacion', item.get('part_number') or str(sale['id']))
|
||||
concepto.set('Cantidad', str(qty))
|
||||
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||
concepto.set('Unidad', 'PZA')
|
||||
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||
concepto.set('Importe', _format_amount(importe))
|
||||
concepto.set('ObjetoImp', '02')
|
||||
|
||||
if discount_amount > 0:
|
||||
concepto.set('Descuento', _format_amount(discount_amount))
|
||||
|
||||
impuestos_concepto = _make_element(concepto, 'Impuestos')
|
||||
traslados_concepto = _make_element(impuestos_concepto, 'Traslados')
|
||||
traslado = _make_element(traslados_concepto, 'Traslado')
|
||||
traslado.set('Base', _format_amount(base))
|
||||
traslado.set('Impuesto', '002')
|
||||
traslado.set('TipoFactor', 'Tasa')
|
||||
traslado.set('TasaOCuota', _format_rate(tax_rate))
|
||||
traslado.set('Importe', _format_amount(tax_amount))
|
||||
|
||||
# Impuestos totales
|
||||
impuestos = _make_element(root, 'Impuestos')
|
||||
impuestos.set('TotalImpuestosTrasladados', _format_amount(total_tax))
|
||||
traslados = _make_element(impuestos, 'Traslados')
|
||||
traslado_total = _make_element(traslados, 'Traslado')
|
||||
traslado_total.set('Base', _format_amount(total_subtotal))
|
||||
traslado_total.set('Impuesto', '002')
|
||||
traslado_total.set('TipoFactor', 'Tasa')
|
||||
traslado_total.set('TasaOCuota', '0.160000')
|
||||
traslado_total.set('Importe', _format_amount(total_tax))
|
||||
|
||||
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
||||
pretty_print=True).decode('utf-8')
|
||||
|
||||
243
pos/services/cfdi_facturapi_builder.py
Normal file
243
pos/services/cfdi_facturapi_builder.py
Normal file
@@ -0,0 +1,243 @@
|
||||
# /home/Autopartes/pos/services/cfdi_facturapi_builder.py
|
||||
"""Build Facturapi invoice payloads from Nexus sales data.
|
||||
|
||||
Facturapi expects a JSON payload instead of an unsigned XML. This module
|
||||
generates those payloads for:
|
||||
- Ingreso (sale invoice)
|
||||
- Egreso (credit note)
|
||||
- Pago (payment complement)
|
||||
- Factura global mensual
|
||||
"""
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from datetime import datetime
|
||||
|
||||
# SAT defaults
|
||||
RFC_PUBLICO_GENERAL = "XAXX010101000"
|
||||
RFC_EXTRANJERO = "XEXX010101000"
|
||||
|
||||
# Forma de pago mapping (Nexus internal -> SAT code)
|
||||
FORMA_PAGO_MAP = {
|
||||
"efectivo": "01",
|
||||
"transferencia": "03",
|
||||
"tarjeta": "04",
|
||||
"cheque": "02",
|
||||
"credito": "99",
|
||||
"mixto": "99",
|
||||
"99": "99",
|
||||
}
|
||||
|
||||
# Metodo de pago
|
||||
METODO_PAGO_MAP = {
|
||||
"PUE": "PUE",
|
||||
"PPD": "PPD",
|
||||
}
|
||||
|
||||
TWO = Decimal("0.01")
|
||||
SIX = Decimal("0.000001")
|
||||
|
||||
|
||||
def _to_dec(val):
|
||||
if val is None:
|
||||
return Decimal("0")
|
||||
return Decimal(str(val))
|
||||
|
||||
|
||||
def _fmt2(val):
|
||||
return float(_to_dec(val).quantize(TWO, ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _fmt6(val):
|
||||
return float(_to_dec(val).quantize(SIX, ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _resolve_forma_pago(sale):
|
||||
method = (sale.get("payment_method") or "").lower().strip()
|
||||
fp = (sale.get("forma_pago_sat") or "").strip()
|
||||
if fp:
|
||||
return fp
|
||||
return FORMA_PAGO_MAP.get(method, "99")
|
||||
|
||||
|
||||
def _resolve_metodo_pago(sale):
|
||||
mp = (sale.get("metodo_pago_sat") or "").upper().strip()
|
||||
if mp in ("PUE", "PPD"):
|
||||
return mp
|
||||
# Default: credit sales are PPD, cash sales are PUE
|
||||
if sale.get("sale_type") == "credit" or sale.get("payment_method") == "credito":
|
||||
return "PPD"
|
||||
return "PUE"
|
||||
|
||||
|
||||
def _build_items(sale_items):
|
||||
items = []
|
||||
for item in sale_items or []:
|
||||
qty = int(item.get("quantity", 1))
|
||||
unit_price = _to_dec(item.get("unit_price", 0))
|
||||
discount = _to_dec(item.get("discount_amount", 0))
|
||||
tax_rate = _to_dec(item.get("tax_rate", "0.16"))
|
||||
|
||||
# Facturapi price is unit price before taxes and discounts
|
||||
product = {
|
||||
"description": item.get("name") or "Autoparte",
|
||||
"product_key": item.get("clave_prod_serv") or "25174800",
|
||||
"unit_key": item.get("clave_unidad") or "H87",
|
||||
"unit_name": "Pieza",
|
||||
"price": _fmt2(unit_price),
|
||||
"tax_included": False,
|
||||
"taxes": [
|
||||
{
|
||||
"type": "IVA",
|
||||
"rate": _fmt6(tax_rate),
|
||||
"factor": "Tasa",
|
||||
}
|
||||
],
|
||||
}
|
||||
if discount > 0:
|
||||
product["discount"] = _fmt2(discount / qty) if qty > 0 else _fmt2(discount)
|
||||
|
||||
items.append({"quantity": qty, "product": product})
|
||||
return items
|
||||
|
||||
|
||||
def _build_customer_payload(customer, tenant_cp):
|
||||
if not customer or not customer.get("rfc"):
|
||||
# Publico en general
|
||||
return {
|
||||
"tax_id": RFC_PUBLICO_GENERAL,
|
||||
"legal_name": "PUBLICO EN GENERAL",
|
||||
"tax_system": "616",
|
||||
"address": {"zip": tenant_cp or "00000"},
|
||||
}
|
||||
|
||||
rfc = (customer.get("rfc") or "").upper().strip()
|
||||
return {
|
||||
"tax_id": rfc,
|
||||
"legal_name": customer.get("razon_social") or customer.get("name") or rfc,
|
||||
"tax_system": customer.get("regimen_fiscal") or "616",
|
||||
"email": customer.get("email"),
|
||||
"address": {"zip": customer.get("cp") or tenant_cp or "00000"},
|
||||
}
|
||||
|
||||
|
||||
def build_ingreso_payload(sale, tenant_config, customer=None):
|
||||
"""Build Facturapi payload for a sale (Comprobante tipo Ingreso)."""
|
||||
tenant_cp = tenant_config.get("cp", "00000")
|
||||
customer_payload = _build_customer_payload(customer, tenant_cp)
|
||||
|
||||
payload = {
|
||||
"customer": customer_payload,
|
||||
"items": _build_items(sale.get("items", [])),
|
||||
"use": customer.get("uso_cfdi") if customer and customer.get("rfc") else "S01",
|
||||
"payment_form": _resolve_forma_pago(sale),
|
||||
"payment_method": _resolve_metodo_pago(sale),
|
||||
"currency": "MXN",
|
||||
"series": tenant_config.get("serie", "A"),
|
||||
"folio_number": sale["id"],
|
||||
}
|
||||
|
||||
# Optional exchange rate for USD
|
||||
if sale.get("currency") and sale["currency"] != "MXN" and sale.get("exchange_rate"):
|
||||
payload["exchange"] = _fmt6(sale["exchange_rate"])
|
||||
payload["currency"] = sale["currency"]
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def build_egreso_payload(sale, tenant_config, customer, original_uuid):
|
||||
"""Build Facturapi payload for a credit note (Comprobante tipo Egreso)."""
|
||||
payload = build_ingreso_payload(sale, tenant_config, customer)
|
||||
payload["type"] = "E"
|
||||
payload["related_documents"] = [
|
||||
{"relationship": "01", "documents": [original_uuid]}
|
||||
]
|
||||
payload["payment_method"] = "PUE"
|
||||
return payload
|
||||
|
||||
|
||||
def build_pago_payload(payment, tenant_config, customer, original_uuid):
|
||||
"""Build Facturapi payload for a payment complement (Comprobante tipo Pago)."""
|
||||
tenant_cp = tenant_config.get("cp", "00000")
|
||||
customer_payload = _build_customer_payload(customer, tenant_cp)
|
||||
|
||||
amount = _to_dec(payment.get("amount", 0))
|
||||
base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP)
|
||||
iva = (amount - base).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "T" not in str(payment_date):
|
||||
payment_date = f"{payment_date}T12:00:00"
|
||||
|
||||
forma_pago = FORMA_PAGO_MAP.get(
|
||||
(payment.get("payment_method") or "").lower().strip(), "01"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"type": "P",
|
||||
"customer": customer_payload,
|
||||
"complements": [
|
||||
{
|
||||
"type": "pago",
|
||||
"data": {
|
||||
"payment_form": forma_pago,
|
||||
"payment_date": payment_date,
|
||||
"amount": _fmt2(amount),
|
||||
"related_documents": [
|
||||
{
|
||||
"uuid": original_uuid,
|
||||
"amount": _fmt2(amount),
|
||||
"taxes": [
|
||||
{
|
||||
"type": "IVA",
|
||||
"rate": 0.16,
|
||||
"factor": "Tasa",
|
||||
"base": _fmt2(base),
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def build_global_invoice_payload(sales, tenant_config, year, month):
|
||||
"""Build Facturapi payload for a monthly global invoice."""
|
||||
tenant_cp = tenant_config.get("cp", "00000")
|
||||
|
||||
total_subtotal = Decimal("0")
|
||||
total_discount = Decimal("0")
|
||||
total_tax = Decimal("0")
|
||||
total_total = Decimal("0")
|
||||
all_items = []
|
||||
|
||||
for sale in sales:
|
||||
total_subtotal += _to_dec(sale.get("subtotal", 0))
|
||||
total_discount += _to_dec(sale.get("discount_total", 0))
|
||||
total_tax += _to_dec(sale.get("tax_total", 0))
|
||||
total_total += _to_dec(sale.get("total", 0))
|
||||
all_items.extend(_build_items(sale.get("items", [])))
|
||||
|
||||
payload = {
|
||||
"customer": {
|
||||
"tax_id": RFC_PUBLICO_GENERAL,
|
||||
"legal_name": "PUBLICO EN GENERAL",
|
||||
"tax_system": "616",
|
||||
"address": {"zip": tenant_cp},
|
||||
},
|
||||
"items": all_items,
|
||||
"use": "S01",
|
||||
"payment_form": "01",
|
||||
"payment_method": "PUE",
|
||||
"currency": "MXN",
|
||||
"series": tenant_config.get("serie", "FG"),
|
||||
"folio_number": int(f"{year}{month:02d}"),
|
||||
"global": {
|
||||
"periodicity": "04", # Mensual
|
||||
"months": f"{month:02d}",
|
||||
"year": year,
|
||||
},
|
||||
}
|
||||
return payload
|
||||
@@ -1,25 +1,25 @@
|
||||
# /home/Autopartes/pos/services/cfdi_queue.py
|
||||
"""CFDI queue service: manages the timbrado pipeline.
|
||||
"""CFDI queue service: manages the Facturapi timbrado pipeline.
|
||||
|
||||
Flow:
|
||||
1. enqueue_cfdi() — inserts XML into cfdi_queue with status='pending'
|
||||
2. process_queue() — sends pending items to Horux API, updates status
|
||||
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
|
||||
2. process_queue() — sends pending items to Facturapi, updates status
|
||||
3. retry_failed() — retries failed items with exponential backoff
|
||||
4. cancel_cfdi() — sends cancel request to Horux API
|
||||
4. cancel_cfdi() — cancels a stamped CFDI via Facturapi
|
||||
|
||||
Horux API endpoints:
|
||||
POST /api/nexus/cfdi/stamp — send unsigned XML, receive signed+timbrado
|
||||
GET /api/nexus/cfdi/status/:uuid — check timbrado status
|
||||
POST /api/nexus/cfdi/cancel — cancel CFDI with SAT motive code
|
||||
Facturapi endpoints used:
|
||||
POST /v2/invoices — create and stamp an invoice
|
||||
GET /v2/invoices/:id — fetch invoice metadata
|
||||
DELETE /v2/invoices/:id — cancel with SAT motive
|
||||
|
||||
Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests
|
||||
from services import facturapi_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,10 +29,7 @@ MAX_RETRIES = len(BACKOFF_INTERVALS)
|
||||
|
||||
|
||||
def _generate_provisional_folio(conn):
|
||||
"""Generate a provisional folio like PRE-00001.
|
||||
|
||||
Uses the cfdi_queue table's max id to avoid collisions.
|
||||
"""
|
||||
"""Generate a provisional folio like PRE-00001."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
||||
seq = cur.fetchone()[0]
|
||||
@@ -40,14 +37,14 @@ def _generate_provisional_folio(conn):
|
||||
return f'PRE-{seq:05d}'
|
||||
|
||||
|
||||
def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
|
||||
def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
|
||||
"""Add a CFDI to the timbrado queue.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection
|
||||
sale_id: int (FK to sales)
|
||||
sale_id: int (FK to sales), may be None for global invoices
|
||||
cfdi_type: 'ingreso' | 'egreso' | 'pago'
|
||||
xml: str (unsigned XML from cfdi_builder)
|
||||
payload: dict (Facturapi JSON payload) or str (JSON string)
|
||||
|
||||
Returns:
|
||||
dict: {id, sale_id, type, status, provisional_folio}
|
||||
@@ -55,12 +52,14 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
|
||||
provisional_folio = _generate_provisional_folio(conn)
|
||||
cur = conn.cursor()
|
||||
|
||||
payload_json = payload if isinstance(payload, str) else json.dumps(payload)
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO cfdi_queue
|
||||
(sale_id, type, xml_unsigned, status, provisional_folio)
|
||||
(sale_id, type, payload_unsigned, status, provisional_folio)
|
||||
VALUES (%s, %s, %s, 'pending', %s)
|
||||
RETURNING id, created_at
|
||||
""", (sale_id, cfdi_type, xml, provisional_folio))
|
||||
""", (sale_id, cfdi_type, payload_json, provisional_folio))
|
||||
cfdi_id, created_at = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
@@ -74,17 +73,17 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
|
||||
}
|
||||
|
||||
|
||||
def process_queue(conn, horux_api_url, api_key):
|
||||
def process_queue(conn, tenant_config, dry_run=False):
|
||||
"""Process all pending CFDI items in the queue.
|
||||
|
||||
Sends each pending XML to Horux for timbrado. On success, updates
|
||||
Sends each pending payload to Facturapi for timbrado. On success, updates
|
||||
the record with the signed XML and UUID fiscal. On failure, increments
|
||||
retry_count and records the error.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection
|
||||
horux_api_url: str base URL for Horux API (e.g. 'https://horux.example.com')
|
||||
api_key: str Horux API key
|
||||
tenant_config: dict with facturapi_key (and optional facturapi_org_id)
|
||||
dry_run: if True, validates payload without stamping
|
||||
|
||||
Returns:
|
||||
dict: {processed: int, stamped: int, failed: int, details: [...]}
|
||||
@@ -92,7 +91,7 @@ def process_queue(conn, horux_api_url, api_key):
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, sale_id, type, xml_unsigned, retry_count
|
||||
SELECT id, sale_id, type, payload_unsigned, retry_count
|
||||
FROM cfdi_queue
|
||||
WHERE status IN ('pending', 'failed')
|
||||
AND retry_count < %s
|
||||
@@ -103,7 +102,12 @@ def process_queue(conn, horux_api_url, api_key):
|
||||
|
||||
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []}
|
||||
|
||||
for cfdi_id, sale_id, cfdi_type, xml_unsigned, retry_count in items:
|
||||
api_key = tenant_config.get('facturapi_key')
|
||||
if not api_key:
|
||||
cur.close()
|
||||
raise ValueError("Facturapi key not configured for tenant")
|
||||
|
||||
for cfdi_id, sale_id, cfdi_type, payload_unsigned, retry_count in items:
|
||||
results['processed'] += 1
|
||||
|
||||
# Update status to 'sending'
|
||||
@@ -113,54 +117,47 @@ def process_queue(conn, horux_api_url, api_key):
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f'{horux_api_url}/api/nexus/cfdi/stamp',
|
||||
headers={
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
'Content-Type': 'application/xml',
|
||||
},
|
||||
data=xml_unsigned.encode('utf-8'),
|
||||
timeout=30,
|
||||
)
|
||||
payload = json.loads(payload_unsigned or '{}')
|
||||
if not payload:
|
||||
raise ValueError("Empty payload in queue item")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
uuid_fiscal = data.get('uuid')
|
||||
xml_signed = data.get('xml', '')
|
||||
if dry_run:
|
||||
# TODO: Facturapi dry-run validation (not officially supported)
|
||||
# For now we just skip the API call and mark as stamped with a fake UUID
|
||||
raise ValueError("dry_run is not supported with Facturapi")
|
||||
|
||||
cur.execute("""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'stamped',
|
||||
xml_signed = %s,
|
||||
uuid_fiscal = %s,
|
||||
stamped_at = NOW(),
|
||||
error_message = NULL
|
||||
WHERE id = %s
|
||||
""", (xml_signed, uuid_fiscal, cfdi_id))
|
||||
conn.commit()
|
||||
invoice = facturapi_service.create_invoice(tenant_config, payload)
|
||||
invoice_id = invoice.get('id')
|
||||
uuid_fiscal = invoice.get('uuid')
|
||||
|
||||
results['stamped'] += 1
|
||||
results['details'].append({
|
||||
'id': cfdi_id, 'status': 'stamped', 'uuid': uuid_fiscal
|
||||
})
|
||||
else:
|
||||
error_msg = f'HTTP {response.status_code}: {response.text[:500]}'
|
||||
cur.execute("""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'failed',
|
||||
retry_count = retry_count + 1,
|
||||
error_message = %s
|
||||
WHERE id = %s
|
||||
""", (error_msg, cfdi_id))
|
||||
conn.commit()
|
||||
# Download signed XML for storage
|
||||
try:
|
||||
xml_signed = facturapi_service.download_xml(tenant_config, invoice_id)
|
||||
xml_signed_str = xml_signed.decode('utf-8') if isinstance(xml_signed, bytes) else str(xml_signed)
|
||||
except Exception as xml_err:
|
||||
logger.warning("Could not download signed XML for %s: %s", invoice_id, xml_err)
|
||||
xml_signed_str = ''
|
||||
|
||||
results['failed'] += 1
|
||||
results['details'].append({
|
||||
'id': cfdi_id, 'status': 'failed', 'error': error_msg
|
||||
})
|
||||
cur.execute("""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'stamped',
|
||||
xml_signed = %s,
|
||||
uuid_fiscal = %s,
|
||||
external_id = %s,
|
||||
stamped_at = NOW(),
|
||||
error_message = NULL
|
||||
WHERE id = %s
|
||||
""", (xml_signed_str, uuid_fiscal, invoice_id, cfdi_id))
|
||||
conn.commit()
|
||||
|
||||
except requests.RequestException as e:
|
||||
error_msg = f'Connection error: {str(e)[:500]}'
|
||||
results['stamped'] += 1
|
||||
results['details'].append({
|
||||
'id': cfdi_id, 'status': 'stamped',
|
||||
'uuid': uuid_fiscal, 'external_id': invoice_id,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'{type(e).__name__}: {str(e)[:500]}'
|
||||
cur.execute("""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'failed',
|
||||
@@ -180,20 +177,13 @@ def process_queue(conn, horux_api_url, api_key):
|
||||
|
||||
|
||||
def retry_failed(conn):
|
||||
"""Find failed items eligible for retry (based on backoff) and reset to pending.
|
||||
"""Find failed items eligible for retry and reset to pending.
|
||||
|
||||
Uses exponential backoff: item is eligible for retry only if enough
|
||||
time has passed since the last attempt based on retry_count.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection
|
||||
|
||||
Returns:
|
||||
int: number of items reset to pending
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
# For each failed item, check if enough time has passed for its retry level
|
||||
cur.execute("""
|
||||
SELECT id, retry_count, created_at
|
||||
FROM cfdi_queue
|
||||
@@ -206,15 +196,15 @@ def retry_failed(conn):
|
||||
now = datetime.utcnow()
|
||||
|
||||
for cfdi_id, retry_count, created_at in items:
|
||||
# Calculate required wait time based on retry count
|
||||
if retry_count < len(BACKOFF_INTERVALS):
|
||||
wait_seconds = BACKOFF_INTERVALS[retry_count]
|
||||
else:
|
||||
wait_seconds = BACKOFF_INTERVALS[-1] # max backoff
|
||||
wait_seconds = BACKOFF_INTERVALS[-1]
|
||||
|
||||
# Check if enough time has passed (use created_at as approximation)
|
||||
# In production, you'd track last_attempt_at separately
|
||||
if True: # Always eligible for manual retry trigger
|
||||
# Use created_at as approximation for last attempt.
|
||||
# In production, track last_attempt_at separately.
|
||||
elapsed = (now - created_at).total_seconds()
|
||||
if elapsed >= wait_seconds:
|
||||
cur.execute("""
|
||||
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
|
||||
""", (cfdi_id,))
|
||||
@@ -226,8 +216,8 @@ def retry_failed(conn):
|
||||
|
||||
|
||||
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||
horux_api_url=None, api_key=None):
|
||||
"""Cancel a stamped CFDI via Horux API.
|
||||
tenant_config=None):
|
||||
"""Cancel a stamped CFDI via Facturapi.
|
||||
|
||||
SAT cancellation motives:
|
||||
01: Comprobante emitido con errores con relacion (requires replacement UUID)
|
||||
@@ -240,8 +230,7 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||
cfdi_id: int (cfdi_queue.id)
|
||||
motive: str ('01', '02', '03', '04')
|
||||
replacement_uuid: str (required if motive == '01')
|
||||
horux_api_url: str (optional, skips API call if None — for offline)
|
||||
api_key: str (optional)
|
||||
tenant_config: dict with facturapi_key
|
||||
|
||||
Returns:
|
||||
dict: {id, status, message}
|
||||
@@ -258,13 +247,13 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
|
||||
SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s
|
||||
""", (cfdi_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"CFDI queue item {cfdi_id} not found")
|
||||
|
||||
_, uuid_fiscal, current_status = row
|
||||
_, uuid_fiscal, external_id, current_status = row
|
||||
|
||||
if current_status == 'cancelled':
|
||||
raise ValueError("CFDI is already cancelled")
|
||||
@@ -280,64 +269,26 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||
cur.close()
|
||||
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'}
|
||||
|
||||
# Send cancel request to Horux
|
||||
if horux_api_url and api_key:
|
||||
try:
|
||||
payload = {
|
||||
'uuid': uuid_fiscal,
|
||||
'motive': motive,
|
||||
}
|
||||
if replacement_uuid:
|
||||
payload['replacement_uuid'] = replacement_uuid
|
||||
if not tenant_config or not tenant_config.get('facturapi_key'):
|
||||
cur.close()
|
||||
raise ValueError("Facturapi key not configured for tenant")
|
||||
|
||||
response = requests.post(
|
||||
f'{horux_api_url}/api/nexus/cfdi/cancel',
|
||||
headers={
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
if not external_id:
|
||||
cur.close()
|
||||
raise ValueError("Cannot cancel: no Facturapi invoice id stored")
|
||||
|
||||
if response.status_code == 200:
|
||||
cur.execute("""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'cancelled',
|
||||
cancel_motive = %s,
|
||||
cancel_replacement_uuid = %s,
|
||||
error_message = NULL
|
||||
WHERE id = %s
|
||||
""", (motive, replacement_uuid, cfdi_id))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {
|
||||
'id': cfdi_id,
|
||||
'status': 'cancelled',
|
||||
'message': f'Cancelled with SAT (motive {motive})',
|
||||
}
|
||||
else:
|
||||
error_msg = f'Cancel failed: HTTP {response.status_code}: {response.text[:500]}'
|
||||
cur.execute("""
|
||||
UPDATE cfdi_queue
|
||||
SET error_message = %s
|
||||
WHERE id = %s
|
||||
""", (error_msg, cfdi_id))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
raise ValueError(error_msg)
|
||||
try:
|
||||
facturapi_service.cancel_invoice(
|
||||
tenant_config, external_id, motive,
|
||||
replacement_uuid=replacement_uuid,
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
cur.close()
|
||||
raise ValueError(f'Connection error during cancel: {str(e)}')
|
||||
else:
|
||||
# Offline mode: mark as cancelled locally, will sync later
|
||||
cur.execute("""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'cancelled',
|
||||
cancel_motive = %s,
|
||||
cancel_replacement_uuid = %s,
|
||||
error_message = 'Cancelled offline, pending SAT sync'
|
||||
error_message = NULL
|
||||
WHERE id = %s
|
||||
""", (motive, replacement_uuid, cfdi_id))
|
||||
conn.commit()
|
||||
@@ -345,24 +296,23 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||
return {
|
||||
'id': cfdi_id,
|
||||
'status': 'cancelled',
|
||||
'message': 'Cancelled offline, pending SAT sync',
|
||||
'message': f'Cancelled with SAT (motive {motive})',
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'Cancel failed: {str(e)[:500]}'
|
||||
cur.execute("""
|
||||
UPDATE cfdi_queue
|
||||
SET error_message = %s
|
||||
WHERE id = %s
|
||||
""", (error_msg, cfdi_id))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
raise ValueError(error_msg)
|
||||
|
||||
|
||||
def get_queue_status(conn, filters=None):
|
||||
"""Get CFDI queue items with optional filters.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection
|
||||
filters: dict with optional keys:
|
||||
status: str filter by status
|
||||
sale_id: int filter by sale
|
||||
page: int (default 1)
|
||||
per_page: int (default 50)
|
||||
|
||||
Returns:
|
||||
dict: {data: [...], pagination: {...}}
|
||||
"""
|
||||
"""Get CFDI queue items with optional filters."""
|
||||
filters = filters or {}
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -392,7 +342,7 @@ def get_queue_status(conn, filters=None):
|
||||
cur.execute(f"""
|
||||
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
||||
q.retry_count, q.provisional_folio, q.error_message,
|
||||
q.cancel_motive, q.created_at, q.stamped_at
|
||||
q.cancel_motive, q.created_at, q.stamped_at, q.external_id
|
||||
FROM cfdi_queue q
|
||||
WHERE {where}
|
||||
ORDER BY q.created_at DESC
|
||||
@@ -408,6 +358,7 @@ def get_queue_status(conn, filters=None):
|
||||
'error_message': r[7], 'cancel_motive': r[8],
|
||||
'created_at': str(r[9]) if r[9] else None,
|
||||
'stamped_at': str(r[10]) if r[10] else None,
|
||||
'external_id': r[11],
|
||||
})
|
||||
|
||||
cur.close()
|
||||
|
||||
168
pos/services/dropshipping_service.py
Normal file
168
pos/services/dropshipping_service.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Dropshipping integration service.
|
||||
|
||||
Provides read-only inventory access for external dropshipping platforms
|
||||
and webhook dispatching on stock/price/sale events.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from services.inventory_engine import get_stock_bulk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_tenant_by_api_key(master_conn, api_key: str):
|
||||
"""Find tenant_id and db_name for a given dropshipping API key.
|
||||
|
||||
Returns (tenant_id, db_name) or (None, None) if invalid.
|
||||
"""
|
||||
if not api_key:
|
||||
return None, None
|
||||
cur = master_conn.cursor()
|
||||
# tenant_config lives in each tenant DB, so we need to scan tenants
|
||||
cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true")
|
||||
tenants = cur.fetchall()
|
||||
for tid, db_name in tenants:
|
||||
try:
|
||||
tcur = master_conn.cursor()
|
||||
# Use dblink or connect to tenant DB? Simpler: the blueprint
|
||||
# will pass tenant_conn directly after resolution.
|
||||
# Instead, we store a mapping in master DB for speed.
|
||||
# For now, return all candidates and let caller validate.
|
||||
pass
|
||||
except Exception:
|
||||
continue
|
||||
cur.close()
|
||||
return None, None
|
||||
|
||||
|
||||
def _get_dropshipping_key(tenant_conn):
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'dropshipping_api_key'")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def validate_api_key(tenant_conn, api_key: str) -> bool:
|
||||
"""Check if the provided API key matches the tenant's configured key."""
|
||||
if not api_key:
|
||||
return False
|
||||
expected = _get_dropshipping_key(tenant_conn)
|
||||
return expected is not None and expected == api_key
|
||||
|
||||
|
||||
def get_inventory_list(tenant_conn, search: str = None, page: int = 1, per_page: int = 50):
|
||||
"""Return inventory items with stock and price for dropshipping."""
|
||||
offset = (max(page, 1) - 1) * per_page
|
||||
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
params = []
|
||||
where = "WHERE is_active = true"
|
||||
if search:
|
||||
where += " AND (name ILIKE %s OR part_number ILIKE %s)"
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, part_number, name, brand, price_1, price_2, price_3,
|
||||
image_url, unit, description
|
||||
FROM inventory
|
||||
{where}
|
||||
ORDER BY id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
params + [per_page, offset],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
# Count total
|
||||
cur.execute(f"SELECT COUNT(*) FROM inventory {where}", params)
|
||||
total = cur.fetchone()[0]
|
||||
cur.close()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
inv_id = r[0]
|
||||
items.append({
|
||||
"id": inv_id,
|
||||
"sku": r[1],
|
||||
"name": r[2],
|
||||
"brand": r[3],
|
||||
"price_1": float(r[4]) if r[4] else None,
|
||||
"price_2": float(r[5]) if r[5] else None,
|
||||
"price_3": float(r[6]) if r[6] else None,
|
||||
"stock": stock_map.get(inv_id, 0),
|
||||
"image_url": r[7],
|
||||
"unit": r[8],
|
||||
"description": r[9],
|
||||
})
|
||||
return {"items": items, "page": page, "per_page": per_page, "total": total}
|
||||
|
||||
|
||||
def get_inventory_by_sku(tenant_conn, sku: str):
|
||||
"""Return a single inventory item by SKU/part_number."""
|
||||
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, part_number, name, brand, price_1, price_2, price_3,
|
||||
image_url, unit, description
|
||||
FROM inventory
|
||||
WHERE part_number = %s AND is_active = true
|
||||
LIMIT 1
|
||||
""",
|
||||
(sku,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return None
|
||||
inv_id = row[0]
|
||||
return {
|
||||
"id": inv_id,
|
||||
"sku": row[1],
|
||||
"name": row[2],
|
||||
"brand": row[3],
|
||||
"price_1": float(row[4]) if row[4] else None,
|
||||
"price_2": float(row[5]) if row[5] else None,
|
||||
"price_3": float(row[6]) if row[6] else None,
|
||||
"stock": stock_map.get(inv_id, 0),
|
||||
"image_url": row[7],
|
||||
"unit": row[8],
|
||||
"description": row[9],
|
||||
}
|
||||
|
||||
|
||||
def get_stock_by_skus(tenant_conn, skus: list[str]) -> dict:
|
||||
"""Return stock levels for a list of SKUs."""
|
||||
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, part_number FROM inventory
|
||||
WHERE part_number = ANY(%s) AND is_active = true
|
||||
""",
|
||||
(skus,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
result = {}
|
||||
for inv_id, sku in rows:
|
||||
result[sku] = stock_map.get(inv_id, 0)
|
||||
return result
|
||||
|
||||
|
||||
def get_webhook_targets(tenant_conn, event_type: str) -> list[str]:
|
||||
"""Return active webhook URLs for a given event type."""
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT target_url FROM dropshipping_webhooks
|
||||
WHERE event_type = %s AND is_active = true
|
||||
""",
|
||||
(event_type,),
|
||||
)
|
||||
urls = [r[0] for r in cur.fetchall()]
|
||||
cur.close()
|
||||
return urls
|
||||
426
pos/services/facturapi_service.py
Normal file
426
pos/services/facturapi_service.py
Normal file
@@ -0,0 +1,426 @@
|
||||
# /home/Autopartes/pos/services/facturapi_service.py
|
||||
"""Facturapi integration for Nexus POS.
|
||||
|
||||
Uses Facturapi REST API directly (requests + Basic Auth) so it is safe for
|
||||
multi-tenant use. Each call receives the API key explicitly, avoiding the
|
||||
global client used by the official facturapi Python library.
|
||||
|
||||
Authentication modes:
|
||||
1. User key (FACTURAPI_USER_KEY env): creates/verifies organizations per tenant.
|
||||
2. Secret key per tenant (tenant_config.facturapi_secret_key): uses existing org.
|
||||
|
||||
Reference: https://docs.facturapi.io/
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_URL = "https://www.facturapi.io/v2"
|
||||
USER_KEY = os.environ.get("FACTURAPI_USER_KEY", "")
|
||||
|
||||
|
||||
class FacturapiError(Exception):
|
||||
def __init__(self, message: str, status_code: int = 0, response_body: str = ""):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.response_body = response_body
|
||||
|
||||
|
||||
# ─── HTTP helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def _request(method: str, endpoint: str, api_key: str, json_payload=None, params=None,
|
||||
extra_headers=None, timeout=60):
|
||||
"""Make a request to Facturapi REST API with Basic Auth."""
|
||||
url = f"{BASE_URL}{endpoint}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
try:
|
||||
resp = requests.request(
|
||||
method,
|
||||
url,
|
||||
auth=(api_key, ""),
|
||||
headers=headers,
|
||||
json=json_payload,
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise FacturapiError(f"Connection error: {e}", status_code=0)
|
||||
|
||||
if not resp.ok:
|
||||
raise FacturapiError(
|
||||
f"Facturapi {method.upper()} {endpoint} failed: {resp.status_code} {resp.text[:500]}",
|
||||
status_code=resp.status_code,
|
||||
response_body=resp.text,
|
||||
)
|
||||
|
||||
if resp.status_code == 204 or not resp.content:
|
||||
return {}
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _download(method: str, endpoint: str, api_key: str, params=None, timeout=60) -> bytes:
|
||||
"""Download binary content (XML/PDF)."""
|
||||
url = f"{BASE_URL}{endpoint}"
|
||||
resp = requests.request(
|
||||
method,
|
||||
url,
|
||||
auth=(api_key, ""),
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise FacturapiError(
|
||||
f"Download failed: {resp.status_code} {resp.text[:500]}",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
return resp.content
|
||||
|
||||
|
||||
# ─── Tenant config helpers ──────────────────────────────────────────────────
|
||||
|
||||
def _get_secret_key(tenant_config: dict) -> Optional[str]:
|
||||
for key in ("facturapi_key", "facturapi_secret_key"):
|
||||
val = (tenant_config.get(key) or "").strip()
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _get_user_key() -> Optional[str]:
|
||||
return USER_KEY.strip() or None
|
||||
|
||||
|
||||
def _is_user_key_mode(tenant_config: dict) -> bool:
|
||||
return bool(_get_user_key()) and not _get_secret_key(tenant_config)
|
||||
|
||||
|
||||
def get_api_key(tenant_config: dict) -> str:
|
||||
"""Resolve the API key to use for a tenant.
|
||||
|
||||
Priority:
|
||||
1. tenant_config.facturapi_secret_key (manual override)
|
||||
2. FACTURAPI_USER_KEY env (auto-org mode)
|
||||
"""
|
||||
secret = _get_secret_key(tenant_config)
|
||||
if secret:
|
||||
return secret
|
||||
user = _get_user_key()
|
||||
if user:
|
||||
return user
|
||||
raise FacturapiError(
|
||||
"Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
|
||||
)
|
||||
|
||||
|
||||
# ─── Organizations ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_organization(tenant_config: dict) -> dict:
|
||||
"""Create a new Facturapi organization for the tenant.
|
||||
|
||||
Requires FACTURAPI_USER_KEY.
|
||||
Returns dict with id, api_key.
|
||||
"""
|
||||
user_key = _get_user_key()
|
||||
if not user_key:
|
||||
raise FacturapiError("FACTURAPI_USER_KEY is required to create organizations")
|
||||
|
||||
payload = {"name": tenant_config.get("razon_social", tenant_config.get("name", "Nexus"))}
|
||||
legal = tenant_config.get("legal_name") or tenant_config.get("razon_social")
|
||||
if legal:
|
||||
payload["legal"] = {"name": legal}
|
||||
if tenant_config.get("rfc"):
|
||||
payload["legal"] = payload.get("legal", {})
|
||||
payload["legal"]["tax_id"] = tenant_config["rfc"]
|
||||
|
||||
org = _request("POST", "/organizations", user_key, json_payload=payload)
|
||||
org_id = org.get("id")
|
||||
|
||||
# Generate live secret key
|
||||
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={})
|
||||
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
|
||||
if not live_key:
|
||||
raise FacturapiError(f"Could not generate live key for org {org_id}")
|
||||
|
||||
return {"org_id": org_id, "api_key": live_key}
|
||||
|
||||
|
||||
def get_organization(org_id: str, api_key: str) -> dict:
|
||||
return _request("GET", f"/organizations/{org_id}", api_key)
|
||||
|
||||
|
||||
def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -> dict:
|
||||
"""Upload CSD (Certificado de Sello Digital) to Facturapi.
|
||||
|
||||
cer_b64 and key_b64 are base64-encoded strings.
|
||||
"""
|
||||
api_key = get_api_key(tenant_config)
|
||||
org_id = tenant_config.get("facturapi_org_id")
|
||||
if not org_id:
|
||||
raise FacturapiError("No Facturapi organization configured for tenant")
|
||||
|
||||
cer_bytes = base64.b64decode(cer_b64)
|
||||
key_bytes = base64.b64decode(key_b64)
|
||||
|
||||
url = f"{BASE_URL}/organizations/{org_id}/certificate"
|
||||
files = {
|
||||
"certificate": ("certificate.cer", cer_bytes, "application/octet-stream"),
|
||||
"private_key": ("private_key.key", key_bytes, "application/octet-stream"),
|
||||
"secret": (None, password),
|
||||
}
|
||||
resp = requests.post(url, auth=(api_key, ""), files=files, timeout=60)
|
||||
if not resp.ok:
|
||||
raise FacturapiError(
|
||||
f"CSD upload failed: {resp.status_code} {resp.text[:500]}",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _get_user_key_for_tenant(tenant_config: dict) -> str:
|
||||
"""Resolve the Facturapi user key to use for organization management.
|
||||
|
||||
Priority:
|
||||
1. FACTURAPI_USER_KEY environment variable
|
||||
2. tenant_config.facturapi_key if it starts with sk_user_
|
||||
"""
|
||||
user_key = _get_user_key()
|
||||
if user_key:
|
||||
return user_key
|
||||
tenant_key = (tenant_config.get("facturapi_key") or "").strip()
|
||||
if tenant_key.startswith("sk_user_"):
|
||||
return tenant_key
|
||||
raise FacturapiError(
|
||||
"FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required"
|
||||
)
|
||||
|
||||
|
||||
def find_organization_by_rfc(tenant_config: dict) -> Optional[dict]:
|
||||
"""Search for an existing Facturapi organization by tenant RFC.
|
||||
|
||||
Requires a user key (FACTURAPI_USER_KEY env or sk_user_* tenant key).
|
||||
Returns the organization dict or None.
|
||||
"""
|
||||
user_key = _get_user_key_for_tenant(tenant_config)
|
||||
|
||||
rfc = (tenant_config.get("rfc") or "").upper().strip()
|
||||
if not rfc:
|
||||
raise FacturapiError("Tenant RFC is required to search organizations")
|
||||
|
||||
page = 1
|
||||
while True:
|
||||
result = _request("GET", "/organizations", user_key, params={"page": page}, timeout=30)
|
||||
for org in result.get("data", []):
|
||||
legal = org.get("legal", {})
|
||||
if (legal.get("tax_id") or "").upper() == rfc:
|
||||
return org
|
||||
if page >= result.get("total_pages", 1):
|
||||
break
|
||||
page += 1
|
||||
return None
|
||||
|
||||
|
||||
def create_organization(tenant_config: dict) -> dict:
|
||||
"""Create a new Facturapi organization for the tenant and return live key.
|
||||
|
||||
Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
|
||||
Uses tenant RFC/razon_social if available.
|
||||
"""
|
||||
user_key = _get_user_key_for_tenant(tenant_config)
|
||||
|
||||
rfc = (tenant_config.get("rfc") or "").upper().strip()
|
||||
name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus"
|
||||
|
||||
# First try to find existing org by RFC
|
||||
existing = find_organization_by_rfc(tenant_config) if rfc else None
|
||||
if existing:
|
||||
org_id = existing["id"]
|
||||
else:
|
||||
payload = {"name": name}
|
||||
org = _request("POST", "/organizations", user_key, json_payload=payload, timeout=60)
|
||||
org_id = org.get("id")
|
||||
if not org_id:
|
||||
raise FacturapiError("Could not create organization: no id returned")
|
||||
|
||||
# Generate live secret key
|
||||
key_resp = _request(
|
||||
"PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60
|
||||
)
|
||||
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
|
||||
if not live_key:
|
||||
raise FacturapiError(f"Could not generate live key for org {org_id}")
|
||||
|
||||
return {"org_id": org_id, "api_key": live_key}
|
||||
|
||||
|
||||
def get_org_status(tenant_config: dict) -> dict:
|
||||
result = {
|
||||
"configured": False,
|
||||
"has_key": False,
|
||||
"has_org_id": False,
|
||||
"has_csd": False,
|
||||
"org_id": None,
|
||||
"legal_name": None,
|
||||
"tax_id": None,
|
||||
"pending_steps": [],
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
api_key = get_api_key(tenant_config)
|
||||
result["has_key"] = True
|
||||
except FacturapiError as e:
|
||||
result["error"] = str(e)
|
||||
return result
|
||||
|
||||
org_id = tenant_config.get("facturapi_org_id")
|
||||
if not org_id:
|
||||
result["error"] = "No Facturapi organization configured"
|
||||
return result
|
||||
|
||||
result["has_org_id"] = True
|
||||
result["org_id"] = org_id
|
||||
|
||||
try:
|
||||
org = get_organization(org_id, api_key)
|
||||
legal = org.get("legal", {})
|
||||
cert = org.get("certificate", {})
|
||||
result.update({
|
||||
"configured": True,
|
||||
"has_csd": bool(cert.get("has_certificate")),
|
||||
"legal_name": legal.get("name") or legal.get("legal_name"),
|
||||
"tax_id": legal.get("tax_id"),
|
||||
"pending_steps": org.get("pending_steps", []),
|
||||
})
|
||||
except FacturapiError as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ─── Customers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str:
|
||||
"""Create or update a customer in Facturapi and return its id.
|
||||
|
||||
customer_data: {
|
||||
legal_name: str,
|
||||
tax_id: str,
|
||||
tax_system: str,
|
||||
email: str,
|
||||
zip: str,
|
||||
country: str (optional, ISO 3166 alpha-3),
|
||||
}
|
||||
"""
|
||||
api_key = get_api_key(tenant_config)
|
||||
tax_id = (customer_data.get("tax_id") or "").upper().strip()
|
||||
if not tax_id:
|
||||
raise FacturapiError("Customer tax_id is required")
|
||||
|
||||
# Try to find existing customer
|
||||
existing_id = None
|
||||
try:
|
||||
result = _request("GET", "/customers", api_key, params={"search": tax_id})
|
||||
for c in result.get("data", []):
|
||||
if (c.get("tax_id") or "").upper() == tax_id:
|
||||
existing_id = c.get("id")
|
||||
break
|
||||
except FacturapiError as e:
|
||||
logger.warning("Failed to search Facturapi customer: %s", e)
|
||||
|
||||
is_foreign = bool(customer_data.get("country")) and customer_data["country"] != "MEX"
|
||||
|
||||
payload = {
|
||||
"legal_name": customer_data.get("legal_name", ""),
|
||||
"email": customer_data.get("email"),
|
||||
"address": {
|
||||
"zip": customer_data.get("zip", "00000"),
|
||||
},
|
||||
}
|
||||
if is_foreign:
|
||||
payload["tax_id"] = tax_id
|
||||
payload["address"]["country"] = customer_data["country"]
|
||||
else:
|
||||
payload["tax_id"] = tax_id
|
||||
if customer_data.get("tax_system"):
|
||||
payload["tax_system"] = customer_data["tax_system"]
|
||||
|
||||
if existing_id:
|
||||
_request("PUT", f"/customers/{existing_id}", api_key, json_payload=payload)
|
||||
return existing_id
|
||||
|
||||
new_customer = _request("POST", "/customers", api_key, json_payload=payload)
|
||||
return new_customer.get("id")
|
||||
|
||||
|
||||
# ─── Invoices ───────────────────────────────────────────────────────────────
|
||||
|
||||
def create_invoice(tenant_config: dict, payload: dict) -> dict:
|
||||
"""Create and stamp an invoice in Facturapi.
|
||||
|
||||
Returns the Facturapi invoice object.
|
||||
"""
|
||||
api_key = get_api_key(tenant_config)
|
||||
return _request("POST", "/invoices", api_key, json_payload=payload, timeout=90)
|
||||
|
||||
|
||||
def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str,
|
||||
replacement_uuid: Optional[str] = None) -> dict:
|
||||
"""Cancel an invoice in Facturapi.
|
||||
|
||||
Motive codes:
|
||||
01: errores con relacion (requires replacement_uuid)
|
||||
02: errores sin relacion
|
||||
03: no se llevo a cabo la operacion
|
||||
04: operacion nominativa relacionada en factura global
|
||||
"""
|
||||
api_key = get_api_key(tenant_config)
|
||||
params = {"motive": motive}
|
||||
if replacement_uuid:
|
||||
params["replacement"] = replacement_uuid
|
||||
return _request("DELETE", f"/invoices/{invoice_id}", api_key, params=params, timeout=60)
|
||||
|
||||
|
||||
def download_xml(tenant_config: dict, invoice_id: str) -> bytes:
|
||||
api_key = get_api_key(tenant_config)
|
||||
return _download("GET", f"/invoices/{invoice_id}/xml", api_key)
|
||||
|
||||
|
||||
def download_pdf(tenant_config: dict, invoice_id: str) -> bytes:
|
||||
api_key = get_api_key(tenant_config)
|
||||
return _download("GET", f"/invoices/{invoice_id}/pdf", api_key)
|
||||
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def is_lco_rejection(message: str) -> bool:
|
||||
"""Detect SAT LCO rejection (CSD not yet propagated)."""
|
||||
if not message:
|
||||
return False
|
||||
msg = message.lower()
|
||||
return any(
|
||||
pattern in msg
|
||||
for pattern in [
|
||||
"lco",
|
||||
"no se encontro el rfc",
|
||||
"rfc no registrado",
|
||||
"lista de contribuyentes obligados",
|
||||
"csd no registrado",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def to_cents(amount) -> int:
|
||||
"""Convert Decimal/float/None to integer cents for Facturapi."""
|
||||
if amount is None:
|
||||
return 0
|
||||
return int(Decimal(str(amount)).quantize(Decimal("0.01")) * 100)
|
||||
56
pos/services/geo_branches.py
Normal file
56
pos/services/geo_branches.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import math
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
"""Calculate the great-circle distance between two points on Earth in km."""
|
||||
R = 6371.0 # Earth radius in km
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
return R * c
|
||||
|
||||
|
||||
def find_nearest_branch(tenant_conn, latitude, longitude):
|
||||
"""
|
||||
Find the nearest active branch with coordinates.
|
||||
Returns a dict with branch info + distance_km, or None.
|
||||
"""
|
||||
if not tenant_conn or latitude is None or longitude is None:
|
||||
return None
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, address, phone, latitude, longitude
|
||||
FROM branches
|
||||
WHERE is_active = TRUE AND latitude IS NOT NULL AND longitude IS NOT NULL
|
||||
"""
|
||||
)
|
||||
branches = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
nearest = None
|
||||
min_dist = float('inf')
|
||||
|
||||
for row in branches:
|
||||
bid, name, address, phone, b_lat, b_lon = row
|
||||
if b_lat is None or b_lon is None:
|
||||
continue
|
||||
dist = haversine(float(latitude), float(longitude), float(b_lat), float(b_lon))
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
nearest = {
|
||||
'id': bid,
|
||||
'name': name,
|
||||
'address': address or '',
|
||||
'phone': phone or '',
|
||||
'latitude': float(b_lat),
|
||||
'longitude': float(b_lon),
|
||||
'distance_km': round(dist, 1),
|
||||
}
|
||||
|
||||
return nearest
|
||||
210
pos/services/global_invoice.py
Normal file
210
pos/services/global_invoice.py
Normal file
@@ -0,0 +1,210 @@
|
||||
# /home/Autopartes/pos/services/global_invoice.py
|
||||
"""Global invoice (Factura Global) service.
|
||||
|
||||
Groups cash sales (PUE, <= $2,000, no individual CFDI) into a single
|
||||
monthly CFDI with InformacionGlobal per SAT requirements.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from services.cfdi_facturapi_builder import build_global_invoice_payload
|
||||
from services.cfdi_queue import enqueue_cfdi, _generate_provisional_folio
|
||||
|
||||
|
||||
def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
|
||||
"""Find sales eligible for global invoicing.
|
||||
|
||||
Criteria:
|
||||
- Payment method: PUE (paid in full)
|
||||
- Total <= max_total
|
||||
- No individual CFDI stamped
|
||||
- Not already included in a global invoice
|
||||
- Created in the given year/month
|
||||
- Optionally filtered by branch_id
|
||||
|
||||
Returns:
|
||||
list of sale dicts with items
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
# Find eligible sale IDs
|
||||
sql = """
|
||||
SELECT s.id
|
||||
FROM sales s
|
||||
WHERE s.metodo_pago_sat = 'PUE'
|
||||
AND s.total <= %s
|
||||
AND s.status = 'completed'
|
||||
AND s.global_invoiced_at IS NULL
|
||||
AND EXTRACT(YEAR FROM s.created_at) = %s
|
||||
AND EXTRACT(MONTH FROM s.created_at) = %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cfdi_queue c
|
||||
WHERE c.sale_id = s.id AND c.status = 'stamped'
|
||||
)
|
||||
"""
|
||||
params = [max_total, year, month]
|
||||
|
||||
if branch_id:
|
||||
sql += " AND s.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
|
||||
sql += " ORDER BY s.created_at ASC"
|
||||
|
||||
cur.execute(sql, params)
|
||||
sale_ids = [r[0] for r in cur.fetchall()]
|
||||
|
||||
if not sale_ids:
|
||||
cur.close()
|
||||
return []
|
||||
|
||||
# Load sale details with items
|
||||
sales = []
|
||||
for sale_id in sale_ids:
|
||||
cur.execute("""
|
||||
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
metodo_pago_sat, forma_pago_sat, status, created_at
|
||||
FROM sales WHERE id = %s
|
||||
""", (sale_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
continue
|
||||
|
||||
sale = {
|
||||
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
|
||||
'employee_id': row[3], 'sale_type': row[4],
|
||||
'payment_method': row[5],
|
||||
'subtotal': float(row[6]) if row[6] else 0,
|
||||
'discount_total': float(row[7]) if row[7] else 0,
|
||||
'tax_total': float(row[8]) if row[8] else 0,
|
||||
'total': float(row[9]) if row[9] else 0,
|
||||
'metodo_pago_sat': row[10] or 'PUE',
|
||||
'forma_pago_sat': row[11] or '01',
|
||||
'status': row[12],
|
||||
'created_at': str(row[13]),
|
||||
'items': [],
|
||||
}
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, inventory_id, part_number, name, quantity, unit_price,
|
||||
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
|
||||
subtotal, clave_prod_serv, clave_unidad
|
||||
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||
""", (sale_id,))
|
||||
|
||||
for r in cur.fetchall():
|
||||
sale['items'].append({
|
||||
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
|
||||
'name': r[3], 'quantity': r[4],
|
||||
'unit_price': float(r[5]) if r[5] else 0,
|
||||
'unit_cost': float(r[6]) if r[6] else 0,
|
||||
'discount_pct': float(r[7]) if r[7] else 0,
|
||||
'discount_amount': float(r[8]) if r[8] else 0,
|
||||
'tax_rate': float(r[9]) if r[9] else 0.16,
|
||||
'tax_amount': float(r[10]) if r[10] else 0,
|
||||
'subtotal': float(r[11]) if r[11] else 0,
|
||||
'clave_prod_serv': r[12] or '25174800',
|
||||
'clave_unidad': r[13] or 'H87',
|
||||
})
|
||||
|
||||
sales.append(sale)
|
||||
|
||||
cur.close()
|
||||
return sales
|
||||
|
||||
|
||||
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
|
||||
max_total=2000, employee_id=None):
|
||||
"""Generate a global invoice for the given month.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection
|
||||
tenant_config: dict with rfc, razon_social, regimen_fiscal, cp, serie
|
||||
year: int
|
||||
month: int
|
||||
branch_id: optional branch filter
|
||||
max_total: max sale total to include (default $2,000)
|
||||
employee_id: optional employee ID for audit
|
||||
|
||||
Returns:
|
||||
dict: {id, status, sales_count, total, xml, provisional_folio}
|
||||
or {error, message} if no eligible sales
|
||||
"""
|
||||
sales = get_eligible_sales(conn, year, month, branch_id, max_total)
|
||||
|
||||
if not sales:
|
||||
return {'error': 'NO_ELIGIBLE_SALES',
|
||||
'message': f'No hay ventas elegibles para factura global de {month:02d}/{year}'}
|
||||
|
||||
payload = build_global_invoice_payload(sales, tenant_config, year, month)
|
||||
|
||||
# Enqueue with sale_id=NULL (global invoice)
|
||||
result = enqueue_cfdi(conn, None, 'ingreso', payload)
|
||||
cfdi_id = result['id']
|
||||
|
||||
cur = conn.cursor()
|
||||
|
||||
# Link sales to global invoice
|
||||
for sale in sales:
|
||||
cur.execute("""
|
||||
INSERT INTO global_invoice_sales (global_invoice_id, sale_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", (cfdi_id, sale['id']))
|
||||
|
||||
# Mark sale as globally invoiced
|
||||
cur.execute("""
|
||||
UPDATE sales SET global_invoiced_at = NOW() WHERE id = %s
|
||||
""", (sale['id'],))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'id': cfdi_id,
|
||||
'status': 'pending',
|
||||
'sales_count': len(sales),
|
||||
'total': sum(s['total'] for s in sales),
|
||||
'provisional_folio': result['provisional_folio'],
|
||||
'payload': payload,
|
||||
}
|
||||
|
||||
|
||||
def get_global_invoice_status(conn, cfdi_id):
|
||||
"""Get status of a global invoice including linked sales."""
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, status, uuid_fiscal, provisional_folio, error_message,
|
||||
created_at, stamped_at
|
||||
FROM cfdi_queue WHERE id = %s
|
||||
""", (cfdi_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
return None
|
||||
|
||||
result = {
|
||||
'id': row[0], 'status': row[1], 'uuid_fiscal': row[2],
|
||||
'provisional_folio': row[3], 'error_message': row[4],
|
||||
'created_at': str(row[5]), 'stamped_at': str(row[6]) if row[6] else None,
|
||||
'sales': [],
|
||||
}
|
||||
|
||||
cur.execute("""
|
||||
SELECT s.id, s.total, s.created_at
|
||||
FROM global_invoice_sales gis
|
||||
JOIN sales s ON s.id = gis.sale_id
|
||||
WHERE gis.global_invoice_id = %s
|
||||
ORDER BY s.created_at ASC
|
||||
""", (cfdi_id,))
|
||||
|
||||
for r in cur.fetchall():
|
||||
result['sales'].append({
|
||||
'id': r[0], 'total': float(r[1]) if r[1] else 0,
|
||||
'created_at': str(r[2]),
|
||||
})
|
||||
|
||||
cur.close()
|
||||
return result
|
||||
@@ -25,22 +25,23 @@ def _safe_g(attr, default=None):
|
||||
def get_stock(conn, inventory_id, branch_id=None):
|
||||
"""Get current stock for an inventory item. Optionally filter by branch.
|
||||
|
||||
Uses Redis cache first, then inventory_stock_summary, falls back to
|
||||
PostgreSQL SUM query.
|
||||
Uses Redis cache first, then inventory_stock (per-branch) or
|
||||
inventory_stock_summary (total), falls back to PostgreSQL SUM query.
|
||||
"""
|
||||
# Try Redis first
|
||||
cached = get_cached_stock(inventory_id, branch_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Use inventory_stock_summary (O(1) lookup)
|
||||
cur = conn.cursor()
|
||||
if branch_id:
|
||||
# Per-branch stock from inventory_stock
|
||||
cur.execute(
|
||||
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s AND branch_id = %s",
|
||||
"SELECT stock FROM inventory_stock WHERE inventory_id = %s AND branch_id = %s",
|
||||
(inventory_id, branch_id)
|
||||
)
|
||||
else:
|
||||
# Total stock from inventory_stock_summary
|
||||
cur.execute(
|
||||
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s",
|
||||
(inventory_id,)
|
||||
@@ -73,13 +74,14 @@ def get_stock(conn, inventory_id, branch_id=None):
|
||||
def get_stock_bulk(conn, branch_id=None):
|
||||
"""Get stock for all items. Returns dict {inventory_id: stock_quantity}.
|
||||
|
||||
Uses inventory_stock_summary for O(1) bulk lookup.
|
||||
Uses inventory_stock (per-branch) or inventory_stock_summary (total)
|
||||
for O(1) bulk lookup.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
if branch_id:
|
||||
cur.execute("""
|
||||
SELECT inventory_id, stock
|
||||
FROM inventory_stock_summary WHERE branch_id = %s
|
||||
FROM inventory_stock WHERE branch_id = %s
|
||||
""", (branch_id,))
|
||||
else:
|
||||
cur.execute("""
|
||||
@@ -96,12 +98,13 @@ def get_stock_bulk(conn, branch_id=None):
|
||||
|
||||
|
||||
def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
|
||||
reference_id=None, reference_type=None, cost_at_time=None, notes=None):
|
||||
reference_id=None, reference_type=None, cost_at_time=None,
|
||||
notes=None, employee_id=None):
|
||||
"""Record a single inventory operation. Does NOT commit — caller controls transaction.
|
||||
|
||||
Args:
|
||||
quantity: positive for entries (PURCHASE, RETURN, INITIAL), negative for exits (SALE)
|
||||
operation_type: SALE, PURCHASE, RETURN, ADJUST, TRANSFER, INITIAL
|
||||
operation_type: SALE, PURCHASE, RETURN, ADJUST, TRANSFER, INITIAL, QUOTE_RESERVE, QUOTE_RELEASE
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
@@ -113,11 +116,23 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
|
||||
""", (
|
||||
inventory_id, branch_id, operation_type, quantity,
|
||||
reference_id, reference_type, cost_at_time,
|
||||
_safe_g('employee_id'),
|
||||
employee_id if employee_id is not None else _safe_g('employee_id'),
|
||||
_safe_g('device_id'),
|
||||
notes
|
||||
))
|
||||
op_id = cur.fetchone()[0]
|
||||
|
||||
# Queue ML stock sync if this product has an active ML listing
|
||||
cur.execute("""
|
||||
INSERT INTO meli_sync_queue (inventory_id, action, status)
|
||||
SELECT %s, 'stock_update', 'pending'
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM marketplace_listings
|
||||
WHERE inventory_id = %s AND channel = 'mercadolibre' AND is_active = true
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", (inventory_id, inventory_id))
|
||||
|
||||
cur.close()
|
||||
return op_id
|
||||
|
||||
@@ -271,38 +286,72 @@ def record_initial(conn, inventory_id, branch_id, quantity, cost=None):
|
||||
return result
|
||||
|
||||
|
||||
def get_alerts(conn, branch_id=None):
|
||||
"""Get stock alerts: zero stock, below minimum, above maximum."""
|
||||
stock_map = get_stock_bulk(conn, branch_id)
|
||||
def get_alerts(conn, branch_id=None, limit_per_type=500):
|
||||
"""Get stock alerts: zero stock, below minimum, above maximum.
|
||||
Returns at most limit_per_type alerts per severity to avoid browser freeze.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
where = "WHERE i.is_active = true"
|
||||
branch_filter = ""
|
||||
params = []
|
||||
if branch_id:
|
||||
where += " AND i.branch_id = %s"
|
||||
branch_filter = " AND i.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
|
||||
# Use a single SQL query with window functions to rank and limit per type
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.name, i.min_stock, i.max_stock, i.branch_id
|
||||
FROM inventory i {where}
|
||||
""", params)
|
||||
WITH stock AS (
|
||||
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS qty
|
||||
FROM inventory_operations
|
||||
GROUP BY inventory_id
|
||||
),
|
||||
alerts_raw AS (
|
||||
SELECT
|
||||
i.id AS inventory_id,
|
||||
i.part_number,
|
||||
i.name,
|
||||
COALESCE(s.qty, 0) AS stock,
|
||||
i.min_stock,
|
||||
i.max_stock,
|
||||
i.branch_id,
|
||||
CASE
|
||||
WHEN COALESCE(s.qty, 0) <= 0 THEN 'zero'
|
||||
WHEN i.min_stock IS NOT NULL AND COALESCE(s.qty, 0) < i.min_stock THEN 'low'
|
||||
WHEN i.max_stock IS NOT NULL AND COALESCE(s.qty, 0) > i.max_stock THEN 'over'
|
||||
END AS alert_type,
|
||||
CASE
|
||||
WHEN COALESCE(s.qty, 0) <= 0 THEN 'critical'
|
||||
WHEN i.min_stock IS NOT NULL AND COALESCE(s.qty, 0) < i.min_stock THEN 'warning'
|
||||
WHEN i.max_stock IS NOT NULL AND COALESCE(s.qty, 0) > i.max_stock THEN 'info'
|
||||
END AS severity
|
||||
FROM inventory i
|
||||
LEFT JOIN stock s ON s.inventory_id = i.id
|
||||
WHERE i.is_active = true {branch_filter}
|
||||
),
|
||||
ranked AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY alert_type ORDER BY inventory_id) AS rn
|
||||
FROM alerts_raw
|
||||
WHERE alert_type IS NOT NULL
|
||||
)
|
||||
SELECT inventory_id, part_number, name, stock, min_stock, max_stock, branch_id, alert_type, severity
|
||||
FROM ranked
|
||||
WHERE rn <= %s
|
||||
ORDER BY severity DESC, inventory_id
|
||||
""", params + [limit_per_type])
|
||||
|
||||
alerts = []
|
||||
for row in cur.fetchall():
|
||||
inv_id, part_num, name, min_s, max_s, br_id = row
|
||||
stock = stock_map.get(inv_id, 0)
|
||||
|
||||
if stock <= 0:
|
||||
alerts.append({'type': 'zero', 'severity': 'critical', 'inventory_id': inv_id,
|
||||
'part_number': part_num, 'name': name, 'stock': stock, 'branch_id': br_id})
|
||||
elif min_s and stock < min_s:
|
||||
alerts.append({'type': 'low', 'severity': 'warning', 'inventory_id': inv_id,
|
||||
'part_number': part_num, 'name': name, 'stock': stock,
|
||||
'min_stock': min_s, 'branch_id': br_id})
|
||||
elif max_s and stock > max_s:
|
||||
alerts.append({'type': 'over', 'severity': 'info', 'inventory_id': inv_id,
|
||||
'part_number': part_num, 'name': name, 'stock': stock,
|
||||
'max_stock': max_s, 'branch_id': br_id})
|
||||
alerts.append({
|
||||
'inventory_id': row[0],
|
||||
'part_number': row[1],
|
||||
'name': row[2],
|
||||
'stock': row[3],
|
||||
'min_stock': row[4],
|
||||
'max_stock': row[5],
|
||||
'branch_id': row[6],
|
||||
'type': row[7],
|
||||
'severity': row[8],
|
||||
})
|
||||
|
||||
cur.close()
|
||||
return alerts
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user