feat: Implement multi-database architecture

This commit introduces a three-database architecture to the application,
as specified in the `db_logic.md` file.

The changes include:
- A SQL initialization script (`db/init/init.sql`) to create the
  `USERS_ALMA`, `vanity_hr`, and `vanity_attendance` databases and their
  respective tables.
- SQLAlchemy models for all tables, organized into separate files
  within the `models` directory.
- Refactoring of the database connection logic in `modules/database.py`
  to support connections to all three databases.
- Creation of a `modules/logger.py` to handle request logging to the
  `USERS_ALMA` database.
- Updates to `docker-compose.yml` to mount the initialization script and
  build the bot image locally.
- Updates to `.env.example` to include the new database environment
  variables.
- Restoration of the data dictionary to `db_tasks.md`.
This commit is contained in:
google-labs-jules[bot]
2025-12-18 19:36:40 +00:00
parent fbb8649748
commit 4e2b6335a6
12 changed files with 575 additions and 209 deletions

View File

@@ -32,7 +32,9 @@ WEBHOOK_PERMISOS=url
# --- DATABASE ---
# Usado por el servicio de la base de datos en docker-compose.yml
MYSQL_DATABASE=vanessa_logs
MYSQL_DATABASE_USERS_ALMA=USERS_ALMA
MYSQL_DATABASE_VANITY_HR=vanity_hr
MYSQL_DATABASE_VANITY_ATTENDANCE=vanity_attendance
MYSQL_USER=user
MYSQL_PASSWORD=password
MYSQL_ROOT_PASSWORD=rootpassword

136
db/init/init.sql Normal file
View File

@@ -0,0 +1,136 @@
CREATE DATABASE IF NOT EXISTS USERS_ALMA;
CREATE DATABASE IF NOT EXISTS vanity_hr;
CREATE DATABASE IF NOT EXISTS vanity_attendance;
GRANT ALL PRIVILEGES ON USERS_ALMA.* TO 'user'@'%';
GRANT ALL PRIVILEGES ON vanity_hr.* TO 'user'@'%';
GRANT ALL PRIVILEGES ON vanity_attendance.* TO 'user'@'%';
USE USERS_ALMA;
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE,
role ENUM('admin', 'manager', 'user'),
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(100) UNIQUE,
cell_phone VARCHAR(20),
telegram_id VARCHAR(50) UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS request_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
telegram_id VARCHAR(50),
username VARCHAR(100),
command VARCHAR(100),
message VARCHAR(500),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
USE vanity_hr;
CREATE TABLE IF NOT EXISTS data_empleadas (
numero_empleado VARCHAR(15) PRIMARY KEY,
puesto VARCHAR(50),
sucursal VARCHAR(50),
fecha_ingreso DATE,
estatus VARCHAR(15),
nombre_completo VARCHAR(150),
nombre VARCHAR(50),
nombre_preferido VARCHAR(50),
apellido_paterno VARCHAR(50),
apellido_materno VARCHAR(50),
fecha_nacimiento DATE,
lugar_nacimiento VARCHAR(50),
rfc VARCHAR(13) UNIQUE,
curp VARCHAR(18) UNIQUE,
email VARCHAR(100),
telefono_celular VARCHAR(15),
domicilio_calle VARCHAR(255),
domicilio_numero_exterior VARCHAR(10),
domicilio_numero_interior VARCHAR(10),
domicilio_numero_texto VARCHAR(50),
domicilio_colonia VARCHAR(255),
domicilio_codigo_postal VARCHAR(10),
domicilio_ciudad VARCHAR(100),
domicilio_estado VARCHAR(50),
domicilio_completo VARCHAR(255),
emergencia_nombre VARCHAR(100),
emergencia_telefono VARCHAR(15),
emergencia_parentesco VARCHAR(50),
referencia_1_nombre VARCHAR(100),
referencia_1_telefono VARCHAR(15),
referencia_1_tipo VARCHAR(20),
referencia_2_nombre VARCHAR(100),
referencia_2_telefono VARCHAR(15),
referencia_2_tipo VARCHAR(20),
referencia_3_nombre VARCHAR(100),
referencia_3_telefono VARCHAR(15),
referencia_3_tipo VARCHAR(20),
origen_registro VARCHAR(50),
telegram_usuario VARCHAR(50),
telegram_chat_id BIGINT,
bot_version VARCHAR(20),
fecha_registro DATETIME,
tiempo_registro_minutos INT,
fecha_procesamiento DATETIME(3)
);
CREATE TABLE IF NOT EXISTS vacaciones (
vacaciones_id VARCHAR(50) PRIMARY KEY,
numero_empleado VARCHAR(15),
tipo_solicitud VARCHAR(20),
estatus ENUM('pendiente', 'aprobado', 'rechazado', 'cancelado'),
fecha_inicio DATE,
fecha_fin DATE,
dias_solicitados INT,
dias_habiles INT,
motivo TEXT,
con_goce_sueldo TINYINT(1),
fecha_solicitud DATETIME,
fecha_procesamiento DATETIME(3),
origen VARCHAR(20),
afecta_nomina TINYINT(1),
FOREIGN KEY (numero_empleado) REFERENCES data_empleadas(numero_empleado)
);
CREATE TABLE IF NOT EXISTS permisos (
permiso_id VARCHAR(50) PRIMARY KEY,
numero_empleado VARCHAR(15),
categoria ENUM('PERSONAL', 'MEDICO', 'OFICIAL', 'OTRO'),
estatus ENUM('pendiente', 'aprobado', 'rechazado', 'cancelado'),
fecha_inicio DATE,
horario_especifico VARCHAR(50),
motivo TEXT,
con_goce_sueldo TINYINT(1),
afecta_nomina TINYINT(1),
FOREIGN KEY (numero_empleado) REFERENCES data_empleadas(numero_empleado)
);
USE vanity_attendance;
CREATE TABLE IF NOT EXISTS asistencia_registros (
id_asistencia INT AUTO_INCREMENT PRIMARY KEY,
numero_empleado VARCHAR(15),
fecha DATE,
hora_entrada_real TIME,
hora_salida_real TIME,
minutos_retraso INT,
minutos_extra INT,
sucursal_registro VARCHAR(50),
telegram_id_usado BIGINT,
FOREIGN KEY (numero_empleado) REFERENCES vanity_hr.data_empleadas(numero_empleado)
);
CREATE TABLE IF NOT EXISTS horario_empleadas (
id_horario INT AUTO_INCREMENT PRIMARY KEY,
numero_empleado VARCHAR(15),
telegram_id BIGINT,
dia_semana VARCHAR(20),
hora_entrada_teorica TIME,
hora_salida_teorica TIME,
FOREIGN KEY (numero_empleado) REFERENCES vanity_hr.data_empleadas(numero_empleado)
);

View File

@@ -1,101 +1,219 @@
# Definición de Tablas y Campos para la Base de Datos
# Sistema Integrado de Gestión (Vanity)
Este documento describe la estructura de la base de datos para el bot Vanessa, diseñada para gestionar la información de empleadas, solicitudes y permisos de forma detallada.
Este repositorio documenta la **especificación técnica completa** del ecosistema Vanity: infraestructura de datos, diccionarios de campos sin truncar, relaciones entre entidades y reglas de negocio que gobiernan los Bots, Recursos Humanos y el sistema de Asistencia.
## 1. Tabla de Usuarias (`users`)
El objetivo es servir como **fuente de verdad técnica** para desarrollo, mantenimiento, auditoría y escalamiento.
Almacena la información central, el estado y el balance de vacaciones de cada empleada.
---
| Campo | Tipo | Descripción |
|--------------------------|-------------------------------|--------------------------------------------------------------------------|
| `id` | `INT` (PK) | Identificador único de la usuaria. |
| `employee_number` | `VARCHAR(50)` (Unique) | Número de empleada único. |
| `telegram_id` | `BIGINT` (Unique) | ID de Telegram de la usuaria. |
| `telegram_username` | `VARCHAR(255)` | Nombre de usuario de Telegram. |
| `full_name` | `VARCHAR(255)` | Nombre completo. |
| `preferred_name` | `VARCHAR(100)` | Nombre preferido de la empleada. |
| `email` | `VARCHAR(255)` (Unique) | Correo electrónico. |
| `phone_number` | `VARCHAR(20)` | Teléfono celular. |
| `position` | `VARCHAR(100)` | Puesto que desempeña. |
| `branch` | `VARCHAR(100)` | Sucursal a la que pertenece. |
| `hire_date` | `DATE` | Fecha de ingreso a la empresa. |
| `status` | `ENUM('activo', 'inactivo')` | Estatus actual de la empleada. |
| `role` | `ENUM('user', 'manager', 'admin')` | Nivel de permisos en el sistema (por defecto: 'user'). |
| `vacation_days_assigned` | `INT` | Días de vacaciones asignados para el periodo actual. |
| `vacation_days_taken` | `INT` (Default: 0) | Suma de días de vacaciones aprobados y tomados. |
| `created_at` | `DATETIME` | Fecha de creación del registro. |
| `updated_at` | `DATETIME` | Fecha de última actualización. |
## 1. Arquitectura de Datos
## 2. Tabla de Solicitudes de Vacaciones (`vacations`)
El sistema se distribuye en **tres bases de datos** dentro del mismo servidor, permitiendo integridad referencial y consultas cruzadas controladas:
Registro detallado de las solicitudes de vacaciones.
* **USERS_ALMA** → Seguridad, autenticación y control de acceso.
* **vanity_hr** → Gestión de personal, vacaciones, permisos y reglas laborales.
* **vanity_attendance** → Control de asistencia y programación de horarios.
| Campo | Tipo | Descripción |
|---------------------|------------------------------------|--------------------------------------------------------------------------|
| `id` | `INT` (PK) | Identificador numérico de la solicitud. |
| `request_id` | `VARCHAR(50)` (Unique) | Identificador alfanumérico único de la solicitud (e.g., "7c32a085..."). |
| `user_id` | `INT` (FK) | Empleada que solicita (`users.id`). |
| `status` | `ENUM('pendiente', 'aprobado', 'rechazado')` | Estado actual de la solicitud. |
| `start_date` | `DATE` | Fecha de inicio de las vacaciones. |
| `end_date` | `DATE` | Fecha de fin de las vacaciones. |
| `requested_days` | `INT` | Número de días naturales solicitados. |
| `business_days` | `INT` | Número de días hábiles que abarca la solicitud. |
| `reason` | `TEXT` | Motivo de la solicitud. |
| `with_pay` | `BOOLEAN` | `TRUE` si es con goce de sueldo. |
| `leave_type` | `ENUM('con_goce', 'sin_goce')` | Clasificación del tipo de permiso. |
| `request_date` | `DATETIME` | Fecha y hora en que se creó la solicitud. |
| `processed_date` | `DATETIME` | Fecha y hora en que se procesó en el sistema (e.g., envío a webhook). |
| `source` | `VARCHAR(50)` | Origen de la solicitud (e.g., "telegram_bot"). |
| `approver_id` | `INT` (FK, nullable) | Usuario (`users.id`) que aprobó o rechazó. |
| `approval_date` | `DATETIME` (nullable) | Fecha y hora de la aprobación o rechazo. |
| `approver_comments` | `TEXT` (nullable) | Comentarios del aprobador. |
| `affects_payroll` | `BOOLEAN` | `TRUE` si la solicitud tiene implicaciones en la nómina. |
---
## 3. Tabla de Solicitudes de Permisos por Horas (`permission_requests`)
## 2. Diccionario de Datos
Registro detallado de permisos especiales por horas.
### 2.1 Base de Datos: `vanity_hr`
| Campo | Tipo | Descripción |
|---------------------|------------------------------------|--------------------------------------------------------------------------|
| `id` | `INT` (PK) | Identificador numérico del permiso. |
| `request_id` | `VARCHAR(50)` (Unique) | Identificador alfanumérico único (e.g., "1LSRADeDNfY"). |
| `user_id` | `INT` (FK) | Empleada que solicita (`users.id`). |
| `category` | `VARCHAR(100)` | Categoría del permiso (e.g., "PERSONAL", "MÉDICO"). |
| `status` | `ENUM('pendiente', 'aprobado', 'rechazado')` | Estado actual del permiso. |
| `permission_date` | `DATE` | Fecha para la cual se solicita el permiso. |
| `start_time` | `TIME` | Hora de inicio del permiso. |
| `end_time` | `TIME` | Hora de fin del permiso. |
| `reason` | `TEXT` | Motivo detallado del permiso. |
| `with_pay` | `BOOLEAN` | `TRUE` si es con goce de sueldo. |
| `leave_type` | `ENUM('con_goce', 'sin_goce')` | Clasificación del tipo de permiso. |
| `request_date` | `DATETIME` | Fecha y hora en que se creó la solicitud. |
| `processed_date` | `DATETIME` | Fecha y hora en que se procesó. |
| `source` | `VARCHAR(50)` | Origen de la solicitud. |
| `approver_id` | `INT` (FK, nullable) | Usuario (`users.id`) que gestionó el permiso. |
| `approval_date` | `DATETIME` (nullable) | Fecha y hora de la gestión. |
| `approver_comments` | `TEXT` (nullable) | Comentarios del aprobador. |
| `affects_payroll` | `BOOLEAN` | `TRUE` si el permiso tiene implicaciones en la nómina. |
#### Tabla: `data_empleadas` (Maestra — 44 campos)
## 4. Interacción con la Base de Datos y Lógica de Negocio
Tabla central de Recursos Humanos. Contiene información contractual, personal, de contacto y metadatos de registro.
### a. Sistema de Roles
| Campo | Tipo | Key | Descripción |
| ------------------------- | ------------ | --- | ---------------------------------- |
| numero_empleado | varchar(15) | PRI | ID único de nómina |
| puesto | varchar(50) | | Cargo / Puesto |
| sucursal | varchar(50) | | Sucursal asignada |
| fecha_ingreso | date | | Fecha de alta (base de antigüedad) |
| estatus | varchar(15) | | Activo / Baja |
| nombre_completo | varchar(150) | | Nombre completo concatenado |
| nombre | varchar(50) | | Nombre(s) |
| nombre_preferido | varchar(50) | | Apodo o nombre de preferencia |
| apellido_paterno | varchar(50) | | Primer apellido |
| apellido_materno | varchar(50) | | Segundo apellido |
| fecha_nacimiento | date | | Fecha de nacimiento |
| lugar_nacimiento | varchar(50) | | Ciudad / Estado |
| rfc | varchar(13) | UNI | RFC |
| curp | varchar(18) | UNI | CURP |
| email | varchar(100) | | Correo electrónico |
| telefono_celular | varchar(15) | | Teléfono móvil |
| domicilio_calle | varchar(255) | | Calle |
| domicilio_numero_exterior | varchar(10) | | Número exterior |
| domicilio_numero_interior | varchar(10) | | Número interior |
| domicilio_numero_texto | varchar(50) | | Referencias |
| domicilio_colonia | varchar(255) | | Colonia |
| domicilio_codigo_postal | varchar(10) | | CP |
| domicilio_ciudad | varchar(100) | | Ciudad |
| domicilio_estado | varchar(50) | | Estado |
| domicilio_completo | varchar(255) | | Dirección formateada |
| emergencia_nombre | varchar(100) | | Contacto de emergencia |
| emergencia_telefono | varchar(15) | | Teléfono de emergencia |
| emergencia_parentesco | varchar(50) | | Parentesco |
| referencia_1_nombre | varchar(100) | | Referencia 1 |
| referencia_1_telefono | varchar(15) | | Teléfono ref 1 |
| referencia_1_tipo | varchar(20) | | Tipo ref 1 |
| referencia_2_nombre | varchar(100) | | Referencia 2 |
| referencia_2_telefono | varchar(15) | | Teléfono ref 2 |
| referencia_2_tipo | varchar(20) | | Tipo ref 2 |
| referencia_3_nombre | varchar(100) | | Referencia 3 |
| referencia_3_telefono | varchar(15) | | Teléfono ref 3 |
| referencia_3_tipo | varchar(20) | | Tipo ref 3 |
| origen_registro | varchar(50) | | Web / Bot |
| telegram_usuario | varchar(50) | | Username Telegram |
| telegram_chat_id | bigint | | ID de chat Telegram |
| bot_version | varchar(20) | | Versión del bot |
| fecha_registro | datetime | | Timestamp creación |
| tiempo_registro_minutos | int | | Duración del registro |
| fecha_procesamiento | datetime(3) | | Timestamp procesado |
- **`user`**: Rol estándar para todas las empleadas. Pueden solicitar vacaciones y permisos, y consultar su propio estado.
- **`manager`**: Puede realizar las mismas acciones que un `user`, y adicionalmente, aprobar/rechazar solicitudes y consultar datos de las usuarias a su cargo.
- **`admin`**: Acceso total. Puede gestionar todos los datos de todas las usuarias y solicitudes.
---
### b. Flujo de Solicitudes (Vacaciones y Permisos)
#### Tabla: `vacaciones` (14 campos)
1. **Creación**: Una usuaria crea una solicitud. El sistema la inserta en la tabla `vacations` o `permission_requests` con estado `'pendiente'`.
2. **Aprobación**: Un `manager` o `admin` revisa la solicitud. Al aprobarla:
* El `status` de la solicitud cambia a `'aprobado'`.
* Se registran el `approver_id`, `approval_date` y `approver_comments`.
3. **Actualización de Balance de Vacaciones**:
* **Si la solicitud es de vacaciones y fue aprobada**: El sistema debe sumar los `business_days` de la solicitud al campo `vacation_days_taken` de la usuaria en la tabla `users`.
* Esta lógica asegura que el balance de días tomados siempre esté sincronizado con los registros aprobados.
| Campo | Tipo | Key | Descripción |
| ------------------- | ----------- | --- | -------------------------------------------- |
| vacaciones_id | varchar(50) | PRI | ID de solicitud |
| numero_empleado | varchar(15) | MUL | Relación con empleada |
| tipo_solicitud | varchar(20) | | VACACIONES |
| estatus | enum | | pendiente / aprobado / rechazado / cancelado |
| fecha_inicio | date | | Inicio |
| fecha_fin | date | | Fin |
| dias_solicitados | int | | Total días |
| dias_habiles | int | | Días descontados |
| motivo | text | | Observaciones |
| con_goce_sueldo | tinyint(1) | | 1 = Sí |
| fecha_solicitud | datetime | | Creación |
| fecha_procesamiento | datetime(3) | | Cambio de estatus |
| origen | varchar(20) | | telegram_bot / web |
| afecta_nomina | tinyint(1) | | Impacto en pago |
### c. Lógica de Negocio Clave
---
#### Tabla: `permisos` (9 campos)
| Campo | Tipo | Key | Descripción |
| ------------------ | ----------- | --- | -------------------------------------------- |
| permiso_id | varchar(50) | PRI | ID de permiso |
| numero_empleado | varchar(15) | MUL | Relación RH |
| categoria | enum | | PERSONAL / MEDICO / OFICIAL / OTRO |
| estatus | enum | | pendiente / aprobado / rechazado / cancelado |
| fecha_inicio | date | | Fecha |
| horario_especifico | varchar(50) | | Rango horario |
| motivo | text | | Razón |
| con_goce_sueldo | tinyint(1) | | 0 / 1 |
| afecta_nomina | tinyint(1) | | Impacto |
---
### 2.2 Base de Datos: `vanity_attendance`
#### Tabla: `asistencia_registros` (9 campos)
| Campo | Tipo | Key | Descripción |
| ----------------- | ----------- | --- | -------------- |
| id_asistencia | int | PRI | Auto-increment |
| numero_empleado | varchar(15) | MUL | Relación RH |
| fecha | date | | Día |
| hora_entrada_real | time | | Entrada |
| hora_salida_real | time | | Salida |
| minutos_retraso | int | | Calculado |
| minutos_extra | int | | Excedente |
| sucursal_registro | varchar(50) | | Sucursal |
| telegram_id_usado | bigint | | ID Telegram |
---
#### Tabla: `horario_empleadas` (Diccionario de turnos)
| Campo | Tipo | Key | Descripción |
| -------------------- | ----------- | --- | ---------------- |
| id_horario | int | PRI | ID |
| numero_empleado | varchar(15) | MUL | Relación RH |
| telegram_id | bigint | | Llave webhook |
| dia_semana | varchar(20) | | monday, tuesday… |
| hora_entrada_teorica | time | | Entrada |
| hora_salida_teorica | time | | Salida |
---
### 2.3 Base de Datos: `USERS_ALMA`
#### Tabla: `users` (10 campos)
| Campo | Tipo | Key | Descripción |
| ----------- | ------------ | --- | ---------------------- |
| id | int | PRI | ID interno |
| username | varchar(50) | UNI | Usuario |
| role | enum | | admin / manager / user |
| first_name | varchar(100) | | Nombre |
| last_name | varchar(100) | | Apellidos |
| email | varchar(100) | UNI | Correo |
| cell_phone | varchar(20) | | Teléfono |
| telegram_id | varchar(50) | UNI | Auth bot |
| created_at | timestamp | | Creación |
| updated_at | timestamp | | Actualización |
---
## 3. Reglas de Negocio
### 3.1 Vacaciones
* **Antigüedad**: `FLOOR(DATEDIFF(fecha_inicio, fecha_ingreso) / 365.25)`
* **Ventana**: mínimo 12 días, máximo 45 días de anticipación
* **Validación**: no se permite solicitar periodos no cumplidos
### 3.2 Asistencia
* Identificación por `telegram_id`
* Cruce con `horario_empleadas` según día
* Cálculo de retraso contra horario teórico
---
## 4. Integración Webhook (Horarios)
* **Identificación**: `body.telegram.user_id`
* **Operación**: Upsert por día
* **Formato**: conversión de `10:00 AM``10:00:00`
---
## 5. Consultas Operativas
```sql
-- Saldo actual de vacaciones
SELECT * FROM vanity_hr.vista_saldos_vacaciones
WHERE numero_empleado = ?;
-- Última solicitud
SELECT * FROM vanity_hr.vacaciones
WHERE numero_empleado = ?
ORDER BY fecha_solicitud DESC
LIMIT 1;
-- Horario del día
SELECT hora_entrada_teorica
FROM vanity_attendance.horario_empleadas
WHERE telegram_id = ? AND dia_semana = 'monday';
```
---
Este documento define el **contrato técnico** del sistema Vanity. Cualquier cambio estructural debe reflejarse aquí antes de pasar a producción.
---
# DB Implementation Tasks
1. **Create initialization script:** Write a SQL script to create the databases and tables.
2. **Modify `docker-compose.yml`:** Mount the initialization script.
3. **Update `.env.example`:** Add new environment variables.
4. **Implement SQLAlchemy models:** Create Python classes for each table.
5. **Refactor database logic:** Use the new models in the application.
- **Balance de Vacaciones**: El balance de días disponibles de una empleada se calcula en tiempo real como `vacation_days_assigned` - `vacation_days_taken`.
- **Nómina**: El campo `affects_payroll` sirve como una bandera para que los sistemas externos de RRHH sepan que una solicitud específica (e.g., un permiso sin goce de sueldo) requiere un ajuste en la nómina.
- **Auditoría**: Todas las fechas (`request_date`, `approval_date`) y IDs (`user_id`, `approver_id`) permiten una auditoría completa de cada solicitud.

View File

@@ -2,7 +2,7 @@ version: "3.8"
services:
bot:
image: marcogll/vanessa-bot:1.2
build: .
container_name: vanessa_bot
restart: always
env_file:
@@ -11,7 +11,6 @@ services:
- MYSQL_HOST=db
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_DATABASE=${MYSQL_DATABASE}
depends_on:
db:
condition: service_healthy
@@ -23,7 +22,6 @@ services:
container_name: vanessa_db
restart: always
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
@@ -34,6 +32,7 @@ services:
retries: 6
start_period: 30s
volumes:
- ./db/init:/docker-entrypoint-initdb.d
- mysql_data:/var/lib/mysql
ports:
- "3306:3306"

View File

@@ -18,7 +18,8 @@ from telegram.ext import Application, Defaults, CommandHandler, ContextTypes
# --- IMPORTAR HABILIDADES ---
from modules.onboarding import onboarding_handler
from modules.rh_requests import vacaciones_handler, permiso_handler
from modules.database import log_request, chat_id_exists # Importar chat_id_exists
from modules.logger import log_request
from modules.database import chat_id_exists # Importar chat_id_exists
from modules.ui import main_actions_keyboard
# from modules.finder import finder_handler (Si lo creas después)

View File

@@ -0,0 +1,31 @@
from sqlalchemy import create_engine, Column, Integer, String, Enum, TIMESTAMP
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
from datetime import datetime
Base = declarative_base()
class RequestLog(Base):
__tablename__ = 'request_logs'
__table_args__ = {'schema': 'USERS_ALMA'}
id = Column(Integer, primary_key=True)
telegram_id = Column(String(50))
username = Column(String(100))
command = Column(String(100))
message = Column(String(500))
created_at = Column(TIMESTAMP, server_default=func.now())
class User(Base):
__tablename__ = 'users'
__table_args__ = {'schema': 'USERS_ALMA'}
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True)
role = Column(Enum('admin', 'manager', 'user'))
first_name = Column(String(100))
last_name = Column(String(100))
email = Column(String(100), unique=True)
cell_phone = Column(String(20))
telegram_id = Column(String(50), unique=True)
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,32 @@
from sqlalchemy import create_engine, Column, Integer, String, Date, Time, BigInteger, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
class AsistenciaRegistros(Base):
__tablename__ = 'asistencia_registros'
__table_args__ = {'schema': 'vanity_attendance'}
id_asistencia = Column(Integer, primary_key=True, autoincrement=True)
numero_empleado = Column(String(15), ForeignKey('vanity_hr.data_empleadas.numero_empleado'))
fecha = Column(Date)
hora_entrada_real = Column(Time)
hora_salida_real = Column(Time)
minutos_retraso = Column(Integer)
minutos_extra = Column(Integer)
sucursal_registro = Column(String(50))
telegram_id_usado = Column(BigInteger)
empleada = relationship("DataEmpleadas", backref="asistencia_registros")
class HorarioEmpleadas(Base):
__tablename__ = 'horario_empleadas'
__table_args__ = {'schema': 'vanity_attendance'}
id_horario = Column(Integer, primary_key=True, autoincrement=True)
numero_empleado = Column(String(15), ForeignKey('vanity_hr.data_empleadas.numero_empleado'))
telegram_id = Column(BigInteger)
dia_semana = Column(String(20))
hora_entrada_teorica = Column(Time)
hora_salida_teorica = Column(Time)
empleada = relationship("DataEmpleadas", backref="horario_empleadas")

View File

@@ -0,0 +1,92 @@
from sqlalchemy import create_engine, Column, Integer, String, Enum, TIMESTAMP, Date, Text, BigInteger, DateTime
from sqlalchemy.dialects.mysql import TINYINT
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship
Base = declarative_base()
class DataEmpleadas(Base):
__tablename__ = 'data_empleadas'
__table_args__ = {'schema': 'vanity_hr'}
numero_empleado = Column(String(15), primary_key=True)
puesto = Column(String(50))
sucursal = Column(String(50))
fecha_ingreso = Column(Date)
estatus = Column(String(15))
nombre_completo = Column(String(150))
nombre = Column(String(50))
nombre_preferido = Column(String(50))
apellido_paterno = Column(String(50))
apellido_materno = Column(String(50))
fecha_nacimiento = Column(Date)
lugar_nacimiento = Column(String(50))
rfc = Column(String(13), unique=True)
curp = Column(String(18), unique=True)
email = Column(String(100))
telefono_celular = Column(String(15))
domicilio_calle = Column(String(255))
domicilio_numero_exterior = Column(String(10))
domicilio_numero_interior = Column(String(10))
domicilio_numero_texto = Column(String(50))
domicilio_colonia = Column(String(255))
domicilio_codigo_postal = Column(String(10))
domicilio_ciudad = Column(String(100))
domicilio_estado = Column(String(50))
domicilio_completo = Column(String(255))
emergencia_nombre = Column(String(100))
emergencia_telefono = Column(String(15))
emergencia_parentesco = Column(String(50))
referencia_1_nombre = Column(String(100))
referencia_1_telefono = Column(String(15))
referencia_1_tipo = Column(String(20))
referencia_2_nombre = Column(String(100))
referencia_2_telefono = Column(String(15))
referencia_2_tipo = Column(String(20))
referencia_3_nombre = Column(String(100))
referencia_3_telefono = Column(String(15))
referencia_3_tipo = Column(String(20))
origen_registro = Column(String(50))
telegram_usuario = Column(String(50))
telegram_chat_id = Column(BigInteger)
bot_version = Column(String(20))
fecha_registro = Column(DateTime)
tiempo_registro_minutos = Column(Integer)
fecha_procesamiento = Column(DateTime)
class Vacaciones(Base):
__tablename__ = 'vacaciones'
__table_args__ = {'schema': 'vanity_hr'}
vacaciones_id = Column(String(50), primary_key=True)
numero_empleado = Column(String(15), ForeignKey('vanity_hr.data_empleadas.numero_empleado'))
tipo_solicitud = Column(String(20))
estatus = Column(Enum('pendiente', 'aprobado', 'rechazado', 'cancelado'))
fecha_inicio = Column(Date)
fecha_fin = Column(Date)
dias_solicitados = Column(Integer)
dias_habiles = Column(Integer)
motivo = Column(Text)
con_goce_sueldo = Column(TINYINT)
fecha_solicitud = Column(DateTime)
fecha_procesamiento = Column(DateTime)
origen = Column(String(20))
afecta_nomina = Column(TINYINT)
empleada = relationship("DataEmpleadas")
class Permisos(Base):
__tablename__ = 'permisos'
__table_args__ = {'schema': 'vanity_hr'}
permiso_id = Column(String(50), primary_key=True)
numero_empleado = Column(String(15), ForeignKey('vanity_hr.data_empleadas.numero_empleado'))
categoria = Column(Enum('PERSONAL', 'MEDICO', 'OFICIAL', 'OTRO'))
estatus = Column(Enum('pendiente', 'aprobado', 'rechazado', 'cancelado'))
fecha_inicio = Column(Date)
horario_especifico = Column(String(50))
motivo = Column(Text)
con_goce_sueldo = Column(TINYINT)
afecta_nomina = Column(TINYINT)
empleada = relationship("DataEmpleadas")

View File

@@ -1,14 +1,40 @@
import logging
import os
from datetime import datetime
from sqlalchemy import Column, DateTime, Integer, MetaData, String, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models.users_alma_models import Base as BaseUsersAlma, User
from models.vanity_hr_models import Base as BaseVanityHr, DataEmpleadas, Vacaciones, Permisos
from models.vanity_attendance_models import Base as BaseVanityAttendance, AsistenciaRegistros, HorarioEmpleadas
import gspread
from google.oauth2.service_account import Credentials
# Configuración de logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
# --- DATABASE (MySQL) SETUP ---
def _build_engine(db_name_env_var):
user = os.getenv("MYSQL_USER")
password = os.getenv("MYSQL_PASSWORD")
db_name = os.getenv(db_name_env_var)
host = os.getenv("MYSQL_HOST", "db")
if not all([user, password, db_name]):
logging.warning(f"Database connection disabled: missing environment variables for {db_name_env_var}.")
return None
try:
db_url = f"mysql+mysqlconnector://{user}:{password}@{host}:3306/{db_name}"
return create_engine(db_url, pool_pre_ping=True)
except Exception as exc:
logging.error(f"Could not create database engine for {db_name}: {exc}")
return None
# Create engines for each database
engine_users_alma = _build_engine("MYSQL_DATABASE_USERS_ALMA")
engine_vanity_hr = _build_engine("MYSQL_DATABASE_VANITY_HR")
engine_vanity_attendance = _build_engine("MYSQL_DATABASE_VANITY_ATTENDANCE")
# Create sessions for each database
SessionUsersAlma = sessionmaker(autocommit=False, autoflush=False, bind=engine_users_alma) if engine_users_alma else None
SessionVanityHr = sessionmaker(autocommit=False, autoflush=False, bind=engine_vanity_hr) if engine_vanity_hr else None
SessionVanityAttendance = sessionmaker(autocommit=False, autoflush=False, bind=engine_vanity_attendance) if engine_vanity_attendance else None
# --- GOOGLE SHEETS SETUP ---
GSHEET_URL = os.getenv("GOOGLE_SHEET_URL")
@@ -16,15 +42,14 @@ GOOGLE_CREDENTIALS_FILE = os.getenv("GOOGLE_CREDENTIALS_FILE", "google_credentia
SHEET_COLUMN_INDEX = 40 # AN is the 40th column
def get_gsheet_client():
"""Retorna un cliente de gspread autenticado o None si falla."""
"""Returns an authenticated gspread client or None if it fails."""
if not GSHEET_URL:
logging.warning("GOOGLE_SHEET_URL no está configurada. La verificación de duplicados está deshabilitada.")
logging.warning("GOOGLE_SHEET_URL is not configured. Duplicate checking is disabled.")
return None
creds = None
scopes = ["https://www.googleapis.com/auth/spreadsheets.readonly"]
# Prioridad 1: Cargar desde variables de entorno
gsa_creds_dict = {
"type": os.getenv("GSA_TYPE"),
"project_id": os.getenv("GSA_PROJECT_ID"),
@@ -41,139 +66,39 @@ def get_gsheet_client():
if all(gsa_creds_dict.values()):
try:
creds = Credentials.from_service_account_info(gsa_creds_dict, scopes=scopes)
logging.info("Autenticando con Google Sheets usando variables de entorno.")
except Exception as e:
logging.error(f"Error al procesar credenciales de entorno de Google: {e}")
logging.error(f"Error processing Google credentials from environment: {e}")
return None
# Prioridad 2: Cargar desde archivo JSON
elif os.path.exists(GOOGLE_CREDENTIALS_FILE):
try:
creds = Credentials.from_service_account_file(GOOGLE_CREDENTIALS_FILE, scopes=scopes)
logging.info(f"Autenticando con Google Sheets usando el archivo '{GOOGLE_CREDENTIALS_FILE}'.")
except Exception as e:
logging.error(f"Error al procesar el archivo de credenciales '{GOOGLE_CREDENTIALS_FILE}': {e}")
logging.error(f"Error processing credentials file '{GOOGLE_CREDENTIALS_FILE}': {e}")
return None
else:
logging.warning("No se encontraron credenciales de Google (ni por variables de entorno ni por archivo). La verificación de duplicados está deshabilitada.")
logging.warning("Google credentials not found (neither environment variables nor file). Duplicate checking is disabled.")
return None
try:
client = gspread.authorize(creds)
return client
return gspread.authorize(creds)
except Exception as e:
logging.error(f"Error al autorizar cliente de gspread: {e}")
logging.error(f"Error authorizing gspread client: {e}")
return None
def chat_id_exists(chat_id: int) -> bool:
"""Verifica si un chat_id de Telegram ya existe en la columna AN de la hoja de cálculo."""
"""Checks if a Telegram chat_id already exists in the Google Sheet."""
client = get_gsheet_client()
if not client:
return False # Si no hay cliente, no podemos verificar, así que asumimos que no existe.
return False
try:
spreadsheet = client.open_by_url(GSHEET_URL)
worksheet = spreadsheet.get_worksheet(0) # Primera hoja
# Obtener todos los valores de la columna AN (índice 40)
worksheet = spreadsheet.get_worksheet(0)
chat_ids_in_sheet = worksheet.col_values(SHEET_COLUMN_INDEX)
# El ID de chat puede venir como número o texto, así que comparamos como string
return str(chat_id) in chat_ids_in_sheet
except gspread.exceptions.SpreadsheetNotFound:
logging.error(f"No se pudo encontrar la hoja de cálculo en la URL proporcionada.")
logging.error("Could not find the spreadsheet at the provided URL.")
return False
except Exception as e:
logging.error(f"Error al leer la hoja de cálculo: {e}")
logging.error(f"Error reading the spreadsheet: {e}")
return False
# --- DATABASE (MySQL) SETUP ---
# Base para los modelos declarativos
Base = declarative_base()
# Clase que mapea a la tabla de logs
class RequestLog(Base):
__tablename__ = 'request_logs'
id = Column(Integer, primary_key=True)
telegram_id = Column(String(50))
username = Column(String(100))
command = Column(String(100))
message = Column(String(500))
created_at = Column(DateTime, default=datetime.utcnow)
def _build_engine():
"""Crea un engine de SQLAlchemy si hay variables de entorno suficientes."""
user = os.getenv("MYSQL_USER")
password = os.getenv("MYSQL_PASSWORD")
database = os.getenv("MYSQL_DATABASE")
host = os.getenv("MYSQL_HOST") or "db" # Permitimos override para uso local
if not all([user, password, database]):
logging.warning("DB logging deshabilitado: faltan MYSQL_USER/MYSQL_PASSWORD/MYSQL_DATABASE.")
return None
try:
db_url = f"mysql+mysqlconnector://{user}:{password}@{host}:3306/{database}"
return create_engine(db_url, pool_pre_ping=True)
except Exception as exc:
logging.error(f"No se pudo crear el engine de base de datos: {exc}")
return None
def _disable_db_logging(reason: str):
"""Deshabilita el logging a DB después de un error para evitar spam."""
global engine, SessionLocal
engine = None
SessionLocal = None
logging.warning(f"DB logging deshabilitado: {reason}")
# Crear el engine y sesión si es posible
engine = _build_engine()
metadata = MetaData() if engine else None
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) if engine else None
# Función para inicializar la base de datos
def init_db():
global engine, SessionLocal
if not engine:
return
try:
logging.info("Inicializando la base de datos y creando tablas si no existen...")
Base.metadata.create_all(bind=engine)
logging.info("Tablas verificadas/creadas correctamente.")
except Exception as e:
logging.error(f"Error al inicializar la base de datos: {e}")
_disable_db_logging("no se pudo inicializar la base de datos (se omitirán logs).")
# No propagamos para que el bot pueda seguir levantando aunque no haya DB
# Función para registrar una solicitud en la base de datos
def log_request(telegram_id, username, command, message):
if not SessionLocal:
logging.debug("Log de DB omitido (DB no configurada).")
return
try:
db_session = SessionLocal()
except Exception as exc:
logging.error(f"No se pudo crear sesión DB, se deshabilita el log: {exc}")
_disable_db_logging("no se pudo abrir sesión")
return
try:
log_entry = RequestLog(
telegram_id=str(telegram_id),
username=username,
command=command,
message=message
)
db_session.add(log_entry)
db_session.commit()
logging.info(f"Log guardado: {command} de {username}")
except Exception as e:
logging.error(f"Error al guardar el log: {e}")
db_session.rollback()
finally:
db_session.close()
# Inicializar la base de datos al arrancar el módulo
init_db()

29
modules/logger.py Normal file
View File

@@ -0,0 +1,29 @@
import logging
from modules.database import SessionUsersAlma
from models.users_alma_models import RequestLog
def log_request(telegram_id, username, command, message):
if not SessionUsersAlma:
logging.debug("DB log omitted (DB not configured).")
return
try:
db_session = SessionUsersAlma()
except Exception as exc:
logging.error(f"Could not create DB session, logging is disabled: {exc}")
return
try:
log_entry = RequestLog(
telegram_id=str(telegram_id),
username=username,
command=command,
message=message
)
db_session.add(log_entry)
db_session.commit()
logging.info(f"Log saved: {command} from {username}")
except Exception as e:
logging.error(f"Error saving log: {e}")
db_session.rollback()
finally:
db_session.close()

View File

@@ -17,7 +17,8 @@ from telegram.ext import (
Defaults,
)
from modules.database import log_request, chat_id_exists
from modules.logger import log_request
from modules.database import chat_id_exists
from modules.ui import main_actions_keyboard
# --- 1. CARGA DE ENTORNO ---

View File

@@ -5,7 +5,7 @@ import string
from datetime import datetime, date
from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update
from telegram.ext import CommandHandler, ContextTypes, ConversationHandler, MessageHandler, filters
from modules.database import log_request
from modules.logger import log_request
from modules.ui import main_actions_keyboard
from modules.ai import classify_reason