From 5d9cbd4812757e184dff55321d5d8fc874ece40c Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sun, 18 Jan 2026 02:41:53 +0000 Subject: [PATCH] =?UTF-8?q?Commit=20inicial:=20Sales=20Bot=20-=20Sistema?= =?UTF-8?q?=20de=20Automatizaci=C3=B3n=20de=20Ventas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 57 +++ README.md | 219 ++++++++++ docs/API.md | 329 +++++++++++++++ docs/ARQUITECTURA.md | 208 +++++++++ docs/INSTALACION.md | 296 +++++++++++++ mattermost/README.md | 81 ++++ mattermost/compose.yaml | 53 +++ nocodb/README.md | 130 ++++++ nocodb/compose.yaml | 47 +++ sales-bot/.env.example | 47 +++ sales-bot/Dockerfile | 69 +++ sales-bot/README.md | 199 +++++++++ sales-bot/app.py | 724 ++++++++++++++++++++++++++++++++ sales-bot/compose.yaml | 85 ++++ sales-bot/handlers.py | 376 +++++++++++++++++ sales-bot/mattermost_client.py | 201 +++++++++ sales-bot/nocodb_client.py | 611 +++++++++++++++++++++++++++ sales-bot/ocr_processor.py | 524 +++++++++++++++++++++++ sales-bot/requirements.txt | 28 ++ sales-bot/utils.py | 166 ++++++++ sales-bot/websocket_listener.py | 175 ++++++++ 21 files changed, 4625 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/API.md create mode 100644 docs/ARQUITECTURA.md create mode 100644 docs/INSTALACION.md create mode 100644 mattermost/README.md create mode 100644 mattermost/compose.yaml create mode 100644 nocodb/README.md create mode 100644 nocodb/compose.yaml create mode 100644 sales-bot/.env.example create mode 100644 sales-bot/Dockerfile create mode 100644 sales-bot/README.md create mode 100644 sales-bot/app.py create mode 100644 sales-bot/compose.yaml create mode 100644 sales-bot/handlers.py create mode 100644 sales-bot/mattermost_client.py create mode 100644 sales-bot/nocodb_client.py create mode 100644 sales-bot/ocr_processor.py create mode 100644 sales-bot/requirements.txt create mode 100644 sales-bot/utils.py create mode 100644 sales-bot/websocket_listener.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c4dda9 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1c6b61 --- /dev/null +++ b/README.md @@ -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= +MATTERMOST_TEAM_NAME=sales +MATTERMOST_WEBHOOK_SECRET= +MATTERMOST_WEBHOOK_URL=http://192.168.10.204:8065/hooks/ + +# NocoDB +NOCODB_URL=http://192.168.10.204:8080 +NOCODB_TOKEN= +NOCODB_TABLE_VENDEDORES= +NOCODB_TABLE_VENTAS= +NOCODB_TABLE_VENTAS_DETALLE= +NOCODB_TABLE_METAS= + +# 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 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..a77f69f --- /dev/null +++ b/docs/API.md @@ -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: +``` + +--- + +### Listar Vendedores + +```http +GET /tables/{TABLE_ID}/records +xc-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: +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: +``` + +--- + +### Obtener Ranking + +```http +GET /tables/{TABLE_ID}/records?sort=-monto&limit=10 +xc-token: +``` + +--- + +## Mattermost API + +### Base URL +``` +http://192.168.10.204:8065/api/v4 +``` + +### Autenticación +```http +Authorization: Bearer +``` + +--- + +### Enviar Mensaje + +```http +POST /posts +Authorization: Bearer +Content-Type: application/json +``` + +**Body:** +```json +{ + "channel_id": "abc123", + "message": "Mensaje de texto" +} +``` + +--- + +### Agregar Reacción + +```http +POST /reactions +Authorization: Bearer +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 +``` + +--- + +## 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 diff --git a/docs/ARQUITECTURA.md b/docs/ARQUITECTURA.md new file mode 100644 index 0000000..cebd9ba --- /dev/null +++ b/docs/ARQUITECTURA.md @@ -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 diff --git a/docs/INSTALACION.md b/docs/INSTALACION.md new file mode 100644 index 0000000..d57790d --- /dev/null +++ b/docs/INSTALACION.md @@ -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//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://:8065 +MATTERMOST_BOT_TOKEN= +MATTERMOST_TEAM_NAME=sales +MATTERMOST_WEBHOOK_SECRET= +MATTERMOST_WEBHOOK_URL=http://:8065/hooks/ + +# NocoDB +NOCODB_URL=http://:8080 +NOCODB_TOKEN= +NOCODB_TABLE_VENDEDORES= +NOCODB_TABLE_VENTAS= +NOCODB_TABLE_VENTAS_DETALLE= +NOCODB_TABLE_METAS= + +# 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://: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://:5000/webhook/mattermost` + - Copiar el token + +5. Ir a **Integraciones > Slash Commands** +6. Crear comandos: + - `/metas` → `http://:5000/comando/metas` + - `/ranking` → `http://:5000/comando/ranking` + +### 5. Iniciar NocoDB + +```bash +cd ../nocodb +docker compose up -d +``` + +Acceder a http://: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/` + +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 +``` diff --git a/mattermost/README.md b/mattermost/README.md new file mode 100644 index 0000000..63dd068 --- /dev/null +++ b/mattermost/README.md @@ -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 +``` diff --git a/mattermost/compose.yaml b/mattermost/compose.yaml new file mode 100644 index 0000000..c88ac2f --- /dev/null +++ b/mattermost/compose.yaml @@ -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 diff --git a/nocodb/README.md b/nocodb/README.md new file mode 100644 index 0000000..e0d0896 --- /dev/null +++ b/nocodb/README.md @@ -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: " http://192.168.10.204:8080/api/v2/... +``` + +### Listar registros +```bash +curl -H "xc-token: " \ + "http://192.168.10.204:8080/api/v2/tables//records" +``` + +### Crear registro +```bash +curl -X POST -H "xc-token: " \ + -H "Content-Type: application/json" \ + -d '{"campo": "valor"}' \ + "http://192.168.10.204:8080/api/v2/tables//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 +``` diff --git a/nocodb/compose.yaml b/nocodb/compose.yaml new file mode 100644 index 0000000..edbefdc --- /dev/null +++ b/nocodb/compose.yaml @@ -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 diff --git a/sales-bot/.env.example b/sales-bot/.env.example new file mode 100644 index 0000000..7b81051 --- /dev/null +++ b/sales-bot/.env.example @@ -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 diff --git a/sales-bot/Dockerfile b/sales-bot/Dockerfile new file mode 100644 index 0000000..902d3bc --- /dev/null +++ b/sales-bot/Dockerfile @@ -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"] diff --git a/sales-bot/README.md b/sales-bot/README.md new file mode 100644 index 0000000..587f893 --- /dev/null +++ b/sales-bot/README.md @@ -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" +} +``` diff --git a/sales-bot/app.py b/sales-bot/app.py new file mode 100644 index 0000000..926a379 --- /dev/null +++ b/sales-bot/app.py @@ -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 = ''' + + + + + + Sales Bot - Dashboard + + + +
+
+
+

Sales Bot Dashboard

+

+
+ +
+ +
+
+
Ventas Hoy
+
-
+
$0.00
+
+
+
Ventas del Mes
+
-
+
$0.00
+
+
+
Vendedores Activos Hoy
+
-
+
+
+
Meta Diaria
+
3
+
tubos por vendedor
+
+
+ +
+
+

🏆 Ranking del Mes (Tubos)

+
    +
  • Cargando...
  • +
+
+ +
+

📋 Ventas Recientes

+
+
Cargando...
+
+
+
+
+ + + + +''' + 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) diff --git a/sales-bot/compose.yaml b/sales-bot/compose.yaml new file mode 100644 index 0000000..912ff25 --- /dev/null +++ b/sales-bot/compose.yaml @@ -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: diff --git a/sales-bot/handlers.py b/sales-bot/handlers.py new file mode 100644 index 0000000..e62a8e2 --- /dev/null +++ b/sales-bot/handlers.py @@ -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" diff --git a/sales-bot/mattermost_client.py b/sales-bot/mattermost_client.py new file mode 100644 index 0000000..ccea85e --- /dev/null +++ b/sales-bot/mattermost_client.py @@ -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 diff --git a/sales-bot/nocodb_client.py b/sales-bot/nocodb_client.py new file mode 100644 index 0000000..5fd861a --- /dev/null +++ b/sales-bot/nocodb_client.py @@ -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) diff --git a/sales-bot/ocr_processor.py b/sales-bot/ocr_processor.py new file mode 100644 index 0000000..e2be830 --- /dev/null +++ b/sales-bot/ocr_processor.py @@ -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) diff --git a/sales-bot/requirements.txt b/sales-bot/requirements.txt new file mode 100644 index 0000000..67070a8 --- /dev/null +++ b/sales-bot/requirements.txt @@ -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 diff --git a/sales-bot/utils.py b/sales-bot/utils.py new file mode 100644 index 0000000..d39bdbd --- /dev/null +++ b/sales-bot/utils.py @@ -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 diff --git a/sales-bot/websocket_listener.py b/sales-bot/websocket_listener.py new file mode 100644 index 0000000..4cd8aff --- /dev/null +++ b/sales-bot/websocket_listener.py @@ -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")