Commit inicial: Sales Bot - Sistema de Automatización de Ventas
- Stack completo con Mattermost, NocoDB y Sales Bot - Procesamiento OCR de tickets con Tesseract - Sistema de comisiones por tubos de tinte - Comandos slash /metas y /ranking - Documentación completa del proyecto Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
57
.gitignore
vendored
Normal file
57
.gitignore
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
# Archivos de entorno con credenciales
|
||||
.env
|
||||
*.env.local
|
||||
*.env.production
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
*/logs/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
|
||||
# Docker
|
||||
*.tar
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Volúmenes de datos (si se guardan localmente)
|
||||
data/
|
||||
*_data/
|
||||
|
||||
# Archivos temporales
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
219
README.md
Normal file
219
README.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Sales Bot - Sistema de Automatización de Ventas
|
||||
|
||||
Sistema integral de automatización de ventas que integra Mattermost, NocoDB y procesamiento OCR para gestionar ventas, comisiones y metas de vendedores.
|
||||
|
||||
## Descripción General
|
||||
|
||||
**Sales Bot** es una solución completa para equipos de ventas que permite:
|
||||
- Registro automático de ventas via chat (Mattermost)
|
||||
- Procesamiento OCR de tickets/facturas
|
||||
- Gestión de vendedores y metas
|
||||
- Cálculo automático de comisiones por tubos de tinte
|
||||
- Reportes y ranking de vendedores
|
||||
|
||||
## Stack Tecnológico
|
||||
|
||||
| Componente | Tecnología |
|
||||
|------------|------------|
|
||||
| Backend | Python 3.12 + Flask + Gunicorn |
|
||||
| Chat | Mattermost Team Edition |
|
||||
| Base de Datos | NocoDB + PostgreSQL |
|
||||
| OCR | Tesseract (inglés/español) |
|
||||
| Visión | OpenCV, Pillow, NumPy |
|
||||
| Infraestructura | Docker Compose |
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
stacks/
|
||||
├── mattermost/ # Stack de Mattermost (chat empresarial)
|
||||
│ └── compose.yaml
|
||||
├── nocodb/ # Stack de NocoDB (base de datos visual)
|
||||
│ └── compose.yaml
|
||||
└── sales-bot/ # Aplicación principal del bot
|
||||
├── app.py # Aplicación Flask principal
|
||||
├── handlers.py # Manejadores de eventos de ventas
|
||||
├── mattermost_client.py # Cliente API de Mattermost
|
||||
├── nocodb_client.py # Cliente API de NocoDB
|
||||
├── ocr_processor.py # Procesador OCR para tickets
|
||||
├── websocket_listener.py # Listener WebSocket
|
||||
├── utils.py # Utilidades
|
||||
├── Dockerfile
|
||||
├── compose.yaml
|
||||
├── requirements.txt
|
||||
└── .env
|
||||
```
|
||||
|
||||
## Servicios
|
||||
|
||||
### Mattermost (Puerto 8065)
|
||||
Plataforma de mensajería empresarial donde los vendedores reportan sus ventas.
|
||||
|
||||
### NocoDB (Puerto 8080)
|
||||
Base de datos visual con interfaz web para gestionar:
|
||||
- Vendedores
|
||||
- Ventas
|
||||
- Detalles de productos
|
||||
- Metas
|
||||
|
||||
### Sales Bot (Puerto 5000)
|
||||
Aplicación principal que orquesta todo el sistema.
|
||||
|
||||
## Instalación
|
||||
|
||||
### Requisitos Previos
|
||||
- Docker y Docker Compose
|
||||
- Acceso a red local (192.168.10.204)
|
||||
|
||||
### Despliegue
|
||||
|
||||
1. **Iniciar Mattermost:**
|
||||
```bash
|
||||
cd mattermost
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. **Iniciar NocoDB:**
|
||||
```bash
|
||||
cd nocodb
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. **Iniciar Sales Bot:**
|
||||
```bash
|
||||
cd sales-bot
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Configuración
|
||||
|
||||
### Variables de Entorno (.env)
|
||||
|
||||
```env
|
||||
# Mattermost
|
||||
MATTERMOST_URL=http://192.168.10.204:8065
|
||||
MATTERMOST_BOT_TOKEN=<tu_token>
|
||||
MATTERMOST_TEAM_NAME=sales
|
||||
MATTERMOST_WEBHOOK_SECRET=<tu_secret>
|
||||
MATTERMOST_WEBHOOK_URL=http://192.168.10.204:8065/hooks/<hook_id>
|
||||
|
||||
# NocoDB
|
||||
NOCODB_URL=http://192.168.10.204:8080
|
||||
NOCODB_TOKEN=<tu_token>
|
||||
NOCODB_TABLE_VENDEDORES=<table_id>
|
||||
NOCODB_TABLE_VENTAS=<table_id>
|
||||
NOCODB_TABLE_VENTAS_DETALLE=<table_id>
|
||||
NOCODB_TABLE_METAS=<table_id>
|
||||
|
||||
# Flask
|
||||
FLASK_PORT=5000
|
||||
FLASK_DEBUG=False
|
||||
LOG_LEVEL=INFO
|
||||
TZ_OFFSET=-6
|
||||
```
|
||||
|
||||
## Uso
|
||||
|
||||
### Registrar una Venta
|
||||
|
||||
En Mattermost, envía un mensaje con el formato:
|
||||
```
|
||||
venta @monto 1500 @cliente Juan
|
||||
```
|
||||
|
||||
Opcionalmente adjunta una foto del ticket para procesamiento OCR automático.
|
||||
|
||||
### Comandos Slash
|
||||
|
||||
| Comando | Descripción |
|
||||
|---------|-------------|
|
||||
| `/metas` | Muestra progreso de meta del vendedor |
|
||||
| `/ranking` | Muestra ranking de vendedores |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Método | Descripción |
|
||||
|----------|--------|-------------|
|
||||
| `/health` | GET | Health check del servicio |
|
||||
| `/webhook/mattermost` | POST | Webhook para mensajes de Mattermost |
|
||||
| `/webhook/nocodb` | POST | Webhook para eventos de NocoDB |
|
||||
| `/comando/metas` | POST | Comando slash /metas |
|
||||
| `/comando/ranking` | POST | Comando slash /ranking |
|
||||
|
||||
## Sistema de Comisiones
|
||||
|
||||
- **Meta diaria:** 3 tubos de tinte
|
||||
- **Comisión:** $10 MXN por cada tubo después del tercero
|
||||
|
||||
### Ejemplo:
|
||||
- Vendedor vende 5 tubos → Comisión: (5-3) × $10 = $20
|
||||
|
||||
## Flujo de Operación
|
||||
|
||||
```
|
||||
Vendedor envía mensaje + foto
|
||||
↓
|
||||
Webhook → Sales Bot
|
||||
↓
|
||||
Extracción de datos (monto, cliente)
|
||||
↓
|
||||
OCR de imagen (productos, fecha, monto)
|
||||
↓
|
||||
Validación (tolerancia ±5%)
|
||||
↓
|
||||
Registro en NocoDB
|
||||
↓
|
||||
Cálculo de comisiones
|
||||
↓
|
||||
Respuesta en Mattermost con confirmación
|
||||
```
|
||||
|
||||
## Tablas en NocoDB
|
||||
|
||||
| Tabla | Descripción |
|
||||
|-------|-------------|
|
||||
| Vendedores | Información de vendedores, metas |
|
||||
| Ventas | Registro de ventas (monto, cliente, fecha) |
|
||||
| Ventas Detalle | Productos por venta (marca, cantidad, precio) |
|
||||
| Metas | Metas mensuales y diarias |
|
||||
|
||||
## Monitoreo
|
||||
|
||||
### Logs
|
||||
Los logs se almacenan en `sales-bot/logs/sales-bot.log`
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
curl http://localhost:5000/health
|
||||
```
|
||||
|
||||
## Desarrollo
|
||||
|
||||
### Requisitos
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Ejecución Local
|
||||
```bash
|
||||
cd sales-bot
|
||||
python app.py
|
||||
```
|
||||
|
||||
## Características
|
||||
|
||||
- OCR robusto con múltiples configuraciones PSM
|
||||
- Validación de montos con tolerancia del 5%
|
||||
- Detección automática de tubos de tinte
|
||||
- Respuestas con emojis motivacionales
|
||||
- Health checks en todos los servicios
|
||||
- Contenedor con usuario no-root
|
||||
- Persistencia de datos en volúmenes Docker
|
||||
|
||||
## Licencia
|
||||
|
||||
Proyecto privado - Consultoría AS
|
||||
|
||||
## Contacto
|
||||
|
||||
Consultoría AS - https://consultoria-as.com
|
||||
329
docs/API.md
Normal file
329
docs/API.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Documentación de API
|
||||
|
||||
## Sales Bot API
|
||||
|
||||
### Base URL
|
||||
```
|
||||
http://192.168.10.204:5000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Health Check
|
||||
|
||||
Verifica el estado del servicio.
|
||||
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
|
||||
**Respuesta exitosa (200):**
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2024-01-15T10:30:45.123456",
|
||||
"version": "1.0.0",
|
||||
"services": {
|
||||
"mattermost": "connected",
|
||||
"nocodb": "connected"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Webhook Mattermost
|
||||
|
||||
Recibe webhooks salientes de Mattermost cuando se envía un mensaje de venta.
|
||||
|
||||
```http
|
||||
POST /webhook/mattermost
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
```
|
||||
|
||||
**Parámetros:**
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| token | string | Token de verificación del webhook |
|
||||
| team_id | string | ID del team |
|
||||
| team_domain | string | Dominio del team |
|
||||
| channel_id | string | ID del canal |
|
||||
| channel_name | string | Nombre del canal |
|
||||
| timestamp | number | Timestamp del mensaje |
|
||||
| user_id | string | ID del usuario |
|
||||
| user_name | string | Nombre de usuario |
|
||||
| post_id | string | ID del post |
|
||||
| text | string | Texto del mensaje |
|
||||
| file_ids | string | IDs de archivos adjuntos (separados por coma) |
|
||||
|
||||
**Respuesta exitosa (200):**
|
||||
```json
|
||||
{
|
||||
"response_type": "comment",
|
||||
"text": "✅ Venta registrada correctamente\n\n**Monto:** $1,500.00\n**Cliente:** Juan Pérez\n**Vendedor:** @vendedor1\n\n📊 **Estadísticas del día:**\n- Tubos vendidos: 5\n- Meta diaria: 3\n- Comisión: $20.00"
|
||||
}
|
||||
```
|
||||
|
||||
**Error (400):**
|
||||
```json
|
||||
{
|
||||
"error": "Token inválido"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Webhook NocoDB
|
||||
|
||||
Recibe webhooks de NocoDB cuando hay cambios en las tablas.
|
||||
|
||||
```http
|
||||
POST /webhook/nocodb
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"type": "records.after.insert",
|
||||
"data": {
|
||||
"table_name": "Ventas",
|
||||
"rows": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Comando /metas
|
||||
|
||||
Muestra el progreso de metas del vendedor.
|
||||
|
||||
```http
|
||||
POST /comando/metas
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
```
|
||||
|
||||
**Parámetros:**
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| user_name | string | Nombre de usuario que ejecuta el comando |
|
||||
| channel_id | string | ID del canal |
|
||||
|
||||
**Respuesta:**
|
||||
```json
|
||||
{
|
||||
"response_type": "ephemeral",
|
||||
"text": "📊 **Tus metas - Enero 2024**\n\n**Tubos vendidos hoy:** 5/3 ✅\n**Comisión del día:** $20.00\n\n**Mes actual:**\n- Total tubos: 45\n- Comisión acumulada: $150.00\n- Total vendido: $15,000.00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Comando /ranking
|
||||
|
||||
Muestra el ranking de vendedores.
|
||||
|
||||
```http
|
||||
POST /comando/ranking
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
```
|
||||
|
||||
**Parámetros:**
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| channel_id | string | ID del canal |
|
||||
|
||||
**Respuesta:**
|
||||
```json
|
||||
{
|
||||
"response_type": "in_channel",
|
||||
"text": "🏆 **Ranking de Vendedores - Enero 2024**\n\n1. 🥇 @vendedor1 - $25,000.00 (75 tubos)\n2. 🥈 @vendedor2 - $20,000.00 (60 tubos)\n3. 🥉 @vendedor3 - $15,000.00 (45 tubos)"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NocoDB API
|
||||
|
||||
### Base URL
|
||||
```
|
||||
http://192.168.10.204:8080/api/v2
|
||||
```
|
||||
|
||||
### Autenticación
|
||||
```http
|
||||
xc-token: <API_TOKEN>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Listar Vendedores
|
||||
|
||||
```http
|
||||
GET /tables/{TABLE_ID}/records
|
||||
xc-token: <TOKEN>
|
||||
```
|
||||
|
||||
**Respuesta:**
|
||||
```json
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"Id": 1,
|
||||
"username": "vendedor1",
|
||||
"nombre_completo": "Juan Pérez",
|
||||
"email": "juan@ejemplo.com",
|
||||
"meta_diaria_tubos": 3,
|
||||
"activo": true,
|
||||
"fecha_registro": "2024-01-01T00:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"totalRows": 1,
|
||||
"page": 1,
|
||||
"pageSize": 25
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Crear Venta
|
||||
|
||||
```http
|
||||
POST /tables/{TABLE_ID}/records
|
||||
xc-token: <TOKEN>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"vendedor_username": "vendedor1",
|
||||
"monto": 1500.00,
|
||||
"cliente": "Juan Pérez",
|
||||
"fecha_venta": "2024-01-15T10:30:00.000Z",
|
||||
"estado": "completada",
|
||||
"canal": "ventas-general",
|
||||
"mensaje_id": "abc123",
|
||||
"descripcion": "Venta de tintes"
|
||||
}
|
||||
```
|
||||
|
||||
**Respuesta:**
|
||||
```json
|
||||
{
|
||||
"Id": 1,
|
||||
"vendedor_username": "vendedor1",
|
||||
"monto": 1500.00,
|
||||
"cliente": "Juan Pérez",
|
||||
"fecha_venta": "2024-01-15T10:30:00.000Z",
|
||||
"estado": "completada",
|
||||
"canal": "ventas-general",
|
||||
"mensaje_id": "abc123",
|
||||
"descripcion": "Venta de tintes"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Filtrar Ventas por Fecha
|
||||
|
||||
```http
|
||||
GET /tables/{TABLE_ID}/records?where=(fecha_venta,gte,2024-01-15)~and(fecha_venta,lt,2024-01-16)
|
||||
xc-token: <TOKEN>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Obtener Ranking
|
||||
|
||||
```http
|
||||
GET /tables/{TABLE_ID}/records?sort=-monto&limit=10
|
||||
xc-token: <TOKEN>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mattermost API
|
||||
|
||||
### Base URL
|
||||
```
|
||||
http://192.168.10.204:8065/api/v4
|
||||
```
|
||||
|
||||
### Autenticación
|
||||
```http
|
||||
Authorization: Bearer <BOT_TOKEN>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Enviar Mensaje
|
||||
|
||||
```http
|
||||
POST /posts
|
||||
Authorization: Bearer <TOKEN>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"channel_id": "abc123",
|
||||
"message": "Mensaje de texto"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Agregar Reacción
|
||||
|
||||
```http
|
||||
POST /reactions
|
||||
Authorization: Bearer <TOKEN>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"post_id": "post123",
|
||||
"emoji_name": "white_check_mark"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Obtener Archivo
|
||||
|
||||
```http
|
||||
GET /files/{file_id}
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Códigos de Error
|
||||
|
||||
| Código | Descripción |
|
||||
|--------|-------------|
|
||||
| 200 | Éxito |
|
||||
| 400 | Solicitud inválida |
|
||||
| 401 | No autorizado |
|
||||
| 403 | Prohibido |
|
||||
| 404 | No encontrado |
|
||||
| 500 | Error interno del servidor |
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
- Sales Bot: Sin límite interno
|
||||
- Mattermost: 10 requests/segundo por usuario
|
||||
- NocoDB: 100 requests/minuto por token
|
||||
208
docs/ARQUITECTURA.md
Normal file
208
docs/ARQUITECTURA.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Arquitectura del Sistema
|
||||
|
||||
## Diagrama General
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ INFRAESTRUCTURA │
|
||||
│ (Docker Compose) │
|
||||
└─────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────────────────────────┼──────────────────────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ MATTERMOST │ │ SALES BOT │ │ NOCODB │
|
||||
│ (Puerto 8065) │◄────────────────►│ (Puerto 5000) │◄────────────────►│ (Puerto 8080) │
|
||||
├─────────────────────┤ ├─────────────────────┤ ├─────────────────────┤
|
||||
│ - Chat empresarial │ │ - Flask/Gunicorn │ │ - UI visual │
|
||||
│ - Webhooks │ │ - OCR Tesseract │ │ - API REST │
|
||||
│ - Comandos slash │ │ - WebSocket client │ │ - Relaciones │
|
||||
│ - Archivos/imágenes │ │ - Handlers │ │ - Webhooks │
|
||||
├─────────────────────┤ ├─────────────────────┤ ├─────────────────────┤
|
||||
│ PostgreSQL │ │ Python 3.12 │ │ PostgreSQL │
|
||||
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
## Flujo de Datos
|
||||
|
||||
```
|
||||
┌──────────┐ ┌────────────┐ ┌───────────┐ ┌─────────┐ ┌────────────┐
|
||||
│ VENDEDOR │────►│ MATTERMOST │────►│ SALES BOT │────►│ NOCODB │────►│ RESPUESTA │
|
||||
└──────────┘ └────────────┘ └───────────┘ └─────────┘ └────────────┘
|
||||
│ │ │ │ │
|
||||
│ │ │ │ │
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
Mensaje Webhook POST Procesamiento Registro DB Mensaje +
|
||||
+ Imagen + Payload OCR + Datos Reacción
|
||||
```
|
||||
|
||||
## Redes Docker
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ HOST (192.168.10.204) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ mattermost-network │ │ sales-bot-network │ │ nocodb-network │ │
|
||||
│ │ (bridge) │ │ (bridge) │ │ (bridge) │ │
|
||||
│ ├─────────────────────┤ ├─────────────────────┤ ├─────────────────────┤ │
|
||||
│ │ mattermost:8065 ◄┼──┼► sales-bot:5000 ◄┼──┼► nocodb:8080 │ │
|
||||
│ │ postgres:5432 │ │ │ │ postgres:5432 │ │
|
||||
│ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Componentes del Sales Bot
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SALES BOT │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ app.py │ │ handlers.py │ │ ocr_processor │ │
|
||||
│ │ ─────────────── │ │ ─────────────── │ │ ─────────────── │ │
|
||||
│ │ Flask App │────►│ handle_venta │────►│ Tesseract OCR │ │
|
||||
│ │ Endpoints │ │ Lógica negocio │ │ OpenCV │ │
|
||||
│ │ Inicialización │ │ Comisiones │ │ Preprocesamiento│ │
|
||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ websocket_ │ │ mattermost_ │ │ nocodb_ │ │
|
||||
│ │ listener.py │ │ client.py │ │ client.py │ │
|
||||
│ │ ─────────────── │ │ ─────────────── │ │ ─────────────── │ │
|
||||
│ │ Eventos tiempo │ │ API Mattermost │ │ API NocoDB │ │
|
||||
│ │ real │ │ Mensajes │ │ CRUD tablas │ │
|
||||
│ │ Thread separado │ │ Archivos │ │ Comisiones │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ utils.py │ │
|
||||
│ │ ─────────────────────────────────────────────────────────────── │ │
|
||||
│ │ extraer_monto() │ extraer_cliente() │ extraer_tubos() │ etc. │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Proceso de Venta
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ PROCESO DE REGISTRO DE VENTA │
|
||||
├────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. RECEPCIÓN │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ Vendedor envía │ │
|
||||
│ │ mensaje + foto │ │
|
||||
│ └────────┬────────┘ │
|
||||
│ │ │
|
||||
│ 2. WEBHOOK │
|
||||
│ ┌────────▼────────┐ │
|
||||
│ │ Mattermost │ │
|
||||
│ │ Outgoing Hook │ │
|
||||
│ └────────┬────────┘ │
|
||||
│ │ │
|
||||
│ 3. EXTRACCIÓN │
|
||||
│ ┌────────▼────────┐ ┌─────────────────┐ │
|
||||
│ │ Parseo de texto │────►│ @monto 1500 │ │
|
||||
│ │ │ │ @cliente Juan │ │
|
||||
│ │ │ │ @tubos 5 │ │
|
||||
│ └────────┬────────┘ └─────────────────┘ │
|
||||
│ │ │
|
||||
│ 4. OCR (si hay imagen) │
|
||||
│ ┌────────▼────────┐ ┌─────────────────┐ │
|
||||
│ │ Descargar img │────►│ Preprocesamiento│ │
|
||||
│ │ de Mattermost │ │ OCR Tesseract │ │
|
||||
│ └────────┬────────┘ │ Detección tubos │ │
|
||||
│ │ └─────────────────┘ │
|
||||
│ │ │
|
||||
│ 5. VALIDACIÓN │
|
||||
│ ┌────────▼────────┐ │
|
||||
│ │ Monto OCR vs │ │
|
||||
│ │ Monto mensaje │ │
|
||||
│ │ (tolerancia 5%) │ │
|
||||
│ └────────┬────────┘ │
|
||||
│ │ │
|
||||
│ 6. REGISTRO │
|
||||
│ ┌────────▼────────┐ ┌─────────────────┐ │
|
||||
│ │ NocoDB API │────►│ Vendedor │ │
|
||||
│ │ │ │ Venta │ │
|
||||
│ │ │ │ Detalle │ │
|
||||
│ │ │ │ Meta │ │
|
||||
│ └────────┬────────┘ └─────────────────┘ │
|
||||
│ │ │
|
||||
│ 7. COMISIONES │
|
||||
│ ┌────────▼────────┐ ┌─────────────────┐ │
|
||||
│ │ Calcular │────►│ tubos > 3? │ │
|
||||
│ │ comisión │ │ comisión = $10 │ │
|
||||
│ │ │ │ × (tubos - 3) │ │
|
||||
│ └────────┬────────┘ └─────────────────┘ │
|
||||
│ │ │
|
||||
│ 8. RESPUESTA │
|
||||
│ ┌────────▼────────┐ │
|
||||
│ │ Mensaje en │ │
|
||||
│ │ Mattermost + │ │
|
||||
│ │ Reacción ✓ │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Seguridad
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CAPA DE SEGURIDAD │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Mattermost Auth │ │ NocoDB Auth │ │
|
||||
│ │ ───────────────── │ │ ───────────────── │ │
|
||||
│ │ Bot Token │ │ API Token (JWT) │ │
|
||||
│ │ Webhook Secret │ │ Bearer Auth │ │
|
||||
│ └─────────────────────┘ └─────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Contenedor Sales Bot │ │
|
||||
│ │ ───────────────────────────────────────────────── │ │
|
||||
│ │ Usuario: salesbot (no-root) │ │
|
||||
│ │ Filesystem: read-only donde es posible │ │
|
||||
│ │ Red: bridge aislada │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Escalabilidad
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Load Balancer │
|
||||
│ (futuro) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
│ │ │
|
||||
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
│ Sales Bot │ │ Sales Bot │ │ Sales Bot │
|
||||
│ Worker 1 │ │ Worker 2 │ │ Worker 3 │
|
||||
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
└───────────────────┼───────────────────┘
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ NocoDB │
|
||||
│ (PostgreSQL) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
El sistema está preparado para escalar horizontalmente gracias a:
|
||||
- Gunicorn con múltiples workers
|
||||
- Base de datos centralizada
|
||||
- Contenedores stateless
|
||||
296
docs/INSTALACION.md
Normal file
296
docs/INSTALACION.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# Guía de Instalación
|
||||
|
||||
## Requisitos del Sistema
|
||||
|
||||
### Hardware Mínimo
|
||||
- CPU: 2 cores
|
||||
- RAM: 4 GB
|
||||
- Disco: 20 GB
|
||||
|
||||
### Software
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- Git
|
||||
|
||||
### Red
|
||||
- Puerto 5000 (Sales Bot)
|
||||
- Puerto 8065 (Mattermost)
|
||||
- Puerto 8080 (NocoDB)
|
||||
|
||||
---
|
||||
|
||||
## Instalación Paso a Paso
|
||||
|
||||
### 1. Clonar el Repositorio
|
||||
|
||||
```bash
|
||||
git clone https://git.consultoria-as.com/<usuario>/stacks.git
|
||||
cd stacks
|
||||
```
|
||||
|
||||
### 2. Configurar Variables de Entorno
|
||||
|
||||
Copiar el archivo de ejemplo y editar:
|
||||
|
||||
```bash
|
||||
cd sales-bot
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Configurar las siguientes variables:
|
||||
|
||||
```env
|
||||
# Mattermost
|
||||
MATTERMOST_URL=http://<IP>:8065
|
||||
MATTERMOST_BOT_TOKEN=<tu_token>
|
||||
MATTERMOST_TEAM_NAME=sales
|
||||
MATTERMOST_WEBHOOK_SECRET=<tu_secret>
|
||||
MATTERMOST_WEBHOOK_URL=http://<IP>:8065/hooks/<hook_id>
|
||||
|
||||
# NocoDB
|
||||
NOCODB_URL=http://<IP>:8080
|
||||
NOCODB_TOKEN=<tu_token>
|
||||
NOCODB_TABLE_VENDEDORES=<table_id>
|
||||
NOCODB_TABLE_VENTAS=<table_id>
|
||||
NOCODB_TABLE_VENTAS_DETALLE=<table_id>
|
||||
NOCODB_TABLE_METAS=<table_id>
|
||||
|
||||
# Flask
|
||||
FLASK_PORT=5000
|
||||
LOG_LEVEL=INFO
|
||||
TZ_OFFSET=-6
|
||||
```
|
||||
|
||||
### 3. Iniciar Mattermost
|
||||
|
||||
```bash
|
||||
cd ../mattermost
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Esperar a que inicie completamente:
|
||||
```bash
|
||||
docker compose logs -f
|
||||
# Esperar mensaje "Server is listening"
|
||||
```
|
||||
|
||||
Acceder a http://<IP>:8065 y completar la configuración inicial:
|
||||
1. Crear cuenta de administrador
|
||||
2. Crear team "sales"
|
||||
3. Crear canales necesarios
|
||||
|
||||
### 4. Configurar Bot en Mattermost
|
||||
|
||||
1. Ir a **Integraciones > Bot Accounts**
|
||||
2. Crear nuevo bot:
|
||||
- Username: `salesbot`
|
||||
- Copiar el token generado
|
||||
|
||||
3. Ir a **Integraciones > Outgoing Webhooks**
|
||||
4. Crear webhook:
|
||||
- Canal: canal de ventas
|
||||
- URL: `http://<IP>:5000/webhook/mattermost`
|
||||
- Copiar el token
|
||||
|
||||
5. Ir a **Integraciones > Slash Commands**
|
||||
6. Crear comandos:
|
||||
- `/metas` → `http://<IP>:5000/comando/metas`
|
||||
- `/ranking` → `http://<IP>:5000/comando/ranking`
|
||||
|
||||
### 5. Iniciar NocoDB
|
||||
|
||||
```bash
|
||||
cd ../nocodb
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Acceder a http://<IP>:8080 y:
|
||||
1. Crear cuenta
|
||||
2. Crear base de datos
|
||||
3. Crear tablas (ver estructura en docs/ARQUITECTURA.md)
|
||||
4. Obtener API token en Settings
|
||||
|
||||
### 6. Configurar Tablas en NocoDB
|
||||
|
||||
Crear las siguientes tablas:
|
||||
|
||||
**Vendedores:**
|
||||
```
|
||||
- Id (Auto)
|
||||
- username (Text, Required)
|
||||
- nombre_completo (Text)
|
||||
- email (Email)
|
||||
- meta_diaria_tubos (Number, Default: 3)
|
||||
- activo (Checkbox, Default: true)
|
||||
- fecha_registro (DateTime)
|
||||
```
|
||||
|
||||
**Ventas:**
|
||||
```
|
||||
- Id (Auto)
|
||||
- vendedor_username (Text, Required)
|
||||
- monto (Currency)
|
||||
- cliente (Text)
|
||||
- fecha_venta (DateTime)
|
||||
- estado (SingleSelect: pendiente, completada, cancelada)
|
||||
- canal (Text)
|
||||
- mensaje_id (Text)
|
||||
- imagen_ticket (Attachment)
|
||||
- descripcion (LongText)
|
||||
```
|
||||
|
||||
**Ventas Detalle:**
|
||||
```
|
||||
- Id (Auto)
|
||||
- venta_id (Number)
|
||||
- producto (Text)
|
||||
- marca (Text)
|
||||
- cantidad (Number)
|
||||
- precio_unitario (Currency)
|
||||
- importe (Currency)
|
||||
```
|
||||
|
||||
**Metas:**
|
||||
```
|
||||
- Id (Auto)
|
||||
- vendedor_username (Text)
|
||||
- mes (Text)
|
||||
- tubos_vendidos (Number)
|
||||
- comision (Currency)
|
||||
- meta_diaria (Number)
|
||||
- porcentaje_completado (Percent)
|
||||
- total_vendido (Currency)
|
||||
```
|
||||
|
||||
### 7. Obtener IDs de Tablas
|
||||
|
||||
En NocoDB, para cada tabla:
|
||||
1. Abrir la tabla
|
||||
2. Copiar el ID de la URL: `/table/<TABLE_ID>`
|
||||
|
||||
Actualizar estos IDs en el archivo `.env`.
|
||||
|
||||
### 8. Iniciar Sales Bot
|
||||
|
||||
```bash
|
||||
cd ../sales-bot
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Verificar que esté corriendo:
|
||||
```bash
|
||||
curl http://localhost:5000/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verificación de la Instalación
|
||||
|
||||
### 1. Verificar Servicios
|
||||
|
||||
```bash
|
||||
# Mattermost
|
||||
curl http://localhost:8065/api/v4/system/ping
|
||||
|
||||
# NocoDB
|
||||
curl http://localhost:8080/api/v2/health
|
||||
|
||||
# Sales Bot
|
||||
curl http://localhost:5000/health
|
||||
```
|
||||
|
||||
### 2. Probar Flujo Completo
|
||||
|
||||
1. Enviar mensaje en Mattermost:
|
||||
```
|
||||
venta @monto 100 @cliente Prueba
|
||||
```
|
||||
|
||||
2. Verificar que el bot responda
|
||||
3. Verificar registro en NocoDB
|
||||
|
||||
---
|
||||
|
||||
## Solución de Problemas
|
||||
|
||||
### Sales Bot no responde
|
||||
|
||||
```bash
|
||||
# Ver logs
|
||||
docker compose -f sales-bot/compose.yaml logs -f
|
||||
|
||||
# Reiniciar
|
||||
docker compose -f sales-bot/compose.yaml restart
|
||||
```
|
||||
|
||||
### Error de conexión a Mattermost
|
||||
|
||||
Verificar:
|
||||
1. Token del bot es correcto
|
||||
2. Bot tiene permisos en el canal
|
||||
3. URL de Mattermost es accesible
|
||||
|
||||
### Error de conexión a NocoDB
|
||||
|
||||
Verificar:
|
||||
1. Token de API es correcto
|
||||
2. IDs de tablas son correctos
|
||||
3. URL de NocoDB es accesible
|
||||
|
||||
### OCR no funciona
|
||||
|
||||
Verificar:
|
||||
1. Tesseract está instalado en el contenedor
|
||||
2. Imagen es legible
|
||||
3. Ver logs para errores específicos
|
||||
|
||||
---
|
||||
|
||||
## Actualización
|
||||
|
||||
```bash
|
||||
# Detener servicios
|
||||
docker compose -f sales-bot/compose.yaml down
|
||||
docker compose -f mattermost/compose.yaml down
|
||||
docker compose -f nocodb/compose.yaml down
|
||||
|
||||
# Actualizar código
|
||||
git pull
|
||||
|
||||
# Reconstruir imágenes
|
||||
docker compose -f sales-bot/compose.yaml build
|
||||
|
||||
# Iniciar servicios
|
||||
docker compose -f mattermost/compose.yaml up -d
|
||||
docker compose -f nocodb/compose.yaml up -d
|
||||
docker compose -f sales-bot/compose.yaml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup y Restauración
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
# Backup de Mattermost
|
||||
docker compose -f mattermost/compose.yaml exec postgres \
|
||||
pg_dump -U consultoria-as mattermost > mattermost_backup.sql
|
||||
|
||||
# Backup de NocoDB
|
||||
docker compose -f nocodb/compose.yaml exec postgres \
|
||||
pg_dump -U consultoria-as nocodb > nocodb_backup.sql
|
||||
```
|
||||
|
||||
### Restauración
|
||||
|
||||
```bash
|
||||
# Restaurar Mattermost
|
||||
cat mattermost_backup.sql | docker compose -f mattermost/compose.yaml exec -T postgres \
|
||||
psql -U consultoria-as mattermost
|
||||
|
||||
# Restaurar NocoDB
|
||||
cat nocodb_backup.sql | docker compose -f nocodb/compose.yaml exec -T postgres \
|
||||
psql -U consultoria-as nocodb
|
||||
```
|
||||
81
mattermost/README.md
Normal file
81
mattermost/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Mattermost - Plataforma de Mensajería
|
||||
|
||||
Stack de Mattermost Team Edition para comunicación del equipo de ventas.
|
||||
|
||||
## Componentes
|
||||
|
||||
- **Mattermost Server** - Servidor de mensajería
|
||||
- **PostgreSQL** - Base de datos
|
||||
|
||||
## Puertos
|
||||
|
||||
| Servicio | Puerto |
|
||||
|----------|--------|
|
||||
| Mattermost | 8065 |
|
||||
| PostgreSQL | 5432 (interno) |
|
||||
|
||||
## Despliegue
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Configuración
|
||||
|
||||
El stack está configurado con:
|
||||
- Usuario: `consultoria-as`
|
||||
- Zona horaria: América/México_City
|
||||
- Modo: Team Edition (gratuito)
|
||||
|
||||
## Webhooks Configurados
|
||||
|
||||
### Webhook Saliente (Outgoing)
|
||||
Envía mensajes del canal de ventas al Sales Bot:
|
||||
- URL destino: `http://192.168.10.204:5000/webhook/mattermost`
|
||||
- Token de verificación configurado en `.env`
|
||||
|
||||
### Comandos Slash
|
||||
|
||||
| Comando | URL | Descripción |
|
||||
|---------|-----|-------------|
|
||||
| /metas | http://192.168.10.204:5000/comando/metas | Muestra metas del vendedor |
|
||||
| /ranking | http://192.168.10.204:5000/comando/ranking | Ranking de vendedores |
|
||||
|
||||
## Volúmenes
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- mattermost_data:/mattermost/data
|
||||
- mattermost_logs:/mattermost/logs
|
||||
- mattermost_config:/mattermost/config
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
```
|
||||
|
||||
## Acceso
|
||||
|
||||
- **URL:** http://192.168.10.204:8065
|
||||
- **Team:** sales
|
||||
|
||||
## Integración con Sales Bot
|
||||
|
||||
1. El vendedor envía un mensaje con foto del ticket
|
||||
2. Mattermost envía webhook al Sales Bot
|
||||
3. Sales Bot procesa y responde en el mismo canal
|
||||
4. Sales Bot agrega reacción al mensaje original
|
||||
|
||||
## Mantenimiento
|
||||
|
||||
### Ver logs
|
||||
```bash
|
||||
docker compose logs -f mattermost
|
||||
```
|
||||
|
||||
### Reiniciar servicios
|
||||
```bash
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### Backup de datos
|
||||
```bash
|
||||
docker compose exec postgres pg_dump -U consultoria-as mattermost > backup.sql
|
||||
```
|
||||
53
mattermost/compose.yaml
Normal file
53
mattermost/compose.yaml
Normal file
@@ -0,0 +1,53 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: mattermost-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: consultoria-as
|
||||
POSTGRES_PASSWORD: Aasi940812
|
||||
POSTGRES_DB: mattermost
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- mattermost-network
|
||||
|
||||
mattermost:
|
||||
image: mattermost/mattermost-team-edition:latest
|
||||
container_name: mattermost
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
TZ: America/Mexico_City
|
||||
MM_SQLSETTINGS_DRIVERNAME: postgres
|
||||
MM_SQLSETTINGS_DATASOURCE: postgres://consultoria-as:Aasi940812@postgres:5432/mattermost?sslmode=disable&connect_timeout=10
|
||||
MM_BLEVESETTINGS_INDEXDIR: /mattermost/bleve-indexes
|
||||
MM_SERVICESETTINGS_SITEURL: http://192.168.10.204:8065
|
||||
MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS: "192.168.10.0/24 172.16.0.0/12"
|
||||
volumes:
|
||||
- mattermost_config:/mattermost/config
|
||||
- mattermost_data:/mattermost/data
|
||||
- mattermost_logs:/mattermost/logs
|
||||
- mattermost_plugins:/mattermost/plugins
|
||||
- mattermost_client_plugins:/mattermost/client/plugins
|
||||
- mattermost_bleve:/mattermost/bleve-indexes
|
||||
ports:
|
||||
- "8065:8065"
|
||||
networks:
|
||||
- mattermost-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
mattermost_config:
|
||||
mattermost_data:
|
||||
mattermost_logs:
|
||||
mattermost_plugins:
|
||||
mattermost_client_plugins:
|
||||
mattermost_bleve:
|
||||
|
||||
networks:
|
||||
mattermost-network:
|
||||
driver: bridge
|
||||
130
nocodb/README.md
Normal file
130
nocodb/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# NocoDB - Base de Datos Visual
|
||||
|
||||
Stack de NocoDB para gestión de datos de ventas.
|
||||
|
||||
## Componentes
|
||||
|
||||
- **NocoDB** - Interfaz visual de base de datos
|
||||
- **PostgreSQL** - Base de datos
|
||||
|
||||
## Puertos
|
||||
|
||||
| Servicio | Puerto |
|
||||
|----------|--------|
|
||||
| NocoDB | 8080 |
|
||||
| PostgreSQL | 5432 (interno) |
|
||||
|
||||
## Despliegue
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Acceso
|
||||
|
||||
- **URL:** http://192.168.10.204:8080
|
||||
- **Autenticación:** JWT Token
|
||||
|
||||
## Tablas
|
||||
|
||||
### Vendedores
|
||||
Información de los vendedores del equipo.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| Id | Auto | ID único |
|
||||
| username | Text | Usuario de Mattermost |
|
||||
| nombre_completo | Text | Nombre completo |
|
||||
| email | Email | Correo electrónico |
|
||||
| meta_diaria_tubos | Number | Meta diaria (default: 3) |
|
||||
| activo | Checkbox | Estado activo |
|
||||
| fecha_registro | DateTime | Fecha de registro |
|
||||
|
||||
### Ventas
|
||||
Registro de todas las ventas.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| Id | Auto | ID único |
|
||||
| vendedor_username | Text | Usuario del vendedor |
|
||||
| monto | Currency | Monto de la venta |
|
||||
| cliente | Text | Nombre del cliente |
|
||||
| fecha_venta | DateTime | Fecha y hora |
|
||||
| estado | SingleSelect | pendiente/completada/cancelada |
|
||||
| canal | Text | Canal de Mattermost |
|
||||
| mensaje_id | Text | ID del mensaje |
|
||||
| imagen_ticket | Attachment | Imagen del ticket |
|
||||
| descripcion | LongText | Notas adicionales |
|
||||
|
||||
### Ventas Detalle
|
||||
Productos de cada venta.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| Id | Auto | ID único |
|
||||
| venta_id | Number | ID de la venta |
|
||||
| producto | Text | Nombre del producto |
|
||||
| marca | Text | Marca del producto |
|
||||
| cantidad | Number | Cantidad |
|
||||
| precio_unitario | Currency | Precio por unidad |
|
||||
| importe | Currency | Total del producto |
|
||||
|
||||
### Metas
|
||||
Seguimiento de metas por vendedor.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| Id | Auto | ID único |
|
||||
| vendedor_username | Text | Usuario del vendedor |
|
||||
| mes | Text | Mes (YYYY-MM) |
|
||||
| tubos_vendidos | Number | Total de tubos vendidos |
|
||||
| comision | Currency | Comisión acumulada |
|
||||
| meta_diaria | Number | Meta diaria de tubos |
|
||||
| porcentaje_completado | Percent | % de meta cumplida |
|
||||
| total_vendido | Currency | Total vendido en el mes |
|
||||
|
||||
## API
|
||||
|
||||
### Autenticación
|
||||
```bash
|
||||
curl -H "xc-token: <TOKEN>" http://192.168.10.204:8080/api/v2/...
|
||||
```
|
||||
|
||||
### Listar registros
|
||||
```bash
|
||||
curl -H "xc-token: <TOKEN>" \
|
||||
"http://192.168.10.204:8080/api/v2/tables/<TABLE_ID>/records"
|
||||
```
|
||||
|
||||
### Crear registro
|
||||
```bash
|
||||
curl -X POST -H "xc-token: <TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"campo": "valor"}' \
|
||||
"http://192.168.10.204:8080/api/v2/tables/<TABLE_ID>/records"
|
||||
```
|
||||
|
||||
## Volúmenes
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- nocodb_data:/usr/app/data
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
```
|
||||
|
||||
## Mantenimiento
|
||||
|
||||
### Ver logs
|
||||
```bash
|
||||
docker compose logs -f nocodb
|
||||
```
|
||||
|
||||
### Backup de datos
|
||||
```bash
|
||||
docker compose exec postgres pg_dump -U consultoria-as nocodb > backup.sql
|
||||
```
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
curl http://192.168.10.204:8080/api/v2/health
|
||||
```
|
||||
47
nocodb/compose.yaml
Normal file
47
nocodb/compose.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: nocodb-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: consultoria-as
|
||||
POSTGRES_PASSWORD: Aasi940812
|
||||
POSTGRES_DB: nocodb
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- nocodb-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U consultoria-as -d nocodb"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
nocodb:
|
||||
image: nocodb/nocodb:latest
|
||||
container_name: nocodb
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NC_DB: pg://postgres:5432?u=consultoria-as&p=Aasi940812&d=nocodb
|
||||
NC_AUTH_JWT_SECRET: tu-secreto-jwt-seguro-cambiar-esto
|
||||
NC_ADMIN_EMAIL: ialcarazsalazar@consultoria-as.com
|
||||
NC_ADMIN_PASSWORD: Aasi940812
|
||||
volumes:
|
||||
- nocodb_data:/usr/app/data
|
||||
ports:
|
||||
- "8080:8080"
|
||||
networks:
|
||||
- nocodb-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
nocodb_data:
|
||||
|
||||
networks:
|
||||
nocodb-network:
|
||||
driver: bridge
|
||||
47
sales-bot/.env.example
Normal file
47
sales-bot/.env.example
Normal file
@@ -0,0 +1,47 @@
|
||||
# ============================================================================
|
||||
# SALES BOT - VARIABLES DE ENTORNO (EJEMPLO)
|
||||
# ============================================================================
|
||||
#
|
||||
# Copiar este archivo a .env y configurar los valores
|
||||
#
|
||||
|
||||
# === MATTERMOST ===
|
||||
# URL de tu instancia de Mattermost
|
||||
MATTERMOST_URL=http://localhost:8065
|
||||
|
||||
# Token del bot salesbot (obtener en Integraciones > Bot Accounts)
|
||||
MATTERMOST_BOT_TOKEN=tu_token_aqui
|
||||
|
||||
# Nombre del equipo en Mattermost
|
||||
MATTERMOST_TEAM_NAME=sales
|
||||
|
||||
# Secret del webhook (obtener en Integraciones > Outgoing Webhooks)
|
||||
MATTERMOST_WEBHOOK_SECRET=tu_secret_aqui
|
||||
|
||||
# Incoming webhook para responder en el canal
|
||||
MATTERMOST_WEBHOOK_URL=http://localhost:8065/hooks/tu_hook_id
|
||||
|
||||
# === NOCODB ===
|
||||
# URL de tu instancia de NocoDB
|
||||
NOCODB_URL=http://localhost:8080
|
||||
|
||||
# Token de API de NocoDB (obtener en Settings > API Tokens)
|
||||
NOCODB_TOKEN=tu_token_aqui
|
||||
|
||||
# IDs de tablas en NocoDB (obtener de la URL al abrir cada tabla)
|
||||
NOCODB_TABLE_VENDEDORES=tu_table_id
|
||||
NOCODB_TABLE_VENTAS=tu_table_id
|
||||
NOCODB_TABLE_VENTAS_DETALLE=tu_table_id
|
||||
NOCODB_TABLE_METAS=tu_table_id
|
||||
|
||||
# === FLASK ===
|
||||
FLASK_PORT=5000
|
||||
FLASK_DEBUG=False
|
||||
|
||||
# === LOGGING ===
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=/app/logs/sales-bot.log
|
||||
|
||||
# === ZONA HORARIA ===
|
||||
# México: -6, Cancún: -5, España: +1
|
||||
TZ_OFFSET=-6
|
||||
69
sales-bot/Dockerfile
Normal file
69
sales-bot/Dockerfile
Normal file
@@ -0,0 +1,69 @@
|
||||
# ============================================================================
|
||||
# SALES BOT - Dockerfile
|
||||
# ============================================================================
|
||||
#
|
||||
# Imagen base con Python 3.12 y Tesseract OCR
|
||||
#
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Metadata
|
||||
LABEL maintainer="sales-bot-team"
|
||||
LABEL description="Sales Bot - Sistema de ventas con Mattermost y OCR"
|
||||
LABEL version="1.0.0"
|
||||
|
||||
# Variables de entorno
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Instalar dependencias del sistema
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Tesseract OCR
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-eng \
|
||||
tesseract-ocr-spa \
|
||||
libtesseract-dev \
|
||||
# Dependencias de procesamiento de imágenes
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
libxrender-dev \
|
||||
libgomp1 \
|
||||
libglib2.0-0 \
|
||||
libgl1 \
|
||||
# Utilidades
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Crear usuario no-root
|
||||
RUN useradd -m -u 1000 -s /bin/bash salesbot
|
||||
|
||||
# Establecer directorio de trabajo
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar requirements primero (para aprovechar cache de Docker)
|
||||
COPY requirements.txt .
|
||||
|
||||
# Instalar dependencias Python
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copiar código de la aplicación
|
||||
COPY --chown=salesbot:salesbot . .
|
||||
|
||||
# Crear directorio de logs
|
||||
RUN mkdir -p /app/logs && chown -R salesbot:salesbot /app/logs
|
||||
|
||||
# Cambiar a usuario no-root
|
||||
USER salesbot
|
||||
|
||||
# Exponer puerto
|
||||
EXPOSE 5000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:5000/health || exit 1
|
||||
|
||||
# Comando de inicio
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "--timeout", "60", "--access-logfile", "-", "--error-logfile", "-", "app:app"]
|
||||
199
sales-bot/README.md
Normal file
199
sales-bot/README.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Sales Bot - Aplicación Principal
|
||||
|
||||
Bot de automatización de ventas para Mattermost con procesamiento OCR de tickets.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Sales Bot │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ Flask │ │ WebSocket │ │ OCR Processor │ │
|
||||
│ │ (app.py) │ │ Listener │ │ │ │
|
||||
│ └──────┬──────┘ └──────┬───────┘ └────────┬─────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────────────┼──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────┴───────┐ │
|
||||
│ │ Handlers │ │
|
||||
│ │ (handlers.py) │ │
|
||||
│ └───────┬───────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────┼──────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ┌──────┴──────┐ ┌─────┴──────┐ ┌─────┴──────┐ │
|
||||
│ │ Mattermost │ │ NocoDB │ │ Utils │ │
|
||||
│ │ Client │ │ Client │ │ │ │
|
||||
│ └─────────────┘ └────────────┘ └────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Módulos
|
||||
|
||||
### app.py
|
||||
Aplicación Flask principal con los siguientes endpoints:
|
||||
|
||||
- `GET /health` - Health check
|
||||
- `POST /webhook/mattermost` - Recibe webhooks de Mattermost
|
||||
- `POST /webhook/nocodb` - Recibe webhooks de NocoDB
|
||||
- `POST /comando/metas` - Comando slash /metas
|
||||
- `POST /comando/ranking` - Comando slash /ranking
|
||||
|
||||
### handlers.py
|
||||
Manejadores de eventos de ventas:
|
||||
|
||||
- `handle_venta_message()` - Procesa mensajes de venta
|
||||
- `generar_reporte_diario()` - Genera reportes diarios
|
||||
|
||||
### mattermost_client.py
|
||||
Cliente para la API de Mattermost:
|
||||
|
||||
```python
|
||||
client = MattermostClient(url, token)
|
||||
client.test_connection()
|
||||
client.post_message(channel_id, message)
|
||||
client.add_reaction(post_id, emoji)
|
||||
client.get_file(file_id)
|
||||
```
|
||||
|
||||
### nocodb_client.py
|
||||
Cliente para la API de NocoDB:
|
||||
|
||||
```python
|
||||
client = NocoDBClient(url, token)
|
||||
client.crear_vendedor(username, nombre, email)
|
||||
client.registrar_venta(vendedor, monto, cliente, imagen)
|
||||
client.get_ventas_dia(vendedor, fecha)
|
||||
client.get_ranking_vendedores(mes)
|
||||
```
|
||||
|
||||
### ocr_processor.py
|
||||
Procesador OCR para tickets:
|
||||
|
||||
```python
|
||||
processor = OCRProcessor()
|
||||
resultado = processor.procesar_imagen(imagen_bytes)
|
||||
# Retorna: monto, fecha, productos, tubos_detectados
|
||||
```
|
||||
|
||||
### websocket_listener.py
|
||||
Listener para eventos en tiempo real de Mattermost:
|
||||
|
||||
```python
|
||||
listener = MattermostWebsocketListener(url, token, callback)
|
||||
listener.start() # Inicia en thread separado
|
||||
```
|
||||
|
||||
### utils.py
|
||||
Funciones de utilidad:
|
||||
|
||||
```python
|
||||
extraer_monto(texto) # "@monto 1500" → 1500.0
|
||||
extraer_cliente(texto) # "@cliente Juan" → "Juan"
|
||||
extraer_tubos(texto) # "@tubos 5" → 5
|
||||
formatear_moneda(1500) # → "$1,500.00"
|
||||
```
|
||||
|
||||
## Instalación con Docker
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Instalación Manual
|
||||
|
||||
```bash
|
||||
# Instalar Tesseract
|
||||
apt-get install tesseract-ocr tesseract-ocr-eng tesseract-ocr-spa
|
||||
|
||||
# Instalar dependencias Python
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Ejecutar
|
||||
python app.py
|
||||
```
|
||||
|
||||
## Variables de Entorno
|
||||
|
||||
| Variable | Descripción | Ejemplo |
|
||||
|----------|-------------|---------|
|
||||
| MATTERMOST_URL | URL de Mattermost | http://192.168.10.204:8065 |
|
||||
| MATTERMOST_BOT_TOKEN | Token del bot | xxx |
|
||||
| MATTERMOST_TEAM_NAME | Nombre del team | sales |
|
||||
| MATTERMOST_WEBHOOK_SECRET | Secret del webhook | xxx |
|
||||
| NOCODB_URL | URL de NocoDB | http://192.168.10.204:8080 |
|
||||
| NOCODB_TOKEN | Token de API | xxx |
|
||||
| NOCODB_TABLE_* | IDs de tablas | xxx |
|
||||
| FLASK_PORT | Puerto de Flask | 5000 |
|
||||
| LOG_LEVEL | Nivel de logging | INFO |
|
||||
| TZ_OFFSET | Offset de zona horaria | -6 |
|
||||
|
||||
## Formato de Mensajes de Venta
|
||||
|
||||
El bot reconoce varios formatos:
|
||||
|
||||
```
|
||||
# Formato con @
|
||||
venta @monto 1500 @cliente Juan @tubos 5
|
||||
|
||||
# Formato con etiquetas
|
||||
venta monto: 1500 cliente: Juan
|
||||
|
||||
# Formato natural
|
||||
venta $1,500 a Juan
|
||||
```
|
||||
|
||||
## Procesamiento OCR
|
||||
|
||||
El procesador OCR detecta automáticamente:
|
||||
- Monto total
|
||||
- Fecha del ticket
|
||||
- Lista de productos
|
||||
- Cantidad de tubos de tinte
|
||||
|
||||
### Marcas de Tinte Reconocidas
|
||||
- Alfaparf Evolution
|
||||
- Wella Koleston
|
||||
- Loreal
|
||||
- Matrix
|
||||
- Schwarzkopf
|
||||
- Revlon
|
||||
- Igora
|
||||
- Majirel
|
||||
|
||||
## Sistema de Comisiones
|
||||
|
||||
```python
|
||||
META_DIARIA_TUBOS = 3
|
||||
COMISION_POR_TUBO = 10 # $10 MXN
|
||||
|
||||
def calcular_comision(tubos_vendidos):
|
||||
if tubos_vendidos > META_DIARIA_TUBOS:
|
||||
return (tubos_vendidos - META_DIARIA_TUBOS) * COMISION_POR_TUBO
|
||||
return 0
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
Los logs se escriben en `/app/logs/sales-bot.log` con formato:
|
||||
```
|
||||
2024-01-15 10:30:45 INFO [app] Venta registrada: $1,500.00 - Juan - vendedor1
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:5000/health
|
||||
```
|
||||
|
||||
Respuesta:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2024-01-15T10:30:45",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
724
sales-bot/app.py
Normal file
724
sales-bot/app.py
Normal file
@@ -0,0 +1,724 @@
|
||||
from flask import Flask, request, jsonify, render_template_string
|
||||
import os
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
# Cargar variables de entorno
|
||||
load_dotenv()
|
||||
|
||||
# Importar módulos personalizados
|
||||
from mattermost_client import MattermostClient
|
||||
from nocodb_client import NocoDBClient
|
||||
from handlers import handle_venta_message, generar_reporte_diario
|
||||
from utils import validar_token_outgoing
|
||||
from websocket_listener import MattermostWebsocketListener
|
||||
|
||||
# Configurar logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Inicializar Flask
|
||||
app = Flask(__name__)
|
||||
|
||||
# Inicializar clientes
|
||||
mattermost = MattermostClient(
|
||||
url=os.getenv('MATTERMOST_URL'),
|
||||
token=os.getenv('MATTERMOST_BOT_TOKEN')
|
||||
)
|
||||
|
||||
nocodb = NocoDBClient(
|
||||
url=os.getenv('NOCODB_URL'),
|
||||
token=os.getenv('NOCODB_TOKEN')
|
||||
)
|
||||
|
||||
# Inicializar websocket listener
|
||||
ws_listener = MattermostWebsocketListener(mattermost, nocodb, handle_venta_message)
|
||||
ws_listener.start()
|
||||
logger.info("Websocket listener iniciado para escuchar mensajes de Mattermost")
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Endpoint para verificar que el bot está funcionando"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'version': '1.0.0'
|
||||
}), 200
|
||||
|
||||
@app.route('/webhook/mattermost', methods=['POST'])
|
||||
def mattermost_webhook():
|
||||
"""
|
||||
Recibe webhooks salientes de Mattermost cuando hay mensajes en el canal de ventas
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
logger.info(f"Webhook recibido: {json.dumps(data, indent=2)}")
|
||||
|
||||
# Validar token
|
||||
token = data.get('token')
|
||||
if not validar_token_outgoing(token):
|
||||
logger.warning(f"Token inválido: {token}")
|
||||
return jsonify({'error': 'Token inválido'}), 403
|
||||
|
||||
# Ignorar mensajes del propio bot
|
||||
if data.get('user_name') == 'sales-bot':
|
||||
return jsonify({'status': 'ignored', 'reason': 'bot message'}), 200
|
||||
|
||||
# Obtener información del mensaje
|
||||
channel_name = data.get('channel_name')
|
||||
user_name = data.get('user_name')
|
||||
text = data.get('text', '').strip()
|
||||
|
||||
logger.info(f"Procesando mensaje de {user_name} en #{channel_name}: {text}")
|
||||
|
||||
# Verificar si es un mensaje de venta
|
||||
palabras_clave = ['venta', 'vendi', 'vendí', 'cliente', 'ticket']
|
||||
es_venta = any(palabra in text.lower() for palabra in palabras_clave)
|
||||
|
||||
if es_venta or data.get('file_ids'):
|
||||
respuesta = handle_venta_message(data, mattermost, nocodb)
|
||||
return jsonify(respuesta), 200
|
||||
|
||||
return jsonify({'status': 'ok', 'message': 'Mensaje procesado'}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando webhook: {str(e)}", exc_info=True)
|
||||
return jsonify({'error': 'Error interno del servidor'}), 500
|
||||
|
||||
@app.route('/webhook/nocodb', methods=['POST'])
|
||||
def nocodb_webhook():
|
||||
"""
|
||||
Recibe webhooks de NocoDB cuando se insertan/actualizan datos
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
logger.info(f"Webhook NocoDB recibido: {json.dumps(data, indent=2)}")
|
||||
|
||||
# Aquí puedes agregar lógica para notificar en Mattermost
|
||||
# cuando se actualicen datos en NocoDB
|
||||
|
||||
return jsonify({'status': 'ok'}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando webhook NocoDB: {str(e)}", exc_info=True)
|
||||
return jsonify({'error': 'Error interno del servidor'}), 500
|
||||
|
||||
@app.route('/comando/metas', methods=['POST'])
|
||||
def comando_metas():
|
||||
"""
|
||||
Endpoint para el comando slash /metas en Mattermost
|
||||
"""
|
||||
try:
|
||||
data = request.form.to_dict()
|
||||
logger.info(f"Comando /metas recibido de {data.get('user_name')}")
|
||||
|
||||
# Validar token
|
||||
token = data.get('token')
|
||||
expected_tokens = [
|
||||
os.getenv('MATTERMOST_SLASH_TOKEN_METAS'),
|
||||
os.getenv('MATTERMOST_OUTGOING_TOKEN')
|
||||
]
|
||||
if token not in expected_tokens:
|
||||
return jsonify({'text': 'Token inválido'}), 403
|
||||
|
||||
user_name = data.get('user_name')
|
||||
|
||||
# Obtener meta del vendedor
|
||||
meta = nocodb.get_meta_vendedor(user_name)
|
||||
|
||||
if not meta:
|
||||
mensaje = (
|
||||
f"@{user_name} Aún no tienes ventas registradas este mes.\n"
|
||||
"¡Empieza a vender y registra tus ventas!"
|
||||
)
|
||||
else:
|
||||
porcentaje = meta.get('porcentaje_completado', 0)
|
||||
total_vendido = meta.get('total_vendido', 0)
|
||||
meta_establecida = meta.get('meta_establecida', 0)
|
||||
ventas_count = meta.get('ventas_realizadas', 0)
|
||||
falta = meta_establecida - total_vendido
|
||||
|
||||
# Barra de progreso visual
|
||||
barra_length = 20
|
||||
completado = int((porcentaje / 100) * barra_length)
|
||||
barra = '█' * completado + '░' * (barra_length - completado)
|
||||
|
||||
mensaje = (
|
||||
f"📊 **Reporte de {user_name}**\n\n"
|
||||
f"`{barra}` {porcentaje:.1f}%\n\n"
|
||||
f"**Total vendido:** ${total_vendido:,.2f} MXN\n"
|
||||
f"**Meta mensual:** ${meta_establecida:,.2f} MXN\n"
|
||||
f"**Falta:** ${falta:,.2f} MXN\n"
|
||||
f"**Ventas realizadas:** {ventas_count}\n"
|
||||
)
|
||||
|
||||
if porcentaje >= 100:
|
||||
mensaje += "\n🎉 **¡Felicidades! Meta completada**"
|
||||
elif porcentaje >= 75:
|
||||
mensaje += f"\n🔥 **¡Casi llegas! Solo faltan ${falta:,.2f}**"
|
||||
|
||||
return jsonify({
|
||||
'response_type': 'ephemeral',
|
||||
'text': mensaje
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando comando /metas: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'text': f'❌ Error procesando comando: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@app.route('/comando/ranking', methods=['POST'])
|
||||
def comando_ranking():
|
||||
"""
|
||||
Endpoint para el comando slash /ranking en Mattermost
|
||||
"""
|
||||
try:
|
||||
data = request.form.to_dict()
|
||||
logger.info(f"Comando /ranking recibido de {data.get('user_name')}")
|
||||
|
||||
# Validar token
|
||||
token = data.get('token')
|
||||
expected_tokens = [
|
||||
os.getenv('MATTERMOST_SLASH_TOKEN_RANKING'),
|
||||
os.getenv('MATTERMOST_OUTGOING_TOKEN')
|
||||
]
|
||||
if token not in expected_tokens:
|
||||
return jsonify({'text': 'Token inválido'}), 403
|
||||
|
||||
# Obtener ranking
|
||||
ranking = nocodb.get_ranking_vendedores()
|
||||
|
||||
if not ranking:
|
||||
mensaje = "No hay datos de ventas este mes."
|
||||
else:
|
||||
mensaje = "🏆 **Ranking de Vendedores - Mes Actual**\n\n"
|
||||
|
||||
for i, vendedor in enumerate(ranking[:10], 1):
|
||||
username = vendedor.get('vendedor_username')
|
||||
total = vendedor.get('total_vendido', 0)
|
||||
porcentaje = vendedor.get('porcentaje_completado', 0)
|
||||
ventas = vendedor.get('ventas_realizadas', 0)
|
||||
|
||||
# Medallas para top 3
|
||||
if i == 1:
|
||||
emoji = '🥇'
|
||||
elif i == 2:
|
||||
emoji = '🥈'
|
||||
elif i == 3:
|
||||
emoji = '🥉'
|
||||
else:
|
||||
emoji = f'{i}.'
|
||||
|
||||
mensaje += (
|
||||
f"{emoji} **@{username}**\n"
|
||||
f" └ ${total:,.2f} MXN ({porcentaje:.1f}%) - {ventas} ventas\n"
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'response_type': 'in_channel',
|
||||
'text': mensaje
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando comando /ranking: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'text': f'❌ Error procesando comando: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@app.route('/comando/ayuda', methods=['POST'])
|
||||
def comando_ayuda():
|
||||
"""
|
||||
Endpoint para el comando slash /ayuda en Mattermost
|
||||
"""
|
||||
try:
|
||||
data = request.form.to_dict()
|
||||
logger.info(f"Comando /ayuda recibido de {data.get('user_name')}")
|
||||
|
||||
# Validar token
|
||||
token = data.get('token')
|
||||
expected_tokens = [
|
||||
os.getenv('MATTERMOST_SLASH_TOKEN_AYUDA'),
|
||||
os.getenv('MATTERMOST_OUTGOING_TOKEN')
|
||||
]
|
||||
if token not in expected_tokens:
|
||||
return jsonify({'text': 'Token inválido'}), 403
|
||||
|
||||
mensaje = (
|
||||
"🤖 **Bot de Ventas - Guía de Uso**\n\n"
|
||||
"**Para registrar una venta:**\n"
|
||||
"• `venta @monto 1500 @cliente Juan Pérez`\n"
|
||||
"• `vendí $1500 a María García`\n"
|
||||
"• También puedes adjuntar foto del ticket\n\n"
|
||||
"**Comandos disponibles:**\n"
|
||||
"• `/metas` - Ver tu progreso del mes\n"
|
||||
"• `/ranking` - Ver ranking de vendedores\n"
|
||||
"• `/ayuda` - Mostrar esta ayuda\n\n"
|
||||
"**Ejemplos de registro de ventas:**\n"
|
||||
"✅ `venta @monto 2500 @cliente Empresa ABC`\n"
|
||||
"✅ `vendí $1,200.50 a cliente Pedro`\n"
|
||||
"✅ `venta @monto 5000 @cliente Tienda XYZ`\n\n"
|
||||
"**Consejos:**\n"
|
||||
"• Registra tus ventas inmediatamente después de cerrarlas\n"
|
||||
"• Incluye el nombre del cliente para mejor seguimiento\n"
|
||||
"• Revisa tu progreso regularmente con `/metas`\n"
|
||||
"• Compite sanamente con tus compañeros en el `/ranking`\n\n"
|
||||
"¡Sigue adelante! 💪"
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'response_type': 'ephemeral',
|
||||
'text': mensaje
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando comando /ayuda: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'text': f'❌ Error procesando comando: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@app.route('/reporte/diario', methods=['POST'])
|
||||
def reporte_diario_manual():
|
||||
"""
|
||||
Endpoint para generar reporte diario manualmente
|
||||
"""
|
||||
try:
|
||||
resultado = generar_reporte_diario(mattermost, nocodb)
|
||||
return jsonify(resultado), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error generando reporte: {str(e)}", exc_info=True)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/test/mattermost', methods=['GET'])
|
||||
def test_mattermost():
|
||||
"""Test de conexión con Mattermost"""
|
||||
try:
|
||||
resultado = mattermost.test_connection()
|
||||
return jsonify(resultado), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/test/nocodb', methods=['GET'])
|
||||
def test_nocodb():
|
||||
"""Test de conexión con NocoDB"""
|
||||
try:
|
||||
resultado = nocodb.test_connection()
|
||||
return jsonify(resultado), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# ============== DASHBOARD ==============
|
||||
|
||||
@app.route('/api/dashboard/resumen', methods=['GET'])
|
||||
def api_dashboard_resumen():
|
||||
"""API: Resumen general del día y mes"""
|
||||
try:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
TZ_MEXICO = timezone(timedelta(hours=-6))
|
||||
|
||||
hoy = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
|
||||
mes = datetime.now(TZ_MEXICO).strftime('%Y-%m')
|
||||
|
||||
# Ventas del día
|
||||
ventas_hoy = nocodb.get_ventas_dia()
|
||||
total_hoy = sum(float(v.get('monto', 0)) for v in ventas_hoy)
|
||||
|
||||
# Ventas del mes
|
||||
ventas_mes = nocodb.get_ventas_mes()
|
||||
total_mes = sum(float(v.get('monto', 0)) for v in ventas_mes)
|
||||
|
||||
# Contar vendedores activos hoy
|
||||
vendedores_hoy = set(v.get('vendedor_username') for v in ventas_hoy)
|
||||
|
||||
return jsonify({
|
||||
'fecha': hoy,
|
||||
'mes': mes,
|
||||
'ventas_hoy': len(ventas_hoy),
|
||||
'monto_hoy': total_hoy,
|
||||
'ventas_mes': len(ventas_mes),
|
||||
'monto_mes': total_mes,
|
||||
'vendedores_activos_hoy': len(vendedores_hoy)
|
||||
}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error en API resumen: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/dashboard/ranking', methods=['GET'])
|
||||
def api_dashboard_ranking():
|
||||
"""API: Ranking de vendedores del mes"""
|
||||
try:
|
||||
ranking = nocodb.get_ranking_vendedores()
|
||||
return jsonify(ranking), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error en API ranking: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/dashboard/ventas-recientes', methods=['GET'])
|
||||
def api_dashboard_ventas_recientes():
|
||||
"""API: Últimas ventas registradas con nombres de vendedores"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
ventas = nocodb.get_ventas_dia()
|
||||
|
||||
# Obtener lista de vendedores para mapear usernames a nombres
|
||||
vendedores_response = requests.get(
|
||||
f"{nocodb.url}/api/v2/tables/{nocodb.table_vendedores}/records",
|
||||
headers=nocodb.headers,
|
||||
params={'limit': 100},
|
||||
timeout=10
|
||||
)
|
||||
vendedores_response.raise_for_status()
|
||||
vendedores = vendedores_response.json().get('list', [])
|
||||
|
||||
# Crear mapa de username -> nombre_completo
|
||||
nombres_map = {v.get('username'): v.get('nombre_completo', v.get('username')) for v in vendedores}
|
||||
|
||||
# Agregar nombre_completo a cada venta
|
||||
for venta in ventas:
|
||||
username = venta.get('vendedor_username', '')
|
||||
venta['nombre_completo'] = nombres_map.get(username, username)
|
||||
|
||||
# Ordenar por fecha descendente y tomar las últimas 20
|
||||
ventas_ordenadas = sorted(ventas, key=lambda x: x.get('fecha_venta', ''), reverse=True)[:20]
|
||||
return jsonify(ventas_ordenadas), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error en API ventas recientes: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/dashboard/metas', methods=['GET'])
|
||||
def api_dashboard_metas():
|
||||
"""API: Estado de metas de todos los vendedores"""
|
||||
try:
|
||||
import requests
|
||||
response = requests.get(
|
||||
f"{nocodb.url}/api/v2/tables/{nocodb.table_metas}/records",
|
||||
headers=nocodb.headers,
|
||||
params={'limit': 100},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
metas = response.json().get('list', [])
|
||||
return jsonify(metas), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error en API metas: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/dashboard')
|
||||
def dashboard():
|
||||
"""Dashboard principal de ventas"""
|
||||
html = '''
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sales Bot - Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
color: #fff;
|
||||
}
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
h1 { font-size: 28px; font-weight: 600; }
|
||||
h1 span { color: #00d4ff; }
|
||||
.fecha { color: #888; font-size: 14px; }
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.stat-card {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 40px rgba(0,212,255,0.1);
|
||||
}
|
||||
.stat-card .label { color: #888; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||||
.stat-card .value { font-size: 32px; font-weight: 700; color: #00d4ff; }
|
||||
.stat-card .subvalue { font-size: 14px; color: #666; margin-top: 4px; }
|
||||
.stat-card.green .value { color: #00ff88; }
|
||||
.stat-card.orange .value { color: #ffaa00; }
|
||||
.stat-card.purple .value { color: #aa00ff; }
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
@media (max-width: 900px) { .main-grid { grid-template-columns: 1fr; } }
|
||||
.panel {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.panel h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.panel h2 .icon { font-size: 24px; }
|
||||
.ranking-list { list-style: none; }
|
||||
.ranking-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.ranking-item:last-child { border-bottom: none; }
|
||||
.ranking-position {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.ranking-position.gold { background: linear-gradient(135deg, #ffd700, #ffaa00); color: #000; }
|
||||
.ranking-position.silver { background: linear-gradient(135deg, #c0c0c0, #888); color: #000; }
|
||||
.ranking-position.bronze { background: linear-gradient(135deg, #cd7f32, #8b4513); color: #fff; }
|
||||
.ranking-position.default { background: rgba(255,255,255,0.1); color: #888; }
|
||||
.ranking-info { flex: 1; }
|
||||
.ranking-name { font-weight: 600; margin-bottom: 2px; }
|
||||
.ranking-stats { font-size: 12px; color: #888; }
|
||||
.ranking-value { text-align: right; }
|
||||
.ranking-tubos { font-size: 24px; font-weight: 700; color: #00d4ff; }
|
||||
.ranking-comision { font-size: 12px; color: #00ff88; }
|
||||
.ventas-list { max-height: 400px; overflow-y: auto; }
|
||||
.venta-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.venta-info .vendedor { font-weight: 600; color: #00d4ff; }
|
||||
.venta-info .cliente { font-size: 12px; color: #888; }
|
||||
.venta-monto { font-size: 18px; font-weight: 700; color: #00ff88; }
|
||||
.refresh-btn {
|
||||
background: rgba(0,212,255,0.2);
|
||||
border: 1px solid #00d4ff;
|
||||
color: #00d4ff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.refresh-btn:hover { background: rgba(0,212,255,0.3); }
|
||||
.loading { text-align: center; padding: 40px; color: #888; }
|
||||
.meta-progress {
|
||||
height: 8px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.meta-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00d4ff, #00ff88);
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div>
|
||||
<h1><span>Sales</span> Bot Dashboard</h1>
|
||||
<p class="fecha" id="fecha-actual"></p>
|
||||
</div>
|
||||
<button class="refresh-btn" onclick="cargarDatos()">🔄 Actualizar</button>
|
||||
</header>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Ventas Hoy</div>
|
||||
<div class="value" id="ventas-hoy">-</div>
|
||||
<div class="subvalue" id="monto-hoy">$0.00</div>
|
||||
</div>
|
||||
<div class="stat-card green">
|
||||
<div class="label">Ventas del Mes</div>
|
||||
<div class="value" id="ventas-mes">-</div>
|
||||
<div class="subvalue" id="monto-mes">$0.00</div>
|
||||
</div>
|
||||
<div class="stat-card orange">
|
||||
<div class="label">Vendedores Activos Hoy</div>
|
||||
<div class="value" id="vendedores-activos">-</div>
|
||||
</div>
|
||||
<div class="stat-card purple">
|
||||
<div class="label">Meta Diaria</div>
|
||||
<div class="value">3</div>
|
||||
<div class="subvalue">tubos por vendedor</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-grid">
|
||||
<div class="panel">
|
||||
<h2><span class="icon">🏆</span> Ranking del Mes (Tubos)</h2>
|
||||
<ul class="ranking-list" id="ranking-list">
|
||||
<li class="loading">Cargando...</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2><span class="icon">📋</span> Ventas Recientes</h2>
|
||||
<div class="ventas-list" id="ventas-list">
|
||||
<div class="loading">Cargando...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function formatMoney(amount) {
|
||||
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
async function cargarResumen() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/resumen');
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('ventas-hoy').textContent = data.ventas_hoy || 0;
|
||||
document.getElementById('monto-hoy').textContent = formatMoney(data.monto_hoy || 0);
|
||||
document.getElementById('ventas-mes').textContent = data.ventas_mes || 0;
|
||||
document.getElementById('monto-mes').textContent = formatMoney(data.monto_mes || 0);
|
||||
document.getElementById('vendedores-activos').textContent = data.vendedores_activos_hoy || 0;
|
||||
document.getElementById('fecha-actual').textContent = new Date().toLocaleDateString('es-MX', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error cargando resumen:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function cargarRanking() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/ranking');
|
||||
const data = await res.json();
|
||||
const lista = document.getElementById('ranking-list');
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
lista.innerHTML = '<li class="loading">No hay datos de ventas</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
lista.innerHTML = data.slice(0, 10).map((v, i) => {
|
||||
const posClass = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : 'default';
|
||||
const tubos = v.tubos_totales || 0;
|
||||
const comision = v.comision_total || 0;
|
||||
const ventas = v.cantidad_ventas || 0;
|
||||
|
||||
const nombre = v.nombre_completo || v.vendedor_username || v.vendedor;
|
||||
const username = v.vendedor_username || v.vendedor;
|
||||
|
||||
return `
|
||||
<li class="ranking-item">
|
||||
<div class="ranking-position ${posClass}">${i + 1}</div>
|
||||
<div class="ranking-info">
|
||||
<div class="ranking-name">${nombre}</div>
|
||||
<div class="ranking-stats">@${username} • ${ventas} ventas • ${v.dias_activos || 0} días activos</div>
|
||||
</div>
|
||||
<div class="ranking-value">
|
||||
<div class="ranking-tubos">${tubos} 🧪</div>
|
||||
${comision > 0 ? `<div class="ranking-comision">+${formatMoney(comision)}</div>` : ''}
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
console.error('Error cargando ranking:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function cargarVentasRecientes() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/ventas-recientes');
|
||||
const data = await res.json();
|
||||
const lista = document.getElementById('ventas-list');
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
lista.innerHTML = '<div class="loading">No hay ventas hoy</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
lista.innerHTML = data.map(v => {
|
||||
const nombre = v.nombre_completo || v.vendedor_username;
|
||||
return `
|
||||
<div class="venta-item">
|
||||
<div class="venta-info">
|
||||
<div class="vendedor">${nombre}</div>
|
||||
<div class="cliente">${v.cliente || 'Sin cliente'} • ${formatDate(v.fecha_venta)}</div>
|
||||
</div>
|
||||
<div class="venta-monto">${formatMoney(v.monto || 0)}</div>
|
||||
</div>
|
||||
`}).join('');
|
||||
} catch (e) {
|
||||
console.error('Error cargando ventas:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function cargarDatos() {
|
||||
cargarResumen();
|
||||
cargarRanking();
|
||||
cargarVentasRecientes();
|
||||
}
|
||||
|
||||
// Cargar datos al inicio
|
||||
cargarDatos();
|
||||
|
||||
// Actualizar cada 30 segundos
|
||||
setInterval(cargarDatos, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
return render_template_string(html)
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.getenv('FLASK_PORT', 5000))
|
||||
host = os.getenv('FLASK_HOST', '0.0.0.0')
|
||||
debug = os.getenv('DEBUG', 'False').lower() == 'true'
|
||||
|
||||
logger.info(f"Iniciando Sales Bot en {host}:{port}")
|
||||
app.run(host=host, port=port, debug=debug)
|
||||
85
sales-bot/compose.yaml
Normal file
85
sales-bot/compose.yaml
Normal file
@@ -0,0 +1,85 @@
|
||||
version: '3.8'
|
||||
|
||||
# ============================================================================
|
||||
# SALES BOT - Docker Compose
|
||||
# ============================================================================
|
||||
#
|
||||
# Servicio: Sales Bot (Bot de ventas con OCR)
|
||||
# Requiere: Mattermost y NocoDB corriendo
|
||||
# Puerto: 5000
|
||||
#
|
||||
# Uso:
|
||||
# 1. Editar archivo .env con tus credenciales
|
||||
# 2. docker-compose build
|
||||
# 3. docker-compose up -d
|
||||
# Acceder: http://your-server-ip:5000/health
|
||||
#
|
||||
|
||||
services:
|
||||
sales-bot:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: sales-bot
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
# === MATTERMOST ===
|
||||
MATTERMOST_URL: ${MATTERMOST_URL:-http://host.docker.internal:8065}
|
||||
MATTERMOST_BOT_TOKEN: ${MATTERMOST_BOT_TOKEN}
|
||||
MATTERMOST_TEAM_NAME: ${MATTERMOST_TEAM_NAME:-sales}
|
||||
MATTERMOST_WEBHOOK_SECRET: ${MATTERMOST_WEBHOOK_SECRET}
|
||||
MATTERMOST_WEBHOOK_URL: ${MATTERMOST_WEBHOOK_URL}
|
||||
|
||||
# === NOCODB ===
|
||||
NOCODB_URL: ${NOCODB_URL:-http://host.docker.internal:8080}
|
||||
NOCODB_TOKEN: ${NOCODB_TOKEN}
|
||||
NOCODB_TABLE_VENDEDORES: ${NOCODB_TABLE_VENDEDORES}
|
||||
NOCODB_TABLE_VENTAS: ${NOCODB_TABLE_VENTAS}
|
||||
NOCODB_TABLE_VENTAS_DETALLE: ${NOCODB_TABLE_VENTAS_DETALLE}
|
||||
NOCODB_TABLE_METAS: ${NOCODB_TABLE_METAS}
|
||||
|
||||
# === FLASK ===
|
||||
FLASK_PORT: 5000
|
||||
FLASK_DEBUG: "False"
|
||||
|
||||
# === LOGGING ===
|
||||
LOG_LEVEL: INFO
|
||||
LOG_FILE: /app/logs/sales-bot.log
|
||||
|
||||
# === ZONA HORARIA ===
|
||||
TZ: America/Mexico_City
|
||||
TZ_OFFSET: "-6"
|
||||
|
||||
volumes:
|
||||
# Montar logs para persistencia
|
||||
- ./logs:/app/logs
|
||||
|
||||
# Si quieres desarrollo en caliente, descomentar:
|
||||
# - ./app.py:/app/app.py
|
||||
# - ./nocodb_client.py:/app/nocodb_client.py
|
||||
# - ./mattermost_client.py:/app/mattermost_client.py
|
||||
# - ./ocr_processor.py:/app/ocr_processor.py
|
||||
# - ./handlers.py:/app/handlers.py
|
||||
|
||||
networks:
|
||||
- sales-bot-network
|
||||
|
||||
# Extra hosts para conectar con servicios del host
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
networks:
|
||||
sales-bot-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
logs:
|
||||
376
sales-bot/handlers.py
Normal file
376
sales-bot/handlers.py
Normal file
@@ -0,0 +1,376 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from utils import extraer_monto, extraer_cliente, formatear_moneda, extraer_tubos
|
||||
from ocr_processor import OCRProcessor
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def handle_venta_message(data, mattermost, nocodb):
|
||||
"""
|
||||
Maneja mensajes de venta en Mattermost
|
||||
NUEVO: Sistema de comisiones por tubos vendidos
|
||||
"""
|
||||
try:
|
||||
user_name = data.get('user_name')
|
||||
text = data.get('text', '').strip()
|
||||
channel_name = data.get('channel_name')
|
||||
post_id = data.get('post_id')
|
||||
file_ids = data.get('file_ids', '')
|
||||
|
||||
if file_ids and isinstance(file_ids, str):
|
||||
file_ids = [f.strip() for f in file_ids.split(',') if f.strip()]
|
||||
elif not file_ids:
|
||||
file_ids = []
|
||||
|
||||
logger.info(f"Procesando venta de {user_name}: {text}, archivos: {file_ids}")
|
||||
|
||||
# Extraer información del texto
|
||||
monto = extraer_monto(text)
|
||||
cliente = extraer_cliente(text)
|
||||
tubos_manual = extraer_tubos(text) # NUEVO: tubos manuales
|
||||
|
||||
# Procesar imágenes adjuntas
|
||||
imagen_url = None
|
||||
ocr_info = ""
|
||||
productos_ocr = []
|
||||
|
||||
if file_ids:
|
||||
logger.info(f"Procesando {len(file_ids)} archivos adjuntos")
|
||||
file_id = file_ids[0]
|
||||
|
||||
imagen_data = mattermost.get_file(file_id)
|
||||
file_info = mattermost.get_file_info(file_id)
|
||||
|
||||
if file_info and imagen_data:
|
||||
filename = file_info.get('name', 'ticket.jpg')
|
||||
file_size = file_info.get('size', 0)
|
||||
|
||||
bot_token = os.getenv('MATTERMOST_BOT_TOKEN')
|
||||
mattermost_url = os.getenv('MATTERMOST_URL')
|
||||
imagen_url = f"{mattermost_url}/api/v4/files/{file_id}?access_token={bot_token}"
|
||||
|
||||
logger.info(f"Archivo adjunto: {filename}, tamaño: {file_size} bytes")
|
||||
|
||||
# Procesar con OCR
|
||||
try:
|
||||
ocr = OCRProcessor()
|
||||
resultado_ocr = ocr.procesar_ticket(imagen_data)
|
||||
|
||||
if resultado_ocr:
|
||||
monto_ocr = resultado_ocr.get('monto_detectado')
|
||||
fecha_ocr = resultado_ocr.get('fecha_detectada')
|
||||
productos_ocr = resultado_ocr.get('productos', [])
|
||||
|
||||
if not monto and monto_ocr:
|
||||
monto = monto_ocr
|
||||
logger.info(f"Usando monto detectado por OCR: ${monto}")
|
||||
ocr_info += f"\n💡 Monto detectado: ${monto:,.2f}"
|
||||
|
||||
elif monto and monto_ocr:
|
||||
es_valido, mensaje = ocr.validar_monto_con_ocr(monto, monto_ocr, tolerancia=0.05)
|
||||
ocr_info += f"\n{mensaje}"
|
||||
|
||||
if not es_valido:
|
||||
logger.warning(mensaje)
|
||||
|
||||
if fecha_ocr:
|
||||
ocr_info += f"\n📅 Fecha: {fecha_ocr}"
|
||||
|
||||
if productos_ocr:
|
||||
# NUEVO: Contar tubos de tinte
|
||||
tubos_tinte = sum(
|
||||
p['cantidad'] for p in productos_ocr
|
||||
if 'tinte' in p['marca'].lower() or 'tinte' in p['producto'].lower()
|
||||
or 'cromatique' in p['marca'].lower()
|
||||
)
|
||||
ocr_info += f"\n🧪 Tubos de tinte: {tubos_tinte}"
|
||||
ocr_info += f"\n📦 Total productos: {len(productos_ocr)}"
|
||||
logger.info(f"Tubos de tinte detectados: {tubos_tinte}")
|
||||
|
||||
except Exception as ocr_error:
|
||||
logger.error(f"Error en OCR: {str(ocr_error)}")
|
||||
ocr_info = "\n⚠️ No se pudo leer el ticket"
|
||||
productos_ocr = []
|
||||
|
||||
logger.info(f"URL de imagen: {imagen_url}")
|
||||
|
||||
if not monto:
|
||||
mensaje = (
|
||||
f"@{user_name} Necesito el monto de la venta.\n"
|
||||
"**Formatos válidos:**\n"
|
||||
"• `venta @monto 1500 @cliente Juan Pérez`\n"
|
||||
"• `vendí $1500 a Juan Pérez`\n"
|
||||
"• Adjunta foto del ticket"
|
||||
)
|
||||
mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':moneybag:')
|
||||
return {'text': mensaje}
|
||||
|
||||
if not cliente:
|
||||
cliente = "Cliente sin nombre"
|
||||
|
||||
# Verificar/crear vendedor
|
||||
vendedor = nocodb.get_vendedor(user_name)
|
||||
if not vendedor:
|
||||
user_info = mattermost.get_user_by_username(user_name)
|
||||
email = user_info.get('email', f"{user_name}@consultoria-as.com") if user_info else f"{user_name}@consultoria-as.com"
|
||||
nombre = user_info.get('first_name', user_name) if user_info else user_name
|
||||
|
||||
vendedor = nocodb.crear_vendedor(
|
||||
username=user_name,
|
||||
nombre_completo=nombre,
|
||||
email=email,
|
||||
meta_diaria_tubos=3 # NUEVO: Meta de 3 tubos diarios
|
||||
)
|
||||
|
||||
if vendedor:
|
||||
mensaje_bienvenida = (
|
||||
f"👋 ¡Bienvenido @{user_name}!\n"
|
||||
f"**Sistema de comisiones:**\n"
|
||||
f"• Meta diaria: {nocodb.META_DIARIA_TUBOS} tubos de tinte\n"
|
||||
f"• Comisión: ${nocodb.COMISION_POR_TUBO:.0f} por tubo después del {nocodb.META_DIARIA_TUBOS}º\n"
|
||||
f"¡Empieza a registrar tus ventas!"
|
||||
)
|
||||
mattermost.post_message_webhook(mensaje_bienvenida, username='Sales Bot', icon_emoji=':wave:')
|
||||
|
||||
# Registrar venta
|
||||
venta = nocodb.registrar_venta(
|
||||
vendedor_username=user_name,
|
||||
monto=monto,
|
||||
cliente=cliente,
|
||||
descripcion=text,
|
||||
mensaje_id=post_id,
|
||||
canal=channel_name,
|
||||
imagen_url=imagen_url
|
||||
)
|
||||
|
||||
if venta:
|
||||
venta_id = venta.get('Id')
|
||||
|
||||
# Guardar productos detectados por OCR
|
||||
if productos_ocr:
|
||||
productos_guardados = nocodb.guardar_productos_venta(venta_id, productos_ocr)
|
||||
if productos_guardados:
|
||||
logger.info(f"Guardados {len(productos_guardados)} productos para venta {venta_id}")
|
||||
|
||||
# NUEVO: Guardar tubos manuales si se especificaron
|
||||
elif tubos_manual and tubos_manual > 0:
|
||||
productos_manuales = [{
|
||||
'producto': 'Tinte (registro manual)',
|
||||
'marca': 'Manual',
|
||||
'cantidad': tubos_manual,
|
||||
'precio_unitario': monto / tubos_manual if tubos_manual > 0 else 0,
|
||||
'importe': monto
|
||||
}]
|
||||
productos_guardados = nocodb.guardar_productos_venta(venta_id, productos_manuales)
|
||||
if productos_guardados:
|
||||
logger.info(f"Guardados {tubos_manual} tubos manuales para venta {venta_id}")
|
||||
|
||||
# NUEVO: Actualizar tabla de metas
|
||||
try:
|
||||
nocodb.actualizar_meta_vendedor(user_name)
|
||||
logger.info(f"Metas actualizadas para {user_name}")
|
||||
except Exception as meta_error:
|
||||
logger.error(f"Error actualizando metas: {str(meta_error)}")
|
||||
|
||||
# Reacción de éxito
|
||||
if post_id:
|
||||
mattermost.add_reaction(post_id, 'white_check_mark')
|
||||
|
||||
# NUEVO: Obtener estadísticas del día
|
||||
stats_dia = nocodb.get_estadisticas_vendedor_dia(user_name)
|
||||
|
||||
# Construir mensaje
|
||||
mensaje_confirmacion = (
|
||||
f"✅ **Venta registrada**\n\n"
|
||||
f"**Vendedor:** @{user_name}\n"
|
||||
f"**Monto:** {formatear_moneda(monto)}\n"
|
||||
f"**Cliente:** {cliente}\n"
|
||||
)
|
||||
|
||||
if imagen_url:
|
||||
mensaje_confirmacion += f"📸 **Ticket:** Guardado{ocr_info}\n"
|
||||
|
||||
# NUEVO: Mostrar estadísticas de tubos y comisiones
|
||||
if stats_dia:
|
||||
tubos_hoy = stats_dia.get('tubos_vendidos', 0)
|
||||
comision_hoy = stats_dia.get('comision', 0)
|
||||
meta = stats_dia.get('meta_diaria', 3)
|
||||
tubos_comisionables = stats_dia.get('tubos_comisionables', 0)
|
||||
|
||||
# Determinar emoji según progreso
|
||||
if tubos_hoy >= meta * 2:
|
||||
emoji = '🔥'
|
||||
mensaje_extra = '¡Increíble día!'
|
||||
elif tubos_hoy >= meta:
|
||||
emoji = '⭐'
|
||||
mensaje_extra = '¡Meta cumplida!'
|
||||
elif tubos_hoy >= meta - 1:
|
||||
emoji = '💪'
|
||||
mensaje_extra = '¡Casi llegas!'
|
||||
else:
|
||||
emoji = '📊'
|
||||
mensaje_extra = '¡Sigue así!'
|
||||
|
||||
mensaje_confirmacion += (
|
||||
f"\n**Resumen del día:** {emoji}\n"
|
||||
f"• Tubos vendidos hoy: {tubos_hoy} 🧪\n"
|
||||
f"• Meta diaria: {meta} tubos\n"
|
||||
)
|
||||
|
||||
if tubos_hoy > meta:
|
||||
mensaje_confirmacion += (
|
||||
f"• Tubos con comisión: {tubos_comisionables}\n"
|
||||
f"• Comisión ganada hoy: {formatear_moneda(comision_hoy)} 💰\n"
|
||||
)
|
||||
else:
|
||||
faltan = meta - tubos_hoy
|
||||
mensaje_confirmacion += f"• Faltan {faltan} tubos para comisión\n"
|
||||
|
||||
mensaje_confirmacion += f"• {mensaje_extra}"
|
||||
|
||||
# Enviar confirmación
|
||||
mattermost.post_message_webhook(
|
||||
mensaje_confirmacion,
|
||||
username='Sales Bot',
|
||||
icon_emoji=':moneybag:'
|
||||
)
|
||||
|
||||
return {'text': mensaje_confirmacion}
|
||||
else:
|
||||
mensaje_error = f"❌ Error al registrar la venta. Intenta de nuevo."
|
||||
mattermost.post_message_webhook(mensaje_error, username='Sales Bot', icon_emoji=':x:')
|
||||
return {'text': mensaje_error}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error en handle_venta_message: {str(e)}", exc_info=True)
|
||||
mensaje_error = f"❌ Error: {str(e)}"
|
||||
return {'text': mensaje_error}
|
||||
|
||||
def generar_reporte_diario(mattermost, nocodb):
|
||||
"""
|
||||
Genera reporte diario de ventas y comisiones
|
||||
NUEVO: Muestra tubos vendidos y comisiones ganadas
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
|
||||
hoy = datetime.now().strftime('%Y-%m-%d')
|
||||
mes_actual = datetime.now().strftime('%Y-%m')
|
||||
|
||||
# Obtener todas las ventas del día
|
||||
ventas_hoy = nocodb.get_ventas_dia()
|
||||
|
||||
# Agrupar por vendedor
|
||||
vendedores_hoy = {}
|
||||
for venta in ventas_hoy:
|
||||
vendedor = venta.get('vendedor_username')
|
||||
if vendedor not in vendedores_hoy:
|
||||
vendedores_hoy[vendedor] = []
|
||||
vendedores_hoy[vendedor].append(venta)
|
||||
|
||||
# Calcular estadísticas por vendedor
|
||||
stats_vendedores = []
|
||||
for vendedor in vendedores_hoy.keys():
|
||||
stats = nocodb.get_estadisticas_vendedor_dia(vendedor, hoy)
|
||||
if stats:
|
||||
stats_vendedores.append(stats)
|
||||
|
||||
# Ordenar por tubos vendidos
|
||||
stats_vendedores.sort(key=lambda x: x.get('tubos_vendidos', 0), reverse=True)
|
||||
|
||||
# Calcular totales
|
||||
total_tubos_dia = sum(s.get('tubos_vendidos', 0) for s in stats_vendedores)
|
||||
total_comisiones = sum(s.get('comision', 0) for s in stats_vendedores)
|
||||
total_monto = sum(s.get('monto_total_dia', 0) for s in stats_vendedores)
|
||||
|
||||
# Construir mensaje
|
||||
mensaje = (
|
||||
f"📊 **Reporte Diario - {datetime.now().strftime('%d/%m/%Y')}**\n\n"
|
||||
f"**Resumen del día:**\n"
|
||||
f"• Tubos vendidos: {total_tubos_dia} 🧪\n"
|
||||
f"• Comisiones pagadas: {formatear_moneda(total_comisiones)} 💰\n"
|
||||
f"• Monto total: {formatear_moneda(total_monto)}\n"
|
||||
f"• Ventas: {len(ventas_hoy)}\n\n"
|
||||
)
|
||||
|
||||
if stats_vendedores:
|
||||
mensaje += "**Top Vendedores del Día:**\n"
|
||||
for i, stats in enumerate(stats_vendedores[:5], 1):
|
||||
vendedor = stats.get('vendedor')
|
||||
tubos = stats.get('tubos_vendidos', 0)
|
||||
comision = stats.get('comision', 0)
|
||||
|
||||
emoji = '🥇' if i == 1 else '🥈' if i == 2 else '🥉' if i == 3 else '🏅'
|
||||
|
||||
if comision > 0:
|
||||
mensaje += f"{emoji} @{vendedor} - {tubos} tubos ({formatear_moneda(comision)} comisión)\n"
|
||||
else:
|
||||
mensaje += f"{emoji} @{vendedor} - {tubos} tubos\n"
|
||||
|
||||
# Obtener canal de reportes
|
||||
team_name = os.getenv('MATTERMOST_TEAM_NAME')
|
||||
channel_reportes = os.getenv('MATTERMOST_CHANNEL_REPORTES')
|
||||
|
||||
canal = mattermost.get_channel_by_name(team_name, channel_reportes)
|
||||
|
||||
if canal:
|
||||
mattermost.post_message(canal['id'], mensaje)
|
||||
logger.info("Reporte diario generado")
|
||||
return {'status': 'success', 'message': 'Reporte generado'}
|
||||
else:
|
||||
logger.warning(f"Canal {channel_reportes} no encontrado")
|
||||
return {'status': 'error', 'message': 'Canal no encontrado'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generando reporte diario: {str(e)}", exc_info=True)
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
def comando_estadisticas(user_name, mattermost, nocodb):
|
||||
"""
|
||||
Muestra estadísticas personales del vendedor
|
||||
Comando: /stats o /estadisticas
|
||||
"""
|
||||
try:
|
||||
# Estadísticas del día
|
||||
stats_hoy = nocodb.get_estadisticas_vendedor_dia(user_name)
|
||||
|
||||
# Estadísticas del mes
|
||||
stats_mes = nocodb.get_estadisticas_vendedor_mes(user_name)
|
||||
|
||||
if not stats_hoy and not stats_mes:
|
||||
mensaje = f"@{user_name} Aún no tienes ventas registradas."
|
||||
return mensaje
|
||||
|
||||
mensaje = f"📈 **Estadísticas de @{user_name}**\n\n"
|
||||
|
||||
# Hoy
|
||||
if stats_hoy:
|
||||
mensaje += (
|
||||
f"**Hoy ({datetime.now().strftime('%d/%m')})**\n"
|
||||
f"• Tubos: {stats_hoy.get('tubos_vendidos', 0)} 🧪\n"
|
||||
f"• Comisión: {formatear_moneda(stats_hoy.get('comision', 0))}\n"
|
||||
f"• Monto: {formatear_moneda(stats_hoy.get('monto_total_dia', 0))}\n"
|
||||
f"• Ventas: {stats_hoy.get('cantidad_ventas', 0)}\n\n"
|
||||
)
|
||||
|
||||
# Mes
|
||||
if stats_mes:
|
||||
mensaje += (
|
||||
f"**Este mes**\n"
|
||||
f"• Tubos totales: {stats_mes.get('tubos_totales', 0)} 🧪\n"
|
||||
f"• Comisión total: {formatear_moneda(stats_mes.get('comision_total', 0))} 💰\n"
|
||||
f"• Monto total: {formatear_moneda(stats_mes.get('monto_total', 0))}\n"
|
||||
f"• Ventas: {stats_mes.get('cantidad_ventas', 0)}\n"
|
||||
f"• Días activos: {stats_mes.get('dias_activos', 0)}\n"
|
||||
f"• Días con meta: {stats_mes.get('dias_meta_cumplida', 0)}\n"
|
||||
f"• Promedio/día: {stats_mes.get('promedio_tubos_dia', 0):.1f} tubos\n"
|
||||
)
|
||||
|
||||
mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':bar_chart:')
|
||||
return mensaje
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error en comando_estadisticas: {str(e)}", exc_info=True)
|
||||
return f"❌ Error obteniendo estadísticas"
|
||||
201
sales-bot/mattermost_client.py
Normal file
201
sales-bot/mattermost_client.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import requests
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MattermostClient:
|
||||
def __init__(self, url, token):
|
||||
self.url = url.rstrip('/')
|
||||
self.token = token
|
||||
self.api_url = f"{self.url}/api/v4"
|
||||
self.headers = {
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
self.webhook_url = os.getenv('MATTERMOST_WEBHOOK_URL')
|
||||
|
||||
def test_connection(self):
|
||||
"""Prueba la conexión con Mattermost"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.api_url}/users/me",
|
||||
headers=self.headers,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
user = response.json()
|
||||
logger.info(f"Conexión exitosa con Mattermost. Bot: {user.get('username')}")
|
||||
return {
|
||||
'status': 'success',
|
||||
'bot_username': user.get('username'),
|
||||
'bot_id': user.get('id')
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error conectando con Mattermost: {str(e)}")
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
def get_channel_by_name(self, team_name, channel_name):
|
||||
"""Obtiene información de un canal por nombre"""
|
||||
try:
|
||||
# Primero obtener el team
|
||||
response = requests.get(
|
||||
f"{self.api_url}/teams/name/{team_name}",
|
||||
headers=self.headers,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
team = response.json()
|
||||
team_id = team['id']
|
||||
|
||||
# Luego obtener el canal
|
||||
response = requests.get(
|
||||
f"{self.api_url}/teams/{team_id}/channels/name/{channel_name}",
|
||||
headers=self.headers,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo canal {channel_name}: {str(e)}")
|
||||
return None
|
||||
|
||||
def post_message(self, channel_id, message, props=None):
|
||||
"""Publica un mensaje en un canal"""
|
||||
try:
|
||||
payload = {
|
||||
'channel_id': channel_id,
|
||||
'message': message
|
||||
}
|
||||
if props:
|
||||
payload['props'] = props
|
||||
|
||||
response = requests.post(
|
||||
f"{self.api_url}/posts",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Mensaje publicado en canal {channel_id}")
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Error publicando mensaje: {str(e)}")
|
||||
return None
|
||||
|
||||
def post_message_webhook(self, message, username=None, icon_emoji=None):
|
||||
"""Publica un mensaje usando webhook incoming"""
|
||||
try:
|
||||
payload = {'text': message}
|
||||
if username:
|
||||
payload['username'] = username
|
||||
if icon_emoji:
|
||||
payload['icon_emoji'] = icon_emoji
|
||||
|
||||
response = requests.post(
|
||||
self.webhook_url,
|
||||
json=payload,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info("Mensaje publicado via webhook")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error publicando via webhook: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_file(self, file_id):
|
||||
"""Descarga un archivo de Mattermost"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.api_url}/files/{file_id}",
|
||||
headers=self.headers,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except Exception as e:
|
||||
logger.error(f"Error descargando archivo {file_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_file_info(self, file_id):
|
||||
"""Obtiene información de un archivo"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.api_url}/files/{file_id}/info",
|
||||
headers=self.headers,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo info de archivo {file_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def upload_file(self, channel_id, file_content, filename):
|
||||
"""Sube un archivo a Mattermost"""
|
||||
try:
|
||||
files = {
|
||||
'files': (filename, file_content)
|
||||
}
|
||||
data = {
|
||||
'channel_id': channel_id
|
||||
}
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.token}'
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{self.api_url}/files",
|
||||
headers=headers,
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Error subiendo archivo: {str(e)}")
|
||||
return None
|
||||
|
||||
def add_reaction(self, post_id, emoji_name):
|
||||
"""Agrega una reacción a un post"""
|
||||
try:
|
||||
# Obtener el user_id del bot
|
||||
me = requests.get(
|
||||
f"{self.api_url}/users/me",
|
||||
headers=self.headers,
|
||||
timeout=10
|
||||
).json()
|
||||
|
||||
payload = {
|
||||
'user_id': me['id'],
|
||||
'post_id': post_id,
|
||||
'emoji_name': emoji_name
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{self.api_url}/reactions",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error agregando reacción: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_user_by_username(self, username):
|
||||
"""Obtiene información de un usuario por username"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.api_url}/users/username/{username}",
|
||||
headers=self.headers,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo usuario {username}: {str(e)}")
|
||||
return None
|
||||
611
sales-bot/nocodb_client.py
Normal file
611
sales-bot/nocodb_client.py
Normal file
@@ -0,0 +1,611 @@
|
||||
import requests
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, date, timezone, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Zona horaria de México (UTC-6)
|
||||
TZ_MEXICO = timezone(timedelta(hours=-6))
|
||||
|
||||
class NocoDBClient:
|
||||
def __init__(self, url, token):
|
||||
self.url = url.rstrip('/')
|
||||
self.token = token
|
||||
self.headers = {
|
||||
'xc-token': self.token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
# IDs de tablas desde variables de entorno
|
||||
self.table_vendedores = os.getenv('NOCODB_TABLE_VENDEDORES')
|
||||
self.table_ventas = os.getenv('NOCODB_TABLE_VENTAS')
|
||||
self.table_metas = os.getenv('NOCODB_TABLE_METAS')
|
||||
self.table_detalle = os.getenv('NOCODB_TABLE_VENTAS_DETALLE')
|
||||
|
||||
# NUEVA CONFIGURACIÓN DE COMISIONES
|
||||
self.META_DIARIA_TUBOS = 3 # Meta: 3 tubos diarios
|
||||
self.COMISION_POR_TUBO = 10.0 # $10 por tubo después del 3ro
|
||||
|
||||
def test_connection(self):
|
||||
"""Prueba la conexión con NocoDB"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.url}/api/v2/meta/bases",
|
||||
headers=self.headers,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
bases = response.json()
|
||||
logger.info(f"Conexión exitosa con NocoDB. Bases: {len(bases.get('list', []))}")
|
||||
return {
|
||||
'status': 'success',
|
||||
'bases_count': len(bases.get('list', []))
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error conectando con NocoDB: {str(e)}")
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
def get_vendedor(self, username):
|
||||
"""Obtiene información de un vendedor por username"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.url}/api/v2/tables/{self.table_vendedores}/records",
|
||||
headers=self.headers,
|
||||
params={'limit': 100},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
vendedores = response.json().get('list', [])
|
||||
|
||||
for vendedor in vendedores:
|
||||
if vendedor.get('username') == username:
|
||||
return vendedor
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo vendedor {username}: {str(e)}")
|
||||
return None
|
||||
|
||||
def crear_vendedor(self, username, nombre_completo, email, meta_diaria_tubos=3):
|
||||
"""Crea un nuevo vendedor con meta diaria de tubos"""
|
||||
try:
|
||||
payload = {
|
||||
'username': username,
|
||||
'nombre_completo': nombre_completo,
|
||||
'email': email,
|
||||
'meta_diaria_tubos': meta_diaria_tubos, # Nueva: meta en tubos por día
|
||||
'activo': True,
|
||||
'fecha_registro': datetime.now(TZ_MEXICO).isoformat()
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{self.url}/api/v2/tables/{self.table_vendedores}/records",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Vendedor {username} creado con meta diaria de {meta_diaria_tubos} tubos")
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Error creando vendedor: {str(e)}")
|
||||
return None
|
||||
|
||||
def registrar_venta(self, vendedor_username, monto, cliente, producto=None,
|
||||
descripcion=None, mensaje_id=None, canal=None, imagen_url=None):
|
||||
"""Registra una nueva venta con URL de imagen opcional"""
|
||||
try:
|
||||
payload = {
|
||||
'vendedor_username': vendedor_username,
|
||||
'monto': float(monto),
|
||||
'cliente': cliente,
|
||||
'fecha_venta': datetime.now(TZ_MEXICO).isoformat(),
|
||||
'estado': 'confirmada',
|
||||
'mensaje_id': mensaje_id,
|
||||
'canal': canal
|
||||
}
|
||||
|
||||
if producto:
|
||||
payload['producto'] = producto
|
||||
if descripcion:
|
||||
payload['descripcion'] = descripcion
|
||||
|
||||
if imagen_url:
|
||||
filename = imagen_url.split('/')[-1].split('?')[0] if '/' in imagen_url else 'ticket.jpg'
|
||||
payload['imagen_ticket'] = [
|
||||
{
|
||||
"url": imagen_url,
|
||||
"title": filename,
|
||||
"mimetype": "image/jpeg"
|
||||
}
|
||||
]
|
||||
logger.info(f"Guardando imagen con URL: {imagen_url[:50]}...")
|
||||
|
||||
logger.info(f"Registrando venta - Monto: ${monto}, Cliente: {cliente}")
|
||||
|
||||
response = requests.post(
|
||||
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
venta = response.json()
|
||||
logger.info(f"Venta registrada: ${monto} - {cliente} - vendedor: {vendedor_username}")
|
||||
|
||||
# NUEVO: No actualizar meta mensual, ahora se calcula al consultar
|
||||
|
||||
return venta
|
||||
except Exception as e:
|
||||
logger.error(f"Error registrando venta: {str(e)}", exc_info=True)
|
||||
if 'response' in locals():
|
||||
logger.error(f"Response: {response.text}")
|
||||
return None
|
||||
|
||||
def get_ventas_dia(self, vendedor_username=None, fecha=None):
|
||||
"""Obtiene ventas de un día específico (convierte UTC a hora México)"""
|
||||
try:
|
||||
if fecha is None:
|
||||
fecha = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
|
||||
elif isinstance(fecha, date):
|
||||
fecha = fecha.strftime('%Y-%m-%d')
|
||||
|
||||
# Obtener todas las ventas y filtrar
|
||||
response = requests.get(
|
||||
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
|
||||
headers=self.headers,
|
||||
params={'limit': 1000},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
todas_ventas = response.json().get('list', [])
|
||||
|
||||
# Filtrar por día (convertir UTC a México)
|
||||
ventas_filtradas = []
|
||||
for venta in todas_ventas:
|
||||
fecha_venta_str = venta.get('fecha_venta', '')
|
||||
vendedor = venta.get('vendedor_username', '')
|
||||
|
||||
# Convertir fecha UTC a México
|
||||
try:
|
||||
# Parsear fecha UTC de NocoDB
|
||||
if '+' in fecha_venta_str:
|
||||
fecha_venta_utc = datetime.fromisoformat(fecha_venta_str.replace('+00:00', '+0000'))
|
||||
else:
|
||||
fecha_venta_utc = datetime.fromisoformat(fecha_venta_str)
|
||||
|
||||
# Convertir a hora de México
|
||||
fecha_venta_mexico = fecha_venta_utc.astimezone(TZ_MEXICO)
|
||||
fecha_venta_local = fecha_venta_mexico.strftime('%Y-%m-%d')
|
||||
except:
|
||||
fecha_venta_local = fecha_venta_str[:10] if fecha_venta_str else ''
|
||||
|
||||
fecha_match = fecha_venta_local == fecha
|
||||
|
||||
if vendedor_username:
|
||||
vendedor_match = vendedor == vendedor_username
|
||||
else:
|
||||
vendedor_match = True
|
||||
|
||||
if fecha_match and vendedor_match:
|
||||
ventas_filtradas.append(venta)
|
||||
|
||||
logger.info(f"Ventas del día {fecha} para {vendedor_username or 'todos'}: {len(ventas_filtradas)}")
|
||||
return ventas_filtradas
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo ventas del día: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_ventas_mes(self, vendedor_username=None, mes=None):
|
||||
"""Obtiene ventas del mes actual o especificado"""
|
||||
try:
|
||||
if mes is None:
|
||||
mes = datetime.now(TZ_MEXICO).strftime('%Y-%m')
|
||||
|
||||
response = requests.get(
|
||||
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
|
||||
headers=self.headers,
|
||||
params={'limit': 1000},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
todas_ventas = response.json().get('list', [])
|
||||
|
||||
ventas_filtradas = []
|
||||
for venta in todas_ventas:
|
||||
fecha_venta = venta.get('fecha_venta', '')
|
||||
vendedor = venta.get('vendedor_username', '')
|
||||
|
||||
fecha_match = fecha_venta.startswith(mes)
|
||||
|
||||
if vendedor_username:
|
||||
vendedor_match = vendedor == vendedor_username
|
||||
else:
|
||||
vendedor_match = True
|
||||
|
||||
if fecha_match and vendedor_match:
|
||||
ventas_filtradas.append(venta)
|
||||
|
||||
logger.info(f"Ventas del mes {mes}: {len(ventas_filtradas)}")
|
||||
return ventas_filtradas
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo ventas del mes: {str(e)}")
|
||||
return []
|
||||
|
||||
def contar_tubos_vendidos_dia(self, vendedor_username, fecha=None):
|
||||
"""
|
||||
Cuenta los tubos de tinte vendidos en un día específico
|
||||
Busca en la tabla de detalle de ventas usando el campo numérico venta_id_num
|
||||
"""
|
||||
try:
|
||||
if fecha is None:
|
||||
fecha = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
|
||||
elif isinstance(fecha, date):
|
||||
fecha = fecha.strftime('%Y-%m-%d')
|
||||
|
||||
logger.info(f"Contando tubos para {vendedor_username} del día {fecha}")
|
||||
|
||||
# Obtener ventas del día
|
||||
ventas_dia = self.get_ventas_dia(vendedor_username, fecha)
|
||||
|
||||
if not ventas_dia:
|
||||
logger.info(f"No hay ventas para {vendedor_username} el {fecha}")
|
||||
return 0
|
||||
|
||||
# Obtener IDs de las ventas
|
||||
venta_ids = [v.get('Id') for v in ventas_dia if v.get('Id')]
|
||||
logger.info(f"IDs de ventas a buscar: {venta_ids}")
|
||||
|
||||
if not venta_ids:
|
||||
return 0
|
||||
|
||||
# Obtener todos los detalles de productos
|
||||
response = requests.get(
|
||||
f"{self.url}/api/v2/tables/{self.table_detalle}/records",
|
||||
headers=self.headers,
|
||||
params={'limit': 1000},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
todos_detalles = response.json().get('list', [])
|
||||
|
||||
logger.info(f"Total detalles en tabla: {len(todos_detalles)}")
|
||||
|
||||
# Contar tubos de tinte (productos que sean tintes)
|
||||
tubos_vendidos = 0
|
||||
detalles_encontrados = 0
|
||||
|
||||
for detalle in todos_detalles:
|
||||
# Usar el campo numérico venta_id_num para verificar la relación
|
||||
venta_id_num = detalle.get('venta_id_num')
|
||||
|
||||
if venta_id_num is None:
|
||||
continue
|
||||
|
||||
# Verificar si este detalle pertenece a alguna de las ventas del día
|
||||
if int(venta_id_num) in venta_ids:
|
||||
detalles_encontrados += 1
|
||||
|
||||
# Detectar si es un tubo de tinte
|
||||
producto = str(detalle.get('producto', '')).lower()
|
||||
marca = str(detalle.get('marca', '')).lower()
|
||||
nombre_completo = f"{marca} {producto}".lower()
|
||||
|
||||
logger.info(f" Analizando: {marca} {producto} (venta_id_num={venta_id_num})")
|
||||
|
||||
# Si contiene "tinte" o "cromatique" o es registro manual, contar
|
||||
if 'tinte' in nombre_completo or 'cromatique' in nombre_completo or 'manual' in marca:
|
||||
cantidad = int(detalle.get('cantidad', 0))
|
||||
tubos_vendidos += cantidad
|
||||
logger.info(f" ✓ Tubo detectado: {cantidad}x {marca} {producto}")
|
||||
|
||||
logger.info(f"Detalles encontrados: {detalles_encontrados}")
|
||||
logger.info(f"Total tubos vendidos por {vendedor_username} el {fecha}: {tubos_vendidos}")
|
||||
return tubos_vendidos
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error contando tubos: {str(e)}", exc_info=True)
|
||||
return 0
|
||||
|
||||
def calcular_comision_dia(self, vendedor_username, fecha=None):
|
||||
"""
|
||||
Calcula la comisión del día para un vendedor
|
||||
Fórmula: $10 por cada tubo vendido después del 3ro
|
||||
"""
|
||||
try:
|
||||
tubos_vendidos = self.contar_tubos_vendidos_dia(vendedor_username, fecha)
|
||||
|
||||
if tubos_vendidos <= self.META_DIARIA_TUBOS:
|
||||
comision = 0.0
|
||||
tubos_comisionables = 0
|
||||
else:
|
||||
tubos_comisionables = tubos_vendidos - self.META_DIARIA_TUBOS
|
||||
comision = tubos_comisionables * self.COMISION_POR_TUBO
|
||||
|
||||
logger.info(
|
||||
f"Comisión {vendedor_username}: "
|
||||
f"{tubos_vendidos} tubos vendidos, "
|
||||
f"{tubos_comisionables} comisionables = ${comision:.2f}"
|
||||
)
|
||||
|
||||
return {
|
||||
'tubos_vendidos': tubos_vendidos,
|
||||
'tubos_comisionables': tubos_comisionables,
|
||||
'comision': comision,
|
||||
'meta_diaria': self.META_DIARIA_TUBOS,
|
||||
'comision_por_tubo': self.COMISION_POR_TUBO
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculando comisión: {str(e)}")
|
||||
return {
|
||||
'tubos_vendidos': 0,
|
||||
'tubos_comisionables': 0,
|
||||
'comision': 0.0,
|
||||
'meta_diaria': self.META_DIARIA_TUBOS,
|
||||
'comision_por_tubo': self.COMISION_POR_TUBO
|
||||
}
|
||||
|
||||
def get_estadisticas_vendedor_dia(self, vendedor_username, fecha=None):
|
||||
"""
|
||||
Obtiene estadísticas completas del vendedor para un día
|
||||
"""
|
||||
try:
|
||||
if fecha is None:
|
||||
fecha = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
|
||||
|
||||
# Contar tubos y calcular comisión
|
||||
resultado = self.calcular_comision_dia(vendedor_username, fecha)
|
||||
|
||||
# Obtener monto total vendido
|
||||
ventas = self.get_ventas_dia(vendedor_username, fecha)
|
||||
monto_total = sum(float(v.get('monto', 0)) for v in ventas)
|
||||
|
||||
estadisticas = {
|
||||
**resultado,
|
||||
'monto_total_dia': monto_total,
|
||||
'cantidad_ventas': len(ventas),
|
||||
'fecha': fecha,
|
||||
'vendedor': vendedor_username
|
||||
}
|
||||
|
||||
return estadisticas
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo estadísticas: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_estadisticas_vendedor_mes(self, vendedor_username, mes=None):
|
||||
"""
|
||||
Obtiene estadísticas del mes completo
|
||||
Calcula comisiones acumuladas día por día
|
||||
"""
|
||||
try:
|
||||
if mes is None:
|
||||
mes = datetime.now(TZ_MEXICO).strftime('%Y-%m')
|
||||
|
||||
# Obtener ventas del mes
|
||||
ventas_mes = self.get_ventas_mes(vendedor_username, mes)
|
||||
|
||||
# Agrupar por día (convertir UTC a México)
|
||||
dias = {}
|
||||
for venta in ventas_mes:
|
||||
fecha_venta = venta.get('fecha_venta', '')
|
||||
if fecha_venta:
|
||||
# Convertir fecha UTC a México
|
||||
try:
|
||||
fecha_utc = datetime.fromisoformat(fecha_venta.replace('+00:00', '+00:00').replace('Z', '+00:00'))
|
||||
if fecha_utc.tzinfo is None:
|
||||
fecha_utc = fecha_utc.replace(tzinfo=timezone.utc)
|
||||
fecha_mexico = fecha_utc.astimezone(TZ_MEXICO)
|
||||
dia = fecha_mexico.strftime('%Y-%m-%d')
|
||||
except:
|
||||
dia = fecha_venta[:10] # Fallback
|
||||
|
||||
if dia not in dias:
|
||||
dias[dia] = []
|
||||
dias[dia].append(venta)
|
||||
|
||||
# Calcular comisiones día por día
|
||||
comision_total_mes = 0.0
|
||||
tubos_totales_mes = 0
|
||||
dias_meta_cumplida = 0
|
||||
|
||||
for dia in sorted(dias.keys()):
|
||||
stats_dia = self.get_estadisticas_vendedor_dia(vendedor_username, dia)
|
||||
if stats_dia:
|
||||
comision_total_mes += stats_dia['comision']
|
||||
tubos_totales_mes += stats_dia['tubos_vendidos']
|
||||
if stats_dia['tubos_vendidos'] >= self.META_DIARIA_TUBOS:
|
||||
dias_meta_cumplida += 1
|
||||
|
||||
monto_total_mes = sum(float(v.get('monto', 0)) for v in ventas_mes)
|
||||
|
||||
return {
|
||||
'mes': mes,
|
||||
'vendedor': vendedor_username,
|
||||
'tubos_totales': tubos_totales_mes,
|
||||
'comision_total': comision_total_mes,
|
||||
'monto_total': monto_total_mes,
|
||||
'cantidad_ventas': len(ventas_mes),
|
||||
'dias_activos': len(dias),
|
||||
'dias_meta_cumplida': dias_meta_cumplida,
|
||||
'promedio_tubos_dia': tubos_totales_mes / len(dias) if dias else 0,
|
||||
'meta_diaria': self.META_DIARIA_TUBOS
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo estadísticas del mes: {str(e)}", exc_info=True)
|
||||
return None
|
||||
|
||||
def get_ranking_vendedores(self, mes=None):
|
||||
"""Obtiene ranking de vendedores por tubos vendidos en el mes"""
|
||||
try:
|
||||
if mes is None:
|
||||
mes = datetime.now(TZ_MEXICO).strftime('%Y-%m')
|
||||
|
||||
# Obtener todos los vendedores
|
||||
response = requests.get(
|
||||
f"{self.url}/api/v2/tables/{self.table_vendedores}/records",
|
||||
headers=self.headers,
|
||||
params={'limit': 100},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
vendedores = response.json().get('list', [])
|
||||
|
||||
# Calcular estadísticas para cada vendedor
|
||||
ranking = []
|
||||
for vendedor in vendedores:
|
||||
if not vendedor.get('activo', True):
|
||||
continue
|
||||
|
||||
username = vendedor.get('username')
|
||||
if not username:
|
||||
continue
|
||||
|
||||
stats = self.get_estadisticas_vendedor_mes(username, mes)
|
||||
if stats:
|
||||
ranking.append({
|
||||
'vendedor_username': username,
|
||||
'nombre_completo': vendedor.get('nombre_completo', username),
|
||||
**stats
|
||||
})
|
||||
|
||||
# Ordenar por tubos vendidos (descendente)
|
||||
ranking_ordenado = sorted(
|
||||
ranking,
|
||||
key=lambda x: x.get('tubos_totales', 0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
return ranking_ordenado
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo ranking: {str(e)}")
|
||||
return []
|
||||
|
||||
def guardar_productos_venta(self, venta_id, productos):
|
||||
"""Guarda el detalle de productos de una venta usando campo numérico venta_id_num"""
|
||||
try:
|
||||
if not self.table_detalle:
|
||||
logger.warning("No se configuró NOCODB_TABLE_VENTAS_DETALLE")
|
||||
return None
|
||||
|
||||
productos_guardados = []
|
||||
|
||||
for producto in productos:
|
||||
# Crear registro del producto con venta_id_num (campo numérico simple)
|
||||
payload = {
|
||||
'producto': producto.get('producto', ''),
|
||||
'marca': producto.get('marca', 'Sin marca'),
|
||||
'cantidad': producto.get('cantidad', 1),
|
||||
'precio_unitario': float(producto.get('precio_unitario', 0)),
|
||||
'importe': float(producto.get('importe', 0)),
|
||||
'detectado_ocr': True,
|
||||
'venta_id_num': int(venta_id) # Campo numérico simple en lugar de link
|
||||
}
|
||||
|
||||
logger.info(f"Guardando producto con venta_id_num={venta_id}: {producto.get('marca')} {producto.get('producto')}")
|
||||
|
||||
response = requests.post(
|
||||
f"{self.url}/api/v2/tables/{self.table_detalle}/records",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
detalle = response.json()
|
||||
detalle_id = detalle.get('Id')
|
||||
|
||||
logger.info(f"Producto guardado ID={detalle_id}: {producto.get('marca')} {producto.get('producto')} -> venta_id_num={venta_id}")
|
||||
productos_guardados.append(detalle)
|
||||
|
||||
logger.info(f"Total productos guardados: {len(productos_guardados)} para venta {venta_id}")
|
||||
return productos_guardados
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error guardando productos: {str(e)}", exc_info=True)
|
||||
return None
|
||||
|
||||
def actualizar_meta_vendedor(self, vendedor_username):
|
||||
"""Actualiza o crea el registro de metas del vendedor para el mes actual"""
|
||||
try:
|
||||
if not self.table_metas:
|
||||
logger.warning("No se configuró NOCODB_TABLE_METAS")
|
||||
return None
|
||||
|
||||
# Formato para búsqueda: 2026-01 (año-mes)
|
||||
mes_busqueda = datetime.now(TZ_MEXICO).strftime('%Y-%m')
|
||||
# Formato para guardar en BD: 2026-01-01 (campo Date requiere YYYY-MM-DD)
|
||||
mes_fecha = datetime.now(TZ_MEXICO).strftime('%Y-%m-01')
|
||||
|
||||
# Obtener estadísticas del mes (ya convierte fechas UTC a México)
|
||||
stats = self.get_estadisticas_vendedor_mes(vendedor_username, mes_busqueda)
|
||||
if not stats:
|
||||
logger.warning(f"No se pudieron obtener estadísticas para {vendedor_username}")
|
||||
return None
|
||||
|
||||
tubos_vendidos = stats.get('tubos_totales', 0)
|
||||
comision = stats.get('comision_total', 0)
|
||||
dias_activos = stats.get('dias_activos', 0)
|
||||
dias_cumplida = stats.get('dias_meta_cumplida', 0)
|
||||
|
||||
logger.info(f"Stats para metas: tubos={tubos_vendidos}, comision={comision}, dias={dias_activos}")
|
||||
|
||||
# Buscar registro existente del mes
|
||||
response = requests.get(
|
||||
f"{self.url}/api/v2/tables/{self.table_metas}/records",
|
||||
headers=self.headers,
|
||||
params={'limit': 1000},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
registros = response.json().get('list', [])
|
||||
|
||||
registro_existente = None
|
||||
for reg in registros:
|
||||
if reg.get('vendedor_username') == vendedor_username:
|
||||
fecha_reg = str(reg.get('mes', ''))[:7]
|
||||
if fecha_reg == mes_busqueda:
|
||||
registro_existente = reg
|
||||
break
|
||||
|
||||
payload = {
|
||||
'vendedor_username': vendedor_username,
|
||||
'mes': mes_fecha,
|
||||
'tubos_vendidos': tubos_vendidos,
|
||||
'comision_ganada': comision,
|
||||
'dias_activos': dias_activos,
|
||||
'dias_meta_cumplida': dias_cumplida
|
||||
}
|
||||
|
||||
if registro_existente:
|
||||
# Actualizar registro existente
|
||||
response = requests.patch(
|
||||
f"{self.url}/api/v2/tables/{self.table_metas}/records",
|
||||
headers=self.headers,
|
||||
json=[{"Id": registro_existente['Id'], **payload}],
|
||||
timeout=10
|
||||
)
|
||||
else:
|
||||
# Crear nuevo registro
|
||||
response = requests.post(
|
||||
f"{self.url}/api/v2/tables/{self.table_metas}/records",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
logger.info(f"Meta actualizada para {vendedor_username}: {tubos_vendidos} tubos, ${comision} comisión")
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error actualizando meta: {str(e)}", exc_info=True)
|
||||
return None
|
||||
|
||||
def get_meta_vendedor(self, vendedor_username, mes=None):
|
||||
"""Obtiene las estadísticas del vendedor para el mes"""
|
||||
return self.get_estadisticas_vendedor_mes(vendedor_username, mes)
|
||||
524
sales-bot/ocr_processor.py
Normal file
524
sales-bot/ocr_processor.py
Normal file
@@ -0,0 +1,524 @@
|
||||
import logging
|
||||
import re
|
||||
from PIL import Image, ImageEnhance, ImageFilter, ImageOps
|
||||
import pytesseract
|
||||
import io
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class OCRProcessor:
|
||||
"""
|
||||
OCR ultra-robusto para tickets tabulares
|
||||
Maneja múltiples errores comunes del OCR
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Inicializa el procesador OCR"""
|
||||
pytesseract.pytesseract.tesseract_cmd = '/usr/bin/tesseract'
|
||||
|
||||
self.configs = [
|
||||
'--psm 6 -l eng',
|
||||
'--psm 4 -l eng',
|
||||
'--psm 3 -l eng',
|
||||
]
|
||||
|
||||
def procesar_ticket(self, imagen_data):
|
||||
"""Procesa una imagen de ticket y extrae información completa"""
|
||||
try:
|
||||
imagen_pil = Image.open(io.BytesIO(imagen_data))
|
||||
|
||||
mejor_resultado = None
|
||||
max_productos = 0
|
||||
|
||||
for config in self.configs:
|
||||
try:
|
||||
imagen_procesada = self._preprocesar_imagen_para_tabla(imagen_pil.copy())
|
||||
texto = pytesseract.image_to_string(imagen_procesada, config=config)
|
||||
|
||||
logger.info(f"Texto extraído con config '{config}':\n{texto}")
|
||||
|
||||
productos = self._extraer_productos_tabla(texto)
|
||||
|
||||
if len(productos) > max_productos:
|
||||
max_productos = len(productos)
|
||||
mejor_resultado = {
|
||||
'texto_completo': texto,
|
||||
'config_usada': config,
|
||||
'productos': productos
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error con config '{config}': {str(e)}")
|
||||
continue
|
||||
|
||||
if mejor_resultado is None:
|
||||
imagen_procesada = self._preprocesar_imagen_para_tabla(imagen_pil)
|
||||
texto = pytesseract.image_to_string(imagen_procesada, config=self.configs[0])
|
||||
mejor_resultado = {
|
||||
'texto_completo': texto,
|
||||
'config_usada': self.configs[0],
|
||||
'productos': []
|
||||
}
|
||||
|
||||
texto = mejor_resultado['texto_completo']
|
||||
mejor_resultado.update({
|
||||
'monto_detectado': self._extraer_monto(texto),
|
||||
'fecha_detectada': self._extraer_fecha(texto),
|
||||
'subtotal': self._extraer_subtotal(texto),
|
||||
'iva': self._extraer_iva(texto),
|
||||
'tienda': self._extraer_tienda(texto),
|
||||
'folio': self._extraer_folio(texto)
|
||||
})
|
||||
|
||||
logger.info(f"Procesamiento completado: {len(mejor_resultado['productos'])} productos detectados")
|
||||
|
||||
return mejor_resultado
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando OCR: {str(e)}", exc_info=True)
|
||||
return None
|
||||
|
||||
def _preprocesar_imagen_para_tabla(self, imagen):
|
||||
"""Preprocesamiento optimizado para tickets tabulares"""
|
||||
try:
|
||||
if imagen.mode != 'L':
|
||||
imagen = imagen.convert('L')
|
||||
|
||||
width, height = imagen.size
|
||||
if width < 1500 or height < 1500:
|
||||
scale = max(1500 / width, 1500 / height)
|
||||
new_size = (int(width * scale), int(height * scale))
|
||||
imagen = imagen.resize(new_size, Image.Resampling.LANCZOS)
|
||||
|
||||
enhancer = ImageEnhance.Contrast(imagen)
|
||||
imagen = enhancer.enhance(3.0)
|
||||
|
||||
enhancer = ImageEnhance.Sharpness(imagen)
|
||||
imagen = enhancer.enhance(2.5)
|
||||
|
||||
imagen = ImageOps.autocontrast(imagen, cutoff=1)
|
||||
|
||||
threshold = 140
|
||||
imagen = imagen.point(lambda p: 255 if p > threshold else 0, mode='1')
|
||||
imagen = imagen.convert('L')
|
||||
|
||||
return imagen
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error en preprocesamiento: {str(e)}")
|
||||
if imagen.mode != 'L':
|
||||
imagen = imagen.convert('L')
|
||||
return imagen
|
||||
|
||||
def _normalizar_precio(self, precio_str):
|
||||
"""
|
||||
Normaliza precios: "$50 00" → 50.00, "1,210 00" → 1210.00
|
||||
"""
|
||||
try:
|
||||
precio_str = precio_str.replace('$', '').strip()
|
||||
precio_str = precio_str.replace(',', '')
|
||||
|
||||
if ' ' in precio_str:
|
||||
partes = precio_str.split()
|
||||
if len(partes) == 2:
|
||||
precio_str = partes[0] + '.' + partes[1]
|
||||
|
||||
return float(precio_str)
|
||||
except:
|
||||
return 0.0
|
||||
|
||||
def _extraer_productos_tabla(self, texto):
|
||||
"""
|
||||
Extrae productos con máxima robustez para errores de OCR
|
||||
"""
|
||||
productos = []
|
||||
|
||||
try:
|
||||
lineas = texto.split('\n')
|
||||
|
||||
# Patrones ultra-robustos
|
||||
patrones = [
|
||||
# Patrón 0: NUEVO - Cuando los precios tienen punto decimal correcto
|
||||
# "TINTE CROMATIQUE 6.7 4 $50.00 $200.00"
|
||||
r'^(.+?)\s+(\d{1,3})\s+\$?\s*([\d,]+\.\d{2})\s+\$?\s*([\d,]+\.\d{2})\s*$',
|
||||
|
||||
# Patrón 1: Nombre Cantidad PU Importe (con espacios en precios)
|
||||
# "TINTE CROMATIQUE 5.0 7 $50 00 $350 00"
|
||||
r'^(.+?)\s+(\d{1,3})\s+\$?\s*([\d,]+\s\d{2})\s+\$?\s*([\d,]+\s\d{2})\s*$',
|
||||
|
||||
# Patrón 2: Nombre+Número Cantidad PU Importe
|
||||
# "TINTE CROMATIQUE 67 4 $50 00 $200.00" (67 pegado)
|
||||
r'^(.+?(?:\d{1,2}[\.\s]?\d{0,2}))\s+(\d{1,3})\s+\$?\s*([\d,]+[\s\.]?\d{0,2})\s+\$?\s*([\d,]+[\s\.]?\d{0,2})\s*$',
|
||||
|
||||
# Patrón 3: Errores OCR "IL |" o "IL l"
|
||||
# "OXIDANTE 20 VOL IL | $120 00 $120 00"
|
||||
r'^(.+?)\s+(?:IL|Il|il|lL)\s+(?:\||I|l|\d)\s+\$?\s*([\d,]+[\s\.]?\d{0,2})\s+\$?\s*([\d,]+[\s\.]?\d{0,2})\s*$',
|
||||
]
|
||||
|
||||
palabras_ignorar = [
|
||||
'producto', 'cantidad', 'p.u', 'importe', 'precio',
|
||||
'total', 'subtotal', 'iva', 'fecha', 'folio', 'ticket',
|
||||
'gracias', 'cambio', 'efectivo', 'tarjeta', 'rfc',
|
||||
'direccion', 'tel', 'hora', 'vendedor', 'distribuidor'
|
||||
]
|
||||
|
||||
for idx, linea in enumerate(lineas):
|
||||
linea_original = linea
|
||||
linea = linea.strip()
|
||||
|
||||
if len(linea) < 5:
|
||||
continue
|
||||
|
||||
linea_lower = linea.lower()
|
||||
if any(palabra in linea_lower for palabra in palabras_ignorar):
|
||||
continue
|
||||
|
||||
if not any(char.isdigit() for char in linea):
|
||||
continue
|
||||
|
||||
# Intentar extraer con cada patrón
|
||||
for patron_idx, patron in enumerate(patrones):
|
||||
match = re.search(patron, linea, re.IGNORECASE)
|
||||
if match:
|
||||
try:
|
||||
grupos = match.groups()
|
||||
producto = self._parsear_producto_robusto(grupos, patron_idx, linea_original)
|
||||
|
||||
if producto and self._validar_producto(producto):
|
||||
# Evitar duplicados
|
||||
if not any(p['linea_original'] == producto['linea_original'] for p in productos):
|
||||
productos.append(producto)
|
||||
logger.info(
|
||||
f"✓ Producto {len(productos)} (patrón {patron_idx}): "
|
||||
f"{producto['cantidad']}x {producto['marca']} {producto['producto']} "
|
||||
f"@ ${producto['precio_unitario']} = ${producto['importe']}"
|
||||
)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error en línea '{linea}': {str(e)}")
|
||||
continue
|
||||
|
||||
logger.info(f"Total productos detectados: {len(productos)}")
|
||||
return productos
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extrayendo productos: {str(e)}", exc_info=True)
|
||||
return []
|
||||
|
||||
def _parsear_producto_robusto(self, grupos, patron_idx, linea_original):
|
||||
"""Parsea productos con máxima flexibilidad"""
|
||||
try:
|
||||
# Patrones 0, 1 y 2: formato normal con 4 campos
|
||||
if patron_idx in [0, 1, 2] and len(grupos) >= 4:
|
||||
nombre_completo = grupos[0].strip()
|
||||
cantidad = int(grupos[1])
|
||||
precio_unitario = self._normalizar_precio(grupos[2])
|
||||
importe = self._normalizar_precio(grupos[3])
|
||||
|
||||
# Validar matemática (tolerancia amplia)
|
||||
diferencia = abs((cantidad * precio_unitario) - importe)
|
||||
if diferencia > 5.0:
|
||||
logger.debug(f"Descartado por matemática: {cantidad} × ${precio_unitario} ≠ ${importe} (dif: ${diferencia})")
|
||||
return None
|
||||
|
||||
# Patrón 3: errores OCR "IL |"
|
||||
elif patron_idx == 3 and len(grupos) >= 3:
|
||||
nombre_completo = grupos[0].strip()
|
||||
cantidad = 1
|
||||
precio_unitario = self._normalizar_precio(grupos[1])
|
||||
importe = self._normalizar_precio(grupos[2])
|
||||
|
||||
# Para cantidad 1, los precios deben ser iguales
|
||||
if abs(precio_unitario - importe) > 2.0:
|
||||
return None
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
# Validaciones básicas
|
||||
if not nombre_completo or len(nombre_completo) < 3:
|
||||
return None
|
||||
|
||||
if cantidad <= 0 or cantidad > 999:
|
||||
return None
|
||||
|
||||
if precio_unitario <= 0 or importe <= 0:
|
||||
return None
|
||||
|
||||
if precio_unitario > 100000 or importe > 1000000:
|
||||
return None
|
||||
|
||||
# Filtrar nombres que son solo números
|
||||
if re.match(r'^[\d\s\$\.\,\-]+$', nombre_completo):
|
||||
return None
|
||||
|
||||
# Separar marca y producto
|
||||
marca, producto = self._separar_marca_producto(nombre_completo)
|
||||
|
||||
return {
|
||||
'producto': producto,
|
||||
'marca': marca,
|
||||
'cantidad': cantidad,
|
||||
'precio_unitario': round(precio_unitario, 2),
|
||||
'importe': round(importe, 2),
|
||||
'linea_original': linea_original.strip()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parseando: {str(e)}")
|
||||
return None
|
||||
|
||||
def _validar_producto(self, producto):
|
||||
"""Validación de producto"""
|
||||
try:
|
||||
if not all(key in producto for key in ['producto', 'marca', 'cantidad', 'importe']):
|
||||
return False
|
||||
|
||||
if producto['cantidad'] <= 0 or producto['cantidad'] > 999:
|
||||
return False
|
||||
|
||||
if producto['importe'] <= 0 or producto['importe'] > 1000000:
|
||||
return False
|
||||
|
||||
if producto['precio_unitario'] <= 0 or producto['precio_unitario'] > 100000:
|
||||
return False
|
||||
|
||||
nombre_limpio = producto['producto'].strip()
|
||||
if len(nombre_limpio) < 2:
|
||||
return False
|
||||
|
||||
if not any(c.isalpha() for c in nombre_limpio):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error validando: {str(e)}")
|
||||
return False
|
||||
|
||||
def _separar_marca_producto(self, nombre_completo):
|
||||
"""Separa marca de producto"""
|
||||
try:
|
||||
nombre_completo = nombre_completo.strip()
|
||||
|
||||
# Marcas de productos de belleza
|
||||
marcas_belleza = [
|
||||
'tinte cromatique', 'cromatique', 'oxidante', 'decolorante',
|
||||
'trat. capilar', 'trat capilar', 'tratamiento', 'prat. capilar',
|
||||
'shampoo', 'acondicionador', 'mascarilla', 'ampolleta',
|
||||
'serum', 'keratina', 'botox', 'alisado'
|
||||
]
|
||||
|
||||
nombre_lower = nombre_completo.lower().strip()
|
||||
|
||||
# Buscar marcas conocidas
|
||||
for marca in marcas_belleza:
|
||||
if marca in nombre_lower:
|
||||
inicio = nombre_lower.index(marca)
|
||||
fin = inicio + len(marca)
|
||||
marca_encontrada = nombre_completo[inicio:fin].strip()
|
||||
producto = (nombre_completo[:inicio] + nombre_completo[fin:]).strip()
|
||||
|
||||
# Si el producto es solo un código numérico, usar nombre completo como producto
|
||||
if not producto or len(producto) < 2:
|
||||
return 'Genérico', nombre_completo
|
||||
|
||||
# Si el producto es solo números/punto (ej: "6.7"), usar nombre completo como producto
|
||||
if re.match(r'^[\d\.]+$', producto):
|
||||
return 'Genérico', nombre_completo
|
||||
|
||||
return marca_encontrada, producto
|
||||
|
||||
# Buscar patrones de código/número al final
|
||||
patron_codigo = r'(.*?)\s+([\d\.]+)\s*$'
|
||||
match = re.search(patron_codigo, nombre_completo)
|
||||
if match:
|
||||
# Para productos con código al final, usar todo como producto
|
||||
# Ej: "TINTE CROMATIQUE 6.7" → marca: "Producto", producto: "TINTE CROMATIQUE 6.7"
|
||||
return 'Producto', nombre_completo
|
||||
|
||||
# Heurística: primeras palabras como marca
|
||||
palabras = nombre_completo.split()
|
||||
if len(palabras) >= 3:
|
||||
marca = ' '.join(palabras[:-1])
|
||||
producto = palabras[-1]
|
||||
|
||||
# Si la última palabra es solo números, usar nombre completo
|
||||
if re.match(r'^[\d\.]+$', producto):
|
||||
return 'Producto', nombre_completo
|
||||
|
||||
return marca, producto
|
||||
elif len(palabras) == 2:
|
||||
return palabras[0], palabras[1]
|
||||
else:
|
||||
return 'Producto', nombre_completo
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error separando marca/producto: {str(e)}")
|
||||
return 'Producto', nombre_completo
|
||||
|
||||
def _extraer_tienda(self, texto):
|
||||
"""Extrae nombre de tienda"""
|
||||
try:
|
||||
lineas = texto.split('\n')[:3]
|
||||
|
||||
for linea in lineas:
|
||||
if re.search(r'S\.A\.|S\.A\. DE C\.V\.|DISTRIBUIDORA', linea, re.IGNORECASE):
|
||||
tienda = linea.strip()
|
||||
logger.info(f"Tienda: {tienda}")
|
||||
return tienda
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error extrayendo tienda: {str(e)}")
|
||||
return None
|
||||
|
||||
def _extraer_folio(self, texto):
|
||||
"""Extrae folio"""
|
||||
try:
|
||||
patrones = [
|
||||
r'ticket[:\s]*(\w+)',
|
||||
r'folio[:\s]*(\w+)',
|
||||
r'no\.\s*(\w+)',
|
||||
]
|
||||
|
||||
for patron in patrones:
|
||||
match = re.search(patron, texto, re.IGNORECASE)
|
||||
if match:
|
||||
folio = match.group(1)
|
||||
logger.info(f"Folio: {folio}")
|
||||
return folio
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error extrayendo folio: {str(e)}")
|
||||
return None
|
||||
|
||||
def _extraer_monto(self, texto):
|
||||
"""Extrae monto total"""
|
||||
try:
|
||||
patron_total = r'total\s*\$?\s*([\d,]+[\s\.]?\d{0,2})'
|
||||
matches = re.finditer(patron_total, texto, re.IGNORECASE)
|
||||
|
||||
montos = []
|
||||
for match in matches:
|
||||
try:
|
||||
monto = self._normalizar_precio(match.group(1))
|
||||
if 1 <= monto <= 1000000:
|
||||
montos.append(monto)
|
||||
except:
|
||||
continue
|
||||
|
||||
if montos:
|
||||
monto_final = montos[-1]
|
||||
logger.info(f"Total: ${monto_final:.2f}")
|
||||
return monto_final
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error extrayendo monto: {str(e)}")
|
||||
return None
|
||||
|
||||
def _extraer_subtotal(self, texto):
|
||||
"""Extrae subtotal"""
|
||||
try:
|
||||
patron = r'subtotal\s*\$?\s*([\d,]+[\s\.]?\d{0,2})'
|
||||
match = re.search(patron, texto, re.IGNORECASE)
|
||||
if match:
|
||||
subtotal = self._normalizar_precio(match.group(1))
|
||||
logger.info(f"Subtotal: ${subtotal:.2f}")
|
||||
return subtotal
|
||||
return None
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def _extraer_iva(self, texto):
|
||||
"""Extrae IVA"""
|
||||
try:
|
||||
patron = r'iva\s*\$?\s*([\d,]+[\s\.]?\d{0,2})'
|
||||
match = re.search(patron, texto, re.IGNORECASE)
|
||||
if match:
|
||||
iva = self._normalizar_precio(match.group(1))
|
||||
logger.info(f"IVA: ${iva:.2f}")
|
||||
return iva
|
||||
return None
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def _extraer_fecha(self, texto):
|
||||
"""Extrae fecha"""
|
||||
try:
|
||||
patrones = [
|
||||
r'fecha[:\s]*(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})',
|
||||
r'(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})',
|
||||
]
|
||||
|
||||
for patron in patrones:
|
||||
match = re.search(patron, texto, re.IGNORECASE)
|
||||
if match:
|
||||
fecha = match.group(1)
|
||||
logger.info(f"Fecha: {fecha}")
|
||||
return fecha
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def validar_monto_con_ocr(self, monto_usuario, monto_ocr, tolerancia=0.1):
|
||||
"""Valida monto vs OCR"""
|
||||
if monto_ocr is None:
|
||||
return True, "No se pudo detectar monto en la imagen"
|
||||
|
||||
diferencia = abs(monto_usuario - monto_ocr)
|
||||
porcentaje_diferencia = (diferencia / monto_ocr) * 100 if monto_ocr > 0 else 0
|
||||
|
||||
if porcentaje_diferencia <= (tolerancia * 100):
|
||||
return True, f"✓ Monto verificado (OCR: ${monto_ocr:,.2f})"
|
||||
else:
|
||||
return False, f"⚠️ Discrepancia: Usuario ${monto_usuario:,.2f} vs Ticket ${monto_ocr:,.2f}"
|
||||
|
||||
def generar_reporte_deteccion(self, resultado_ocr):
|
||||
"""Genera reporte legible"""
|
||||
if not resultado_ocr:
|
||||
return "No se pudo procesar el ticket"
|
||||
|
||||
reporte = []
|
||||
reporte.append("=" * 50)
|
||||
reporte.append("REPORTE DE DETECCIÓN OCR")
|
||||
reporte.append("=" * 50)
|
||||
|
||||
if resultado_ocr.get('tienda'):
|
||||
reporte.append(f"\n🏪 Tienda: {resultado_ocr['tienda']}")
|
||||
|
||||
if resultado_ocr.get('folio'):
|
||||
reporte.append(f"📋 Folio: {resultado_ocr['folio']}")
|
||||
|
||||
if resultado_ocr.get('fecha_detectada'):
|
||||
reporte.append(f"📅 Fecha: {resultado_ocr['fecha_detectada']}")
|
||||
|
||||
productos = resultado_ocr.get('productos', [])
|
||||
if productos:
|
||||
reporte.append(f"\n📦 PRODUCTOS DETECTADOS ({len(productos)}):")
|
||||
reporte.append("-" * 50)
|
||||
|
||||
for i, prod in enumerate(productos, 1):
|
||||
reporte.append(
|
||||
f"{i}. {prod['cantidad']}x {prod['marca']} {prod['producto']}"
|
||||
)
|
||||
reporte.append(f" Precio unitario: ${prod['precio_unitario']:.2f}")
|
||||
reporte.append(f" Importe: ${prod['importe']:.2f}")
|
||||
reporte.append("")
|
||||
|
||||
if resultado_ocr.get('subtotal'):
|
||||
reporte.append(f"Subtotal: ${resultado_ocr['subtotal']:.2f}")
|
||||
|
||||
if resultado_ocr.get('iva'):
|
||||
reporte.append(f"IVA: ${resultado_ocr['iva']:.2f}")
|
||||
|
||||
if resultado_ocr.get('monto_detectado'):
|
||||
reporte.append(f"\n💰 TOTAL: ${resultado_ocr['monto_detectado']:.2f}")
|
||||
|
||||
reporte.append("=" * 50)
|
||||
|
||||
return "\n".join(reporte)
|
||||
28
sales-bot/requirements.txt
Normal file
28
sales-bot/requirements.txt
Normal file
@@ -0,0 +1,28 @@
|
||||
# Dependencias para el OCR mejorado
|
||||
|
||||
# OCR
|
||||
pytesseract==0.3.10
|
||||
|
||||
# Procesamiento de imágenes
|
||||
Pillow==10.2.0
|
||||
opencv-python==4.9.0.80
|
||||
numpy==1.26.3
|
||||
|
||||
# Web framework (si usas Flask)
|
||||
Flask==3.0.0
|
||||
gunicorn==21.2.0
|
||||
|
||||
# Cliente de Mattermost
|
||||
mattermostdriver==7.3.2
|
||||
|
||||
# HTTP requests
|
||||
requests==2.31.0
|
||||
|
||||
# Variables de entorno
|
||||
python-dotenv==1.0.0
|
||||
|
||||
# Logging
|
||||
coloredlogs==15.0.1
|
||||
|
||||
# Utilidades
|
||||
python-dateutil==2.8.2
|
||||
166
sales-bot/utils.py
Normal file
166
sales-bot/utils.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Utilidades para el Sales Bot
|
||||
"""
|
||||
import re
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validar_token_outgoing(token):
|
||||
"""
|
||||
Valida el token de webhooks salientes de Mattermost
|
||||
"""
|
||||
if not token:
|
||||
return False
|
||||
|
||||
expected_tokens = [
|
||||
os.getenv('MATTERMOST_WEBHOOK_SECRET'),
|
||||
os.getenv('MATTERMOST_OUTGOING_TOKEN'),
|
||||
]
|
||||
|
||||
return token in [t for t in expected_tokens if t]
|
||||
|
||||
|
||||
def extraer_monto(texto):
|
||||
"""
|
||||
Extrae el monto de una venta del texto del mensaje.
|
||||
Soporta formatos:
|
||||
- @monto 1500
|
||||
- $1,500.00
|
||||
- $1500
|
||||
- 1500 pesos
|
||||
"""
|
||||
if not texto:
|
||||
return None
|
||||
|
||||
texto = texto.lower()
|
||||
|
||||
# Buscar formato @monto XXXX
|
||||
patron_monto = r'@monto\s+\$?([\d,]+\.?\d*)'
|
||||
match = re.search(patron_monto, texto)
|
||||
if match:
|
||||
monto_str = match.group(1).replace(',', '')
|
||||
try:
|
||||
return float(monto_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Buscar formato $X,XXX.XX o $XXXX
|
||||
patron_dinero = r'\$\s*([\d,]+\.?\d*)'
|
||||
match = re.search(patron_dinero, texto)
|
||||
if match:
|
||||
monto_str = match.group(1).replace(',', '')
|
||||
try:
|
||||
return float(monto_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Buscar formato XXXX pesos
|
||||
patron_pesos = r'([\d,]+\.?\d*)\s*pesos'
|
||||
match = re.search(patron_pesos, texto)
|
||||
if match:
|
||||
monto_str = match.group(1).replace(',', '')
|
||||
try:
|
||||
return float(monto_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extraer_cliente(texto):
|
||||
"""
|
||||
Extrae el nombre del cliente del texto del mensaje.
|
||||
Soporta formatos:
|
||||
- @cliente Juan Pérez
|
||||
- cliente: Juan Pérez
|
||||
- a Juan Pérez
|
||||
"""
|
||||
if not texto:
|
||||
return None
|
||||
|
||||
# Buscar formato @cliente NOMBRE
|
||||
patron_cliente = r'@cliente\s+([^\n@$]+)'
|
||||
match = re.search(patron_cliente, texto, re.IGNORECASE)
|
||||
if match:
|
||||
cliente = match.group(1).strip()
|
||||
# Limpiar palabras clave que no son parte del nombre
|
||||
cliente = re.sub(r'\s*@monto.*$', '', cliente, flags=re.IGNORECASE)
|
||||
return cliente.strip() if cliente.strip() else None
|
||||
|
||||
# Buscar formato "cliente: NOMBRE" o "cliente NOMBRE"
|
||||
patron_cliente2 = r'cliente[:\s]+([^\n@$]+)'
|
||||
match = re.search(patron_cliente2, texto, re.IGNORECASE)
|
||||
if match:
|
||||
cliente = match.group(1).strip()
|
||||
cliente = re.sub(r'\s*@monto.*$', '', cliente, flags=re.IGNORECASE)
|
||||
return cliente.strip() if cliente.strip() else None
|
||||
|
||||
# Buscar formato "a NOMBRE" (después de un monto)
|
||||
patron_a = r'\$[\d,\.]+\s+a\s+([^\n@$]+)'
|
||||
match = re.search(patron_a, texto, re.IGNORECASE)
|
||||
if match:
|
||||
cliente = match.group(1).strip()
|
||||
return cliente.strip() if cliente.strip() else None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def formatear_moneda(valor, simbolo='$', decimales=2):
|
||||
"""
|
||||
Formatea un número como moneda.
|
||||
Ejemplo: 1500.5 -> $1,500.50
|
||||
"""
|
||||
if valor is None:
|
||||
return f"{simbolo}0.00"
|
||||
|
||||
try:
|
||||
valor = float(valor)
|
||||
return f"{simbolo}{valor:,.{decimales}f}"
|
||||
except (ValueError, TypeError):
|
||||
return f"{simbolo}0.00"
|
||||
|
||||
|
||||
def extraer_tubos(texto):
|
||||
"""
|
||||
Extrae la cantidad de tubos del texto del mensaje.
|
||||
Soporta formatos:
|
||||
- @tubos 5
|
||||
- tubos: 5
|
||||
- 5 tubos
|
||||
"""
|
||||
if not texto:
|
||||
return None
|
||||
|
||||
texto = texto.lower()
|
||||
|
||||
# Buscar formato @tubos XXXX
|
||||
patron_tubos = r'@tubos\s+(\d+)'
|
||||
match = re.search(patron_tubos, texto)
|
||||
if match:
|
||||
try:
|
||||
return int(match.group(1))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Buscar formato "tubos: X" o "tubos X"
|
||||
patron_tubos2 = r'tubos[:\s]+(\d+)'
|
||||
match = re.search(patron_tubos2, texto)
|
||||
if match:
|
||||
try:
|
||||
return int(match.group(1))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Buscar formato "X tubos"
|
||||
patron_tubos3 = r'(\d+)\s*tubos?'
|
||||
match = re.search(patron_tubos3, texto)
|
||||
if match:
|
||||
try:
|
||||
return int(match.group(1))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None
|
||||
175
sales-bot/websocket_listener.py
Normal file
175
sales-bot/websocket_listener.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Listener de Websocket para Mattermost
|
||||
Escucha mensajes en tiempo real sin depender de outgoing webhooks
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import websockets
|
||||
import requests
|
||||
from threading import Thread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MattermostWebsocketListener:
|
||||
def __init__(self, mattermost_client, nocodb_client, handler_func):
|
||||
self.url = os.getenv('MATTERMOST_URL', '').rstrip('/')
|
||||
self.token = os.getenv('MATTERMOST_BOT_TOKEN')
|
||||
self.mattermost = mattermost_client
|
||||
self.nocodb = nocodb_client
|
||||
self.handler_func = handler_func
|
||||
self.ws_url = self.url.replace('http://', 'ws://').replace('https://', 'wss://') + '/api/v4/websocket'
|
||||
self.bot_user_id = None
|
||||
self.running = False
|
||||
|
||||
def get_bot_user_id(self):
|
||||
"""Obtiene el ID del bot para ignorar sus propios mensajes"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.url}/api/v4/users/me",
|
||||
headers={'Authorization': f'Bearer {self.token}'},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
self.bot_user_id = response.json().get('id')
|
||||
logger.info(f"Bot user ID: {self.bot_user_id}")
|
||||
return self.bot_user_id
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo bot user ID: {e}")
|
||||
return None
|
||||
|
||||
async def listen(self):
|
||||
"""Escucha mensajes via websocket"""
|
||||
self.get_bot_user_id()
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
logger.info(f"Conectando a websocket: {self.ws_url}")
|
||||
async with websockets.connect(self.ws_url) as ws:
|
||||
# Autenticar
|
||||
auth_msg = json.dumps({
|
||||
"seq": 1,
|
||||
"action": "authentication_challenge",
|
||||
"data": {"token": self.token}
|
||||
})
|
||||
await ws.send(auth_msg)
|
||||
logger.info("Autenticación enviada al websocket")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
message = await asyncio.wait_for(ws.recv(), timeout=30)
|
||||
await self.process_message(message)
|
||||
except asyncio.TimeoutError:
|
||||
# Enviar ping para mantener conexión
|
||||
await ws.ping()
|
||||
except websockets.ConnectionClosed:
|
||||
logger.warning("Conexión websocket cerrada, reconectando...")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error en websocket: {e}")
|
||||
if self.running:
|
||||
logger.info("Reconectando en 5 segundos...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def process_message(self, raw_message):
|
||||
"""Procesa un mensaje recibido del websocket"""
|
||||
try:
|
||||
data = json.loads(raw_message)
|
||||
event = data.get('event')
|
||||
|
||||
# Solo procesar mensajes nuevos (posted)
|
||||
if event != 'posted':
|
||||
return
|
||||
|
||||
post_data = data.get('data', {})
|
||||
post_str = post_data.get('post', '{}')
|
||||
post = json.loads(post_str)
|
||||
|
||||
user_id = post.get('user_id')
|
||||
message = post.get('message', '')
|
||||
channel_id = post.get('channel_id')
|
||||
file_ids = post.get('file_ids', [])
|
||||
|
||||
# Ignorar mensajes del propio bot
|
||||
if user_id == self.bot_user_id:
|
||||
return
|
||||
|
||||
# Ignorar mensajes que son respuestas del bot (contienen emojis de confirmación)
|
||||
if '✅' in message or '**Venta registrada**' in message or 'Resumen del día' in message:
|
||||
return
|
||||
|
||||
# Verificar si es un mensaje de venta (debe empezar con palabra clave)
|
||||
message_lower = message.lower().strip()
|
||||
palabras_clave = ['venta', 'vendi', 'vendí']
|
||||
es_venta = any(message_lower.startswith(palabra) for palabra in palabras_clave)
|
||||
|
||||
if not es_venta and not file_ids:
|
||||
return
|
||||
|
||||
logger.info(f"Mensaje de venta detectado: {message}")
|
||||
|
||||
# Obtener información del usuario
|
||||
try:
|
||||
user_response = requests.get(
|
||||
f"{self.url}/api/v4/users/{user_id}",
|
||||
headers={'Authorization': f'Bearer {self.token}'},
|
||||
timeout=10
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
user = user_response.json()
|
||||
username = user.get('username', 'unknown')
|
||||
except:
|
||||
username = 'unknown'
|
||||
|
||||
# Obtener nombre del canal
|
||||
try:
|
||||
channel_response = requests.get(
|
||||
f"{self.url}/api/v4/channels/{channel_id}",
|
||||
headers={'Authorization': f'Bearer {self.token}'},
|
||||
timeout=10
|
||||
)
|
||||
channel_response.raise_for_status()
|
||||
channel = channel_response.json()
|
||||
channel_name = channel.get('name', 'unknown')
|
||||
except:
|
||||
channel_name = 'unknown'
|
||||
|
||||
# Construir data similar al webhook
|
||||
webhook_data = {
|
||||
'user_name': username,
|
||||
'user_id': user_id,
|
||||
'text': message,
|
||||
'channel_id': channel_id,
|
||||
'channel_name': channel_name,
|
||||
'post_id': post.get('id'),
|
||||
'file_ids': file_ids
|
||||
}
|
||||
|
||||
# Llamar al handler (el handler ya publica via webhook)
|
||||
self.handler_func(webhook_data, self.mattermost, self.nocodb)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando mensaje websocket: {e}")
|
||||
|
||||
def start(self):
|
||||
"""Inicia el listener en un thread separado"""
|
||||
self.running = True
|
||||
|
||||
def run_loop():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(self.listen())
|
||||
|
||||
thread = Thread(target=run_loop, daemon=True)
|
||||
thread.start()
|
||||
logger.info("Websocket listener iniciado")
|
||||
return thread
|
||||
|
||||
def stop(self):
|
||||
"""Detiene el listener"""
|
||||
self.running = False
|
||||
logger.info("Websocket listener detenido")
|
||||
Reference in New Issue
Block a user