From 4e2b6335a68edfab6b228cb6325864828a85e9c1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:36:40 +0000 Subject: [PATCH] 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`. --- .env.example | 4 +- db/init/init.sql | 136 ++++++++++++++ db_tasks.md | 286 ++++++++++++++++++++--------- docker-compose.yml | 5 +- main.py | 3 +- models/users_alma_models.py | 31 ++++ models/vanity_attendance_models.py | 32 ++++ models/vanity_hr_models.py | 92 ++++++++++ modules/database.py | 161 +++++----------- modules/logger.py | 29 +++ modules/onboarding.py | 3 +- modules/rh_requests.py | 2 +- 12 files changed, 575 insertions(+), 209 deletions(-) create mode 100644 db/init/init.sql create mode 100644 models/users_alma_models.py create mode 100644 models/vanity_attendance_models.py create mode 100644 models/vanity_hr_models.py create mode 100644 modules/logger.py diff --git a/.env.example b/.env.example index 15d1d69..688fea0 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/db/init/init.sql b/db/init/init.sql new file mode 100644 index 0000000..2e823ea --- /dev/null +++ b/db/init/init.sql @@ -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) +); diff --git a/db_tasks.md b/db_tasks.md index 0f9d930..45c307e 100644 --- a/db_tasks.md +++ b/db_tasks.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index e0e2966..71b435d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/main.py b/main.py index f8dfb07..25265a2 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/models/users_alma_models.py b/models/users_alma_models.py new file mode 100644 index 0000000..cab872d --- /dev/null +++ b/models/users_alma_models.py @@ -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()) diff --git a/models/vanity_attendance_models.py b/models/vanity_attendance_models.py new file mode 100644 index 0000000..050295f --- /dev/null +++ b/models/vanity_attendance_models.py @@ -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") diff --git a/models/vanity_hr_models.py b/models/vanity_hr_models.py new file mode 100644 index 0000000..2f4b445 --- /dev/null +++ b/models/vanity_hr_models.py @@ -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") diff --git a/modules/database.py b/modules/database.py index e1d6d08..f5b1845 100644 --- a/modules/database.py +++ b/modules/database.py @@ -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() diff --git a/modules/logger.py b/modules/logger.py new file mode 100644 index 0000000..ae9f2ee --- /dev/null +++ b/modules/logger.py @@ -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() diff --git a/modules/onboarding.py b/modules/onboarding.py index da47618..2af4d0a 100644 --- a/modules/onboarding.py +++ b/modules/onboarding.py @@ -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 --- diff --git a/modules/rh_requests.py b/modules/rh_requests.py index 11fcdb9..bc187d7 100644 --- a/modules/rh_requests.py +++ b/modules/rh_requests.py @@ -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