feat: refactor schedule storage to vanity_hr schema, update onboarding command to /registro, and enhance horario flow with short name collection

This commit is contained in:
Marco Gallegos
2025-12-20 22:43:34 -06:00
parent 338108d7b7
commit d66e8118eb
16 changed files with 570 additions and 405 deletions

View File

@@ -10,11 +10,12 @@ Este repositorio está pensado como **proyecto Python profesional**, modular y l
Vanessa no es un chatbot genérico: es una interfaz conversacional para procesos reales de negocio. Vanessa no es un chatbot genérico: es una interfaz conversacional para procesos reales de negocio.
- **Onboarding completo de nuevas socias (`/welcome`)**: Recolecta datos, valida que no existan duplicados en la DB, y ejecuta un registro en dos fases: - **Onboarding completo de nuevas socias (`/registro`, alias `/welcome`)**: Recolecta datos, valida que no existan duplicados en la DB, y ejecuta un registro en dos fases. **Debe completarse una vez para habilitar el resto de comandos.**
1. **Crea un usuario de acceso** en la tabla `USERS_ALMA.users` para la autenticación del bot. 1. **Crea un usuario de acceso** en la tabla `USERS_ALMA.users` para la autenticación del bot.
2. **Crea un perfil de empleada** completo en la tabla `vanity_hr.data_empleadas`, que es la tabla maestra de RRHH. 2. **Crea un perfil de empleada** completo en la tabla `vanity_hr.data_empleadas`, que es la tabla maestra de RRHH.
- **Solicitud de vacaciones (`/vacaciones`)**: Flujo dinámico para gestionar días de descanso. - **Definición de horario semanal (`/horario`)**: Captura guiada día a día que termina en un upsert por día dentro de `vanity_hr.horario_empleadas` y dispara un webhook operativo; sólo se habilita si ya estás registrada.
- **Solicitud de permisos por horas (`/permiso`)**: Incluye clasificación de motivos mediante IA (Gemini). - **Solicitud de vacaciones (`/vacaciones`)**: Flujo dinámico para gestionar días de descanso, disponible sólo si tu `telegram_id` ya existe en la base vía `/registro`.
- **Solicitud de permisos por horas (`/permiso`)**: Incluye clasificación de motivos mediante IA (Gemini) y requiere que el onboarding haya terminado.
Cada flujo es un módulo independiente que interactúa con la base de datos y flujos de **n8n**. Cada flujo es un módulo independiente que interactúa con la base de datos y flujos de **n8n**.
@@ -38,11 +39,14 @@ vanity_bot/
│ ├── vanity_hr_models.py │ ├── vanity_hr_models.py
│ └── vanity_attendance_models.py │ └── vanity_attendance_models.py
── modules/ # Habilidades del bot ── conv-flows/ # Plantillas JSON de flujos declarativos (p. ej. horario.json)
└── modules/ # Habilidades del bot y utilidades
├── ai.py # Clasificación de motivos con Gemini ├── ai.py # Clasificación de motivos con Gemini
├── database.py # Conexión a DB y lógica de negocio (registro/verificación) ├── database.py # Conexión a DB y lógica de negocio (registro/verificación)
├── finalizer.py # Acciones finales por flujo (webhooks + persistencia)
├── flow_builder.py # Loader que convierte las plantillas JSON en ConversationHandlers
├── logger.py # Registro de auditoría ├── logger.py # Registro de auditoría
├── onboarding.py # Flujo /welcome ├── onboarding.py # Flujo /registro (/welcome)
├── rh_requests.py # /vacaciones y /permiso ├── rh_requests.py # /vacaciones y /permiso
└── ui.py # Teclados y componentes de interfaz └── ui.py # Teclados y componentes de interfaz
``` ```
@@ -64,6 +68,7 @@ GOOGLE_API_KEY=AIzaSy...
WEBHOOK_ONBOARDING=https://... WEBHOOK_ONBOARDING=https://...
WEBHOOK_VACACIONES=https://... WEBHOOK_VACACIONES=https://...
WEBHOOK_PERMISOS=https://... WEBHOOK_PERMISOS=https://...
WEBHOOK_SCHEDULE=https://...
# --- DATABASE SETUP --- # --- DATABASE SETUP ---
MYSQL_HOST=db MYSQL_HOST=db
@@ -79,6 +84,18 @@ MYSQL_DATABASE_VANITY_ATTENDANCE=vanity_attendance
--- ---
## 🔄 Flujos declarativos
El bot puede registrar conversaciones complejas sin código específico gracias a:
- `conv-flows/*.json`: Describe cada paso del flujo (texto, teclados, variables y transiciones). Actualmente `horario.json` define `/horario`.
- `modules/flow_builder.py`: Lee los JSON y crea dinámicamente los `ConversationHandler`.
- `modules/finalizer.py`: Ejecuta la acción final de cada flujo. Para `/horario` convierte las horas a formato 24 h, envía `WEBHOOK_SCHEDULE` y distribuye los registros por día en `vanity_hr.horario_empleadas`.
Si un flujo requiere lógica adicional, se agrega un finalizer nuevo y se anota en el map `FINALIZATION_MAP`.
---
## 🐳 Ejecución con Docker (Recomendado) ## 🐳 Ejecución con Docker (Recomendado)
El proyecto está dockerizado para facilitar su despliegue y aislamiento. El proyecto está dockerizado para facilitar su despliegue y aislamiento.

View File

@@ -56,7 +56,7 @@ Arquitectura **modular y desacoplada**:
└── modules/ # HABILIDADES └── modules/ # HABILIDADES
├── __init__.py ├── __init__.py
├── onboarding.py # /welcome — Contrato (35 pasos) ├── onboarding.py # /registro (/welcome) — Contrato (35 pasos)
├── rh_requests.py # /vacaciones y /permiso (IA) ├── rh_requests.py # /vacaciones y /permiso (IA)
``` ```
@@ -64,7 +64,7 @@ Arquitectura **modular y desacoplada**:
## 💬 Módulos y Flujos Conversacionales ## 💬 Módulos y Flujos Conversacionales
### 1⃣ Onboarding — `/welcome` ### 1⃣ Onboarding — `/registro` (alias `/welcome`)
**Objetivo** **Objetivo**
Recopilar la información completa para el contrato de nuevas socias. Recopilar la información completa para el contrato de nuevas socias.
@@ -80,7 +80,7 @@ Recopilar la información completa para el contrato de nuevas socias.
**Ejemplo de conversación** **Ejemplo de conversación**
``` ```
User: /welcome User: /registro
Vanessa: ¡Hola Ana! 👋 Soy Vanessa de RH. Vamos a dejar listo tu registro. Vanessa: ¡Hola Ana! 👋 Soy Vanessa de RH. Vamos a dejar listo tu registro.
Vanessa: ¿Cómo te gusta que te llamemos? Vanessa: ¿Cómo te gusta que te llamemos?
User: Anita User: Anita

View File

@@ -2,114 +2,118 @@
"flow_name": "horario", "flow_name": "horario",
"steps": [ "steps": [
{ {
"state": 0, "state": -3,
"variable": "INTRO_READ", "variable": "INTRO_READ",
"type": "keyboard", "type": "keyboard",
"question": "ANTES DE DEFINIR TUS HORARIOS\n\nLee con atención:\n\n• Estos horarios se usarán para control de asistencia y reportes\n• Selecciona únicamente los botones disponibles\n• El sistema convertirá automáticamente a formato 24 hrs\n\nCuando estés listo, continúa.", "question": "ANTES DE DEFINIR TUS HORARIOS\n\nLee con atención:\n\n• Estos horarios se usarán para control de asistencia y reportes\n• Selecciona únicamente los botones disponibles\n• El sistema convertirá automáticamente a formato 24 hrs\n\nCuando estés lista, confirma para continuar.",
"options": ["Continuar"], "options": ["Continuar"]
"next_step": 1 },
{
"state": -2,
"variable": "INTRO_NAME",
"type": "info",
"question": "NOMBRE CORTO\n\nEste nombre se usará para mensajes internos, reportes y notificaciones."
},
{
"state": -1,
"variable": "SHORT_NAME",
"type": "text",
"question": "¿Cómo te dicen normalmente?"
},
{
"state": 0,
"variable": "INTRO_HOURS",
"type": "info",
"question": "DEFINICIÓN DE HORARIOS\n\nDefine tu horario habitual por día usando botones AM / PM."
}, },
{ {
"state": 1, "state": 1,
"variable": "SHORT_NAME",
"type": "text",
"question": "¿Cómo te dicen normalmente?",
"next_step": 2
},
{
"state": 2,
"variable": "MONDAY_IN", "variable": "MONDAY_IN",
"type": "keyboard", "type": "keyboard",
"question": "Lunes · Hora de entrada", "question": "Lunes · Hora de entrada",
"options": ["10:00 AM","11:00 AM","12:00 PM","1:00 PM","2:00 PM","3:00 PM"], "options": ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM"]
"next_step": 3
}, },
{ {
"state": 3, "state": 2,
"variable": "MONDAY_OUT", "variable": "MONDAY_OUT",
"type": "keyboard", "type": "keyboard",
"question": "Lunes · Hora de salida", "question": "Lunes · Hora de salida",
"options": ["4:00 PM","5:00 PM","6:00 PM","7:00 PM","8:00 PM"], "options": ["4:00 PM", "5:00 PM", "6:00 PM", "7:00 PM", "8:00 PM"]
"next_step": 4
}, },
{ {
"state": 4, "state": 3,
"variable": "TUESDAY_IN", "variable": "TUESDAY_IN",
"type": "keyboard", "type": "keyboard",
"question": "Martes · Hora de entrada", "question": "Martes · Hora de entrada",
"options": ["10:00 AM","11:00 AM","12:00 PM","1:00 PM","2:00 PM","3:00 PM"], "options": ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM"]
"next_step": 5
}, },
{ {
"state": 5, "state": 4,
"variable": "TUESDAY_OUT", "variable": "TUESDAY_OUT",
"type": "keyboard", "type": "keyboard",
"question": "Martes · Hora de salida", "question": "Martes · Hora de salida",
"options": ["4:00 PM","5:00 PM","6:00 PM","7:00 PM","8:00 PM"], "options": ["4:00 PM", "5:00 PM", "6:00 PM", "7:00 PM", "8:00 PM"]
"next_step": 6
}, },
{ {
"state": 6, "state": 5,
"variable": "WEDNESDAY_IN", "variable": "WEDNESDAY_IN",
"type": "keyboard", "type": "keyboard",
"question": "Miércoles · Hora de entrada", "question": "Miércoles · Hora de entrada",
"options": ["10:00 AM","11:00 AM","12:00 PM","1:00 PM","2:00 PM","3:00 PM"], "options": ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM"]
"next_step": 7
}, },
{ {
"state": 7, "state": 6,
"variable": "WEDNESDAY_OUT", "variable": "WEDNESDAY_OUT",
"type": "keyboard", "type": "keyboard",
"question": "Miércoles · Hora de salida", "question": "Miércoles · Hora de salida",
"options": ["4:00 PM","5:00 PM","6:00 PM","7:00 PM","8:00 PM"], "options": ["4:00 PM", "5:00 PM", "6:00 PM", "7:00 PM", "8:00 PM"]
"next_step": 8
}, },
{ {
"state": 8, "state": 7,
"variable": "THURSDAY_IN", "variable": "THURSDAY_IN",
"type": "keyboard", "type": "keyboard",
"question": "Jueves · Hora de entrada", "question": "Jueves · Hora de entrada",
"options": ["10:00 AM","11:00 AM","12:00 PM","1:00 PM","2:00 PM","3:00 PM"], "options": ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM"]
"next_step": 9
}, },
{ {
"state": 9, "state": 8,
"variable": "THURSDAY_OUT", "variable": "THURSDAY_OUT",
"type": "keyboard", "type": "keyboard",
"question": "Jueves · Hora de salida", "question": "Jueves · Hora de salida",
"options": ["4:00 PM","5:00 PM","6:00 PM","7:00 PM","8:00 PM"], "options": ["4:00 PM", "5:00 PM", "6:00 PM", "7:00 PM", "8:00 PM"]
"next_step": 10
}, },
{ {
"state": 10, "state": 9,
"variable": "FRIDAY_IN", "variable": "FRIDAY_IN",
"type": "keyboard", "type": "keyboard",
"question": "Viernes · Hora de entrada", "question": "Viernes · Hora de entrada",
"options": ["10:00 AM","11:00 AM","12:00 PM","1:00 PM","2:00 PM","3:00 PM"], "options": ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM"]
"next_step": 11
}, },
{ {
"state": 11, "state": 10,
"variable": "FRIDAY_OUT", "variable": "FRIDAY_OUT",
"type": "keyboard", "type": "keyboard",
"question": "Viernes · Hora de salida", "question": "Viernes · Hora de salida",
"options": ["4:00 PM","5:00 PM","6:00 PM","7:00 PM","8:00 PM"], "options": ["4:00 PM", "5:00 PM", "6:00 PM", "7:00 PM", "8:00 PM"]
"next_step": 12
}, },
{ {
"state": 12, "state": 11,
"variable": "SATURDAY_IN", "variable": "SATURDAY_IN",
"type": "keyboard", "type": "keyboard",
"question": "Sábado · Hora de entrada", "question": "Sábado · Hora de entrada",
"options": ["10:00 AM","11:00 AM","12:00 PM","1:00 PM","2:00 PM","3:00 PM"], "options": ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM"]
"next_step": 13
}, },
{ {
"state": 13, "state": 12,
"variable": "SATURDAY_OUT", "variable": "SATURDAY_OUT",
"type": "info", "type": "info",
"question": "Sábado · Hora de salida\n\nLa salida del sábado queda registrada automáticamente a las 6:00 PM.", "question": "Sábado · Hora de salida\n\nLa salida del sábado queda registrada automáticamente a las 6:00 PM."
"next_step": -1 },
{
"state": 99,
"variable": "FLOW_END",
"type": "info",
"question": "HORARIOS REGISTRADOS\n\nTus horarios quedaron guardados correctamente. Si necesitas un ajuste, notifícalo a administración."
} }
] ]
} }

View File

@@ -1,10 +1,22 @@
{ {
"flow_name": "leave_request", "flow_name": "leave_request",
"steps": [ "steps": [
{
"state": "INTRO_ALERT",
"variable": "AVISO_INICIAL",
"question": "⏱️ SOLICITUD DE PERMISO POR HORAS\n\nConfirma que le avisaste a tu manager y que necesitas registrar un permiso formal.",
"type": "keyboard",
"options": ["Continuar"]
},
{
"state": "INTRO_SCOPE",
"variable": "INTRO_SCOPE",
"question": "SECCIÓN ÚNICA · FECHAS, HORARIO Y MOTIVO\n\nResponde exactamente lo que se te pide para evitar rechazos.",
"type": "info"
},
{ {
"state": "PERMISO_CUANDO", "state": "PERMISO_CUANDO",
"question": "⏱️ **Solicitud de Permiso**\n\n¿Para cuándo lo necesitas?", "question": "¿Para cuándo lo necesitas?",
"handler": "recibir_cuando_permiso",
"type": "keyboard", "type": "keyboard",
"options": ["Hoy", "Mañana", "Pasado mañana", "Fecha específica"], "options": ["Hoy", "Mañana", "Pasado mañana", "Fecha específica"],
"next_step": [ "next_step": [
@@ -21,7 +33,6 @@
{ {
"state": "PERMISO_ANIO", "state": "PERMISO_ANIO",
"question": "¿Para qué año es el permiso? (elige el actual o el siguiente)", "question": "¿Para qué año es el permiso? (elige el actual o el siguiente)",
"handler": "recibir_anio_permiso",
"type": "keyboard", "type": "keyboard",
"options": ["current_year", "next_year"], "options": ["current_year", "next_year"],
"next_step": "INICIO_DIA" "next_step": "INICIO_DIA"
@@ -29,14 +40,12 @@
{ {
"state": "INICIO_DIA", "state": "INICIO_DIA",
"question": "¿En qué *día* inicia el permiso? (número, ej: 12)", "question": "¿En qué *día* inicia el permiso? (número, ej: 12)",
"handler": "recibir_inicio_dia",
"type": "text", "type": "text",
"next_step": "INICIO_MES" "next_step": "INICIO_MES"
}, },
{ {
"state": "INICIO_MES", "state": "INICIO_MES",
"question": "¿De qué *mes* inicia?", "question": "¿De qué *mes* inicia?",
"handler": "recibir_inicio_mes",
"type": "keyboard", "type": "keyboard",
"options": [ "options": [
"Enero", "Febrero", "Marzo", "Enero", "Febrero", "Marzo",
@@ -49,14 +58,12 @@
{ {
"state": "FIN_DIA", "state": "FIN_DIA",
"question": "¿Qué *día* termina?", "question": "¿Qué *día* termina?",
"handler": "recibir_fin_dia",
"type": "text", "type": "text",
"next_step": "FIN_MES" "next_step": "FIN_MES"
}, },
{ {
"state": "FIN_MES", "state": "FIN_MES",
"question": "¿De qué *mes* termina?", "question": "¿De qué *mes* termina?",
"handler": "recibir_fin_mes",
"type": "keyboard", "type": "keyboard",
"options": [ "options": [
"Enero", "Febrero", "Marzo", "Enero", "Febrero", "Marzo",
@@ -69,15 +76,20 @@
{ {
"state": "HORARIO", "state": "HORARIO",
"question": "¿Cuál es el horario? Ej: `09:00-11:00` o `Todo el día`.", "question": "¿Cuál es el horario? Ej: `09:00-11:00` o `Todo el día`.",
"handler": "recibir_horario",
"type": "text", "type": "text",
"next_step": "MOTIVO" "next_step": "MOTIVO"
}, },
{ {
"state": "MOTIVO", "state": "MOTIVO",
"question": "Entendido. ¿Cuál es el motivo o comentario adicional?", "question": "Entendido. ¿Cuál es el motivo o comentario adicional?",
"handler": "recibir_motivo_fin", "type": "text",
"type": "text" "next_step": "FLOW_END"
},
{
"state": "FLOW_END",
"variable": "FLOW_END",
"question": "✅ Gracias. Tu permiso quedó registrado y RH revisará la información. Si necesitas otro, vuelve a iniciar con /permiso.",
"type": "info"
} }
] ]
} }

View File

@@ -1,55 +1,67 @@
{ {
"flow_name": "onboarding", "flow_name": "onboarding",
"steps": [ "steps": [
{
"state": -2,
"variable": "AVISO_INICIAL",
"question": "⚠️ AVISO IMPORTANTE\n\nEste formulario crea tu expediente oficial.\n\n• Lee cada pregunta con atención.\n• Responde solo lo que se te pide.\n• Escribe /cancelar en cualquier momento para detener el proceso.\n\nConfirma cuando estés lista.",
"type": "keyboard",
"options": ["Comenzar"]
},
{
"state": -1,
"variable": "INTRO_PERSONALES",
"question": "SECCIÓN 1 · DATOS PERSONALES\n\nUsaremos tu información exactamente como aparece en tus documentos oficiales.\n\n(Confirma para continuar)",
"type": "keyboard",
"options": ["Continuar"]
},
{ {
"state": 0, "state": 0,
"variable": "NOMBRE_SALUDO", "variable": "NOMBRE_SALUDO",
"question": "¿Cómo te gusta que te llamemos?", "question": "¿Cómo te gusta que te llamemos?",
"type": "text", "type": "text"
"next_step": 1
}, },
{ {
"state": 1, "state": 1,
"variable": "NOMBRE_COMPLETO", "variable": "NOMBRE_COMPLETO",
"question": "Escribe tus nombres (SIN apellidos), exactamente como aparecen en tu INE.", "question": "Escribe tus nombres (SIN apellidos), exactamente como aparecen en tu INE.",
"type": "text", "type": "text"
"next_step": 2
}, },
{ {
"state": 2, "state": 2,
"variable": "APELLIDO_PATERNO", "variable": "APELLIDO_PATERNO",
"question": "Apellido paterno:", "question": "Apellido paterno:",
"type": "text", "type": "text"
"next_step": 3
}, },
{ {
"state": 3, "state": 3,
"variable": "APELLIDO_MATERNO", "variable": "APELLIDO_MATERNO",
"question": "Apellido materno:", "question": "Apellido materno:",
"type": "text", "type": "text"
"next_step": 4
}, },
{ {
"state": 4, "state": 4,
"variable": "CUMPLE_DIA", "variable": "CUMPLE_DIA",
"question": "Fecha de nacimiento · Día (solo número, ej. 13)", "question": "Fecha de nacimiento · Día (solo número, ej. 13)",
"type": "text", "type": "text"
"next_step": 5
}, },
{ {
"state": 5, "state": 5,
"variable": "CUMPLE_MES", "variable": "CUMPLE_MES",
"question": "Fecha de nacimiento · Mes", "question": "Fecha de nacimiento · Mes",
"type": "keyboard", "type": "keyboard",
"options": ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"], "options": [
"next_step": 6 "Enero", "Febrero", "Marzo",
"Abril", "Mayo", "Junio",
"Julio", "Agosto", "Septiembre",
"Octubre", "Noviembre", "Diciembre"
]
}, },
{ {
"state": 6, "state": 6,
"variable": "CUMPLE_ANIO", "variable": "CUMPLE_ANIO",
"question": "Fecha de nacimiento · Año (4 dígitos)", "question": "Fecha de nacimiento · Año (4 dígitos)",
"type": "text", "type": "text"
"next_step": 7
}, },
{ {
"state": 7, "state": 7,
@@ -59,7 +71,7 @@
"options": ["Coahuila", "Nuevo León", "Otro"], "options": ["Coahuila", "Nuevo León", "Otro"],
"next_steps": [ "next_steps": [
{ "value": "Otro", "go_to": 7.1 }, { "value": "Otro", "go_to": 7.1 },
{ "value": "default", "go_to": 8 } { "value": "default", "go_to": 7.5 }
] ]
}, },
{ {
@@ -67,70 +79,68 @@
"variable": "ESTADO_NACIMIENTO_OTRO", "variable": "ESTADO_NACIMIENTO_OTRO",
"question": "Escribe el nombre del estado donde naciste.", "question": "Escribe el nombre del estado donde naciste.",
"type": "text", "type": "text",
"next_step": 8 "next_step": 7.5
},
{
"state": 7.5,
"variable": "INTRO_ADMIN",
"question": "SECCIÓN 2 · DATOS ADMINISTRATIVOS\n\nEstos datos se usan para contrato, nómina y comunicación oficial.\n\n(Confirma para continuar)",
"type": "keyboard",
"options": ["Continuar"]
}, },
{ {
"state": 8, "state": 8,
"variable": "RFC", "variable": "RFC",
"question": "RFC completo (13 caracteres, sin espacios):", "question": "RFC completo (13 caracteres, sin espacios):",
"type": "text", "type": "text"
"next_step": 9
}, },
{ {
"state": 9, "state": 9,
"variable": "CURP", "variable": "CURP",
"question": "CURP completo (18 caracteres):", "question": "CURP completo (18 caracteres):",
"type": "text", "type": "text"
"next_step": 10
}, },
{ {
"state": 10, "state": 10,
"variable": "CORREO", "variable": "CORREO",
"question": "Correo electrónico personal:", "question": "Correo electrónico personal:",
"type": "text", "type": "text"
"next_step": 11
}, },
{ {
"state": 11, "state": 11,
"variable": "CELULAR", "variable": "CELULAR",
"question": "Número de celular (10 dígitos):", "question": "Número de celular (10 dígitos):",
"type": "text", "type": "text"
"next_step": 12
}, },
{ {
"state": 12, "state": 12,
"variable": "CALLE", "variable": "CALLE",
"question": "Domicilio · Calle:", "question": "Domicilio · Calle:",
"type": "text", "type": "text"
"next_step": 13
}, },
{ {
"state": 13, "state": 13,
"variable": "NUM_EXTERIOR", "variable": "NUM_EXTERIOR",
"question": "Domicilio · Número exterior:", "question": "Domicilio · Número exterior:",
"type": "text", "type": "text"
"next_step": 14
}, },
{ {
"state": 14, "state": 14,
"variable": "NUM_INTERIOR", "variable": "NUM_INTERIOR",
"question": "Domicilio · Número interior (0 si no aplica):", "question": "Domicilio · Número interior (0 si no aplica):",
"type": "text", "type": "text"
"next_step": 15
}, },
{ {
"state": 15, "state": 15,
"variable": "COLONIA", "variable": "COLONIA",
"question": "Domicilio · Colonia:", "question": "Domicilio · Colonia:",
"type": "text", "type": "text"
"next_step": 16
}, },
{ {
"state": 16, "state": 16,
"variable": "CODIGO_POSTAL", "variable": "CODIGO_POSTAL",
"question": "Código Postal (5 dígitos):", "question": "Código Postal (5 dígitos):",
"type": "text", "type": "text"
"next_step": 17
}, },
{ {
"state": 17, "state": 17,
@@ -155,167 +165,179 @@
"variable": "ROL", "variable": "ROL",
"question": "Rol dentro del equipo:", "question": "Rol dentro del equipo:",
"type": "keyboard", "type": "keyboard",
"options": ["Belleza", "Staff (Recepción)", "Marketing"], "options": ["Belleza", "Staff (Recepción)", "Marketing"]
"next_step": 19
}, },
{ {
"state": 19, "state": 19,
"variable": "SUCURSAL", "variable": "SUCURSAL",
"question": "Sucursal principal:", "question": "Sucursal principal:",
"type": "keyboard", "type": "keyboard",
"options": ["Plaza Cima (Sur)", "Plaza O (Carranza)"], "options": ["Plaza Cima (Sur)", "Plaza O (Carranza)"]
"next_step": 20
}, },
{ {
"state": 20, "state": 20,
"variable": "INICIO_DIA", "variable": "INICIO_DIA",
"question": "Fecha de ingreso · Día:", "question": "Fecha de ingreso · Día:",
"type": "text", "type": "text"
"next_step": 21
}, },
{ {
"state": 21, "state": 21,
"variable": "INICIO_MES", "variable": "INICIO_MES",
"question": "Fecha de ingreso · Mes:", "question": "Fecha de ingreso · Mes:",
"type": "keyboard", "type": "keyboard",
"options": ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"], "options": [
"next_step": 22 "Enero", "Febrero", "Marzo",
"Abril", "Mayo", "Junio",
"Julio", "Agosto", "Septiembre",
"Octubre", "Noviembre", "Diciembre"
]
}, },
{ {
"state": 22, "state": 22,
"variable": "INICIO_ANIO", "variable": "INICIO_ANIO",
"question": "Fecha de ingreso · Año:", "question": "Fecha de ingreso · Año:",
"type": "keyboard", "type": "keyboard",
"options": ["2024", "2025", "2026"], "options": ["2024", "2025", "2026"]
"next_step": 23 },
{
"state": 22.5,
"variable": "INTRO_EMERGENCIA",
"question": "SECCIÓN 3 · CONTACTO DE EMERGENCIA\n\nEsta persona solo será contactada en una emergencia real.\n\n(Confirma para continuar)",
"type": "keyboard",
"options": ["Continuar"]
}, },
{ {
"state": 23, "state": 23,
"variable": "REF1_NOMBRE", "variable": "EMERGENCIA_NOMBRE",
"question": "Referencia 1 · Nombre completo:", "question": "Contacto de emergencia · Nombre completo:",
"type": "text", "type": "text"
"next_step": 24
}, },
{ {
"state": 24, "state": 24,
"variable": "REF1_TELEFONO", "variable": "EMERGENCIA_TEL",
"question": "Referencia 1 · Teléfono:", "question": "Contacto de emergencia · Teléfono:",
"type": "text", "type": "text"
"next_step": 25
}, },
{ {
"state": 25, "state": 25,
"variable": "REF1_TIPO", "variable": "EMERGENCIA_RELACION",
"question": "Referencia 1 · Relación:", "question": "Relación con el contacto de emergencia:",
"type": "keyboard", "type": "keyboard",
"options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"], "options": ["Padre/Madre", "Pareja", "Hermano/a", "Hijo/a", "Amigo/a", "Otro"],
"next_steps": [ "next_steps": [
{ "value": "Otra", "go_to": 25.1 }, { "value": "Otro", "go_to": 25.1 },
{ "value": "default", "go_to": 26 } { "value": "default", "go_to": 26 }
] ]
}, },
{ {
"state": 25.1, "state": 25.1,
"variable": "REF1_TIPO_OTRA", "variable": "EMERGENCIA_RELACION_OTRA",
"question": "Especifíca la relación con la Referencia 1:", "question": "Describe la relación con tu contacto de emergencia:",
"type": "text", "type": "text",
"next_step": 26 "next_step": 26
}, },
{ {
"state": 26, "state": 26,
"variable": "REF2_NOMBRE", "variable": "INTRO_REFERENCIAS",
"question": "Referencia 2 · Nombre completo:", "question": "SECCIÓN 4 · REFERENCIAS PERSONALES\n\nNecesitamos 3 referencias reales que te conozcan.\n\n(Confirma para continuar)",
"type": "text", "type": "keyboard",
"next_step": 27 "options": ["Continuar"]
}, },
{ {
"state": 27, "state": 27,
"variable": "REF2_TELEFONO", "variable": "REF1_NOMBRE",
"question": "Referencia 2 · Teléfono:", "question": "Referencia 1 · Nombre completo:",
"type": "text", "type": "text"
"next_step": 28
}, },
{ {
"state": 28, "state": 28,
"variable": "REF2_TIPO", "variable": "REF1_TELEFONO",
"question": "Referencia 2 · Relación:", "question": "Referencia 1 · Teléfono:",
"type": "keyboard", "type": "text"
"options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"],
"next_steps": [
{ "value": "Otra", "go_to": 28.1 },
{ "value": "default", "go_to": 29 }
]
},
{
"state": 28.1,
"variable": "REF2_TIPO_OTRA",
"question": "Especifíca la relación con la Referencia 2:",
"type": "text",
"next_step": 29
}, },
{ {
"state": 29, "state": 29,
"variable": "REF3_NOMBRE", "variable": "REF1_TIPO",
"question": "Referencia 3 · Nombre completo:", "question": "Referencia 1 · Relación:",
"type": "keyboard",
"options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"],
"next_steps": [
{ "value": "Otra", "go_to": 29.1 },
{ "value": "default", "go_to": 30 }
]
},
{
"state": 29.1,
"variable": "REF1_TIPO_OTRA",
"question": "Especifica la relación con la Referencia 1:",
"type": "text", "type": "text",
"next_step": 30 "next_step": 30
}, },
{ {
"state": 30, "state": 30,
"variable": "REF3_TELEFONO", "variable": "REF2_NOMBRE",
"question": "Referencia 3 · Teléfono:", "question": "Referencia 2 · Nombre completo:",
"type": "text", "type": "text"
"next_step": 31
}, },
{ {
"state": 31, "state": 31,
"variable": "REF3_TIPO", "variable": "REF2_TELEFONO",
"question": "Referencia 3 · Relación:", "question": "Referencia 2 · Teléfono:",
"type": "keyboard", "type": "text"
"options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"],
"next_steps": [
{ "value": "Otra", "go_to": 31.1 },
{ "value": "default", "go_to": 32 }
]
},
{
"state": 31.1,
"variable": "REF3_TIPO_OTRA",
"question": "Especifíca la relación con la Referencia 3:",
"type": "text",
"next_step": 32
}, },
{ {
"state": 32, "state": 32,
"variable": "EMERGENCIA_NOMBRE", "variable": "REF2_TIPO",
"question": "Contacto de emergencia · Nombre completo:", "question": "Referencia 2 · Relación:",
"type": "keyboard",
"options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"],
"next_steps": [
{ "value": "Otra", "go_to": 32.1 },
{ "value": "default", "go_to": 33 }
]
},
{
"state": 32.1,
"variable": "REF2_TIPO_OTRA",
"question": "Especifica la relación con la Referencia 2:",
"type": "text", "type": "text",
"next_step": 33 "next_step": 33
}, },
{ {
"state": 33, "state": 33,
"variable": "EMERGENCIA_TEL", "variable": "REF3_NOMBRE",
"question": "Contacto de emergencia · Teléfono:", "question": "Referencia 3 · Nombre completo:",
"type": "text", "type": "text"
"next_step": 34
}, },
{ {
"state": 34, "state": 34,
"variable": "EMERGENCIA_RELACION", "variable": "REF3_TELEFONO",
"question": "Relación con el contacto de emergencia:", "question": "Referencia 3 · Teléfono:",
"type": "text"
},
{
"state": 35,
"variable": "REF3_TIPO",
"question": "Referencia 3 · Relación:",
"type": "keyboard", "type": "keyboard",
"options": ["Padre/Madre", "Esposo/a", "Pareja", "Hijo/a", "Hermano/a", "Amigo/a", "Otro"], "options": ["Familiar", "Amistad", "Trabajo", "Académica", "Otra"],
"next_steps": [ "next_steps": [
{ "value": "Otro", "go_to": 34.1 }, { "value": "Otra", "go_to": 35.1 },
{ "value": "default", "go_to": -1 } { "value": "default", "go_to": 99 }
] ]
}, },
{ {
"state": 34.1, "state": 35.1,
"variable": "EMERGENCIA_RELACION_OTRA", "variable": "REF3_TIPO_OTRA",
"question": "Especifíca la relación con el contacto de emergencia:", "question": "Especifica la relación con la Referencia 3:",
"type": "text", "type": "text",
"next_step": -1 "next_step": 99
},
{
"state": 99,
"variable": "FLOW_END",
"question": "✅ ¡Gracias!\n\nTu registro quedó completo. RH validará la información y te confirmará los siguientes pasos.\n\nSi necesitas corregir algo, escribe /cancelar y empieza de nuevo.",
"type": "info"
} }
] ]
} }

View File

@@ -2,15 +2,28 @@
"flow_name": "vacations", "flow_name": "vacations",
"steps": [ "steps": [
{ {
"state": "INICIO_DIA", "state": -2,
"question": "🌴 **Solicitud de Vacaciones**\n\nVamos a registrar tu descanso. ¿Qué *día* inicia? (número, ej: 10)", "variable": "AVISO_GENERAL",
"handler": "recibir_inicio_dia", "question": "🌴 SOLICITUD DE VACACIONES\n\nAntes de registrar tus fechas confirma que:\n\n• Ya hablaste con tu manager\n• Las fechas no se traslapan con guardias críticas\n• Sabes que el bot notificará al equipo de RH\n\nConfirma para continuar.",
"type": "keyboard",
"options": ["Continuar"]
},
{
"state": -1,
"variable": "INTRO_FECHAS",
"question": "SECCIÓN ÚNICA · FECHAS Y MOTIVO\n\nComparte las fechas exactas del descanso y cualquier comentario relevante.",
"type": "info"
},
{
"state": 0,
"variable": "INICIO_DIA",
"question": "¿Qué *día* inicia tu descanso? (número, ej: 10)",
"type": "text" "type": "text"
}, },
{ {
"state": "INICIO_MES", "state": 1,
"variable": "INICIO_MES",
"question": "¿De qué *mes* inicia?", "question": "¿De qué *mes* inicia?",
"handler": "recibir_inicio_mes",
"type": "keyboard", "type": "keyboard",
"options": [ "options": [
"Enero", "Febrero", "Marzo", "Enero", "Febrero", "Marzo",
@@ -20,22 +33,22 @@
] ]
}, },
{ {
"state": "INICIO_ANIO", "state": 2,
"question": "¿De qué *año* inicia?", "variable": "INICIO_ANIO",
"handler": "recibir_inicio_anio", "question": "¿De qué *año* inicia? (elige el actual o el siguiente)",
"type": "keyboard", "type": "keyboard",
"options": ["current_year", "next_year"] "options": ["current_year", "next_year"]
}, },
{ {
"state": "FIN_DIA", "state": 3,
"variable": "FIN_DIA",
"question": "¿Qué *día* termina tu descanso?", "question": "¿Qué *día* termina tu descanso?",
"handler": "recibir_fin_dia",
"type": "text" "type": "text"
}, },
{ {
"state": "FIN_MES", "state": 4,
"variable": "FIN_MES",
"question": "¿De qué *mes* termina?", "question": "¿De qué *mes* termina?",
"handler": "recibir_fin_mes",
"type": "keyboard", "type": "keyboard",
"options": [ "options": [
"Enero", "Febrero", "Marzo", "Enero", "Febrero", "Marzo",
@@ -45,17 +58,23 @@
] ]
}, },
{ {
"state": "FIN_ANIO", "state": 5,
"question": "¿De qué *año* termina tu descanso?", "variable": "FIN_ANIO",
"handler": "recibir_fin_anio", "question": "¿De qué *año* termina tu descanso? (elige el actual o el siguiente)",
"type": "keyboard", "type": "keyboard",
"options": ["current_year", "next_year"] "options": ["current_year", "next_year"]
}, },
{ {
"state": "MOTIVO", "state": 6,
"variable": "MOTIVO",
"question": "Entendido. ¿Cuál es el motivo o comentario adicional?", "question": "Entendido. ¿Cuál es el motivo o comentario adicional?",
"handler": "recibir_motivo_fin",
"type": "text" "type": "text"
},
{
"state": 99,
"variable": "FLOW_END",
"question": "✅ Solicitud registrada.\n\nRH validará la información y te confirmará si las fechas quedan asignadas. Si necesitas cambiar algo, vuelve a iniciar con /vacaciones.",
"type": "info"
} }
] ]
} }

View File

@@ -106,6 +106,16 @@ CREATE TABLE IF NOT EXISTS permisos (
FOREIGN KEY (numero_empleado) REFERENCES data_empleadas(numero_empleado) FOREIGN KEY (numero_empleado) REFERENCES 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 data_empleadas(numero_empleado)
);
USE vanity_attendance; USE vanity_attendance;
CREATE TABLE IF NOT EXISTS asistencia_registros ( CREATE TABLE IF NOT EXISTS asistencia_registros (
@@ -120,32 +130,3 @@ CREATE TABLE IF NOT EXISTS asistencia_registros (
telegram_id_usado BIGINT, telegram_id_usado BIGINT,
FOREIGN KEY (numero_empleado) REFERENCES vanity_hr.data_empleadas(numero_empleado) 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)
);
CREATE TABLE IF NOT EXISTS horarios_configurados (
id INT AUTO_INCREMENT PRIMARY KEY,
telegram_id BIGINT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
short_name VARCHAR(100),
monday_in TIME,
monday_out TIME,
tuesday_in TIME,
tuesday_out TIME,
wednesday_in TIME,
wednesday_out TIME,
thursday_in TIME,
thursday_out TIME,
friday_in TIME,
friday_out TIME,
saturday_in TIME,
saturday_out TIME
);

View File

@@ -108,6 +108,20 @@ Tabla central de Recursos Humanos. Contiene información contractual, personal,
| con_goce_sueldo | tinyint(1) | | 0 / 1 | | con_goce_sueldo | tinyint(1) | | 0 / 1 |
| afecta_nomina | tinyint(1) | | Impacto | | afecta_nomina | tinyint(1) | | Impacto |
#### 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 |
*Los capturados desde `/horario` generan un registro por día mediante upsert (por `telegram_id` + `dia_semana`).*
--- ---
### 2.2 Base de Datos: `vanity_attendance` ### 2.2 Base de Datos: `vanity_attendance`
@@ -128,19 +142,6 @@ Tabla central de Recursos Humanos. Contiene información contractual, personal,
--- ---
#### 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` ### 2.3 Base de Datos: `USERS_ALMA`
#### Tabla: `users` (10 campos) #### Tabla: `users` (10 campos)
@@ -171,7 +172,7 @@ Tabla central de Recursos Humanos. Contiene información contractual, personal,
### 3.2 Asistencia ### 3.2 Asistencia
* Identificación por `telegram_id` * Identificación por `telegram_id`
* Cruce con `horario_empleadas` según día * Cruce con `vanity_hr.horario_empleadas` según día
* Cálculo de retraso contra horario teórico * Cálculo de retraso contra horario teórico
--- ---
@@ -180,6 +181,7 @@ Tabla central de Recursos Humanos. Contiene información contractual, personal,
* **Identificación**: `body.telegram.user_id` * **Identificación**: `body.telegram.user_id`
* **Operación**: Upsert por día * **Operación**: Upsert por día
* **Persistencia**: `vanity_hr.horario_empleadas` (clave `telegram_id` + `dia_semana`)
* **Formato**: conversión de `10:00 AM``10:00:00` * **Formato**: conversión de `10:00 AM``10:00:00`
--- ---
@@ -199,7 +201,7 @@ LIMIT 1;
-- Horario del día -- Horario del día
SELECT hora_entrada_teorica SELECT hora_entrada_teorica
FROM vanity_attendance.horario_empleadas FROM vanity_hr.horario_empleadas
WHERE telegram_id = ? AND dia_semana = 'monday'; WHERE telegram_id = ? AND dia_semana = 'monday';
``` ```

View File

@@ -20,6 +20,8 @@ from modules.flow_builder import load_flows
from modules.logger import log_request from modules.logger import log_request
from modules.database import chat_id_exists # Importar chat_id_exists from modules.database import chat_id_exists # Importar chat_id_exists
from modules.ui import main_actions_keyboard from modules.ui import main_actions_keyboard
from modules.onboarding import onboarding_handler
from modules.rh_requests import vacaciones_handler, permiso_handler
# from modules.finder import finder_handler (Si lo creas después) # from modules.finder import finder_handler (Si lo creas después)
# Cargar links desde variables de entorno # Cargar links desde variables de entorno
@@ -118,6 +120,10 @@ def main():
for handler in flow_handlers: for handler in flow_handlers:
app.add_handler(handler) app.add_handler(handler)
app.add_handler(onboarding_handler)
app.add_handler(vacaciones_handler)
app.add_handler(permiso_handler)
app.add_handler(CommandHandler("links", links_menu)) app.add_handler(CommandHandler("links", links_menu))
# app.add_handler(finder_handler) # app.add_handler(finder_handler)

View File

@@ -18,36 +18,3 @@ class AsistenciaRegistros(Base):
sucursal_registro = Column(String(50)) sucursal_registro = Column(String(50))
telegram_id_usado = Column(BigInteger) telegram_id_usado = Column(BigInteger)
empleada = relationship("DataEmpleadas", backref="asistencia_registros") 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")
class HorariosConfigurados(Base):
__tablename__ = 'horarios_configurados'
__table_args__ = {'schema': 'vanity_attendance'}
id = Column(Integer, primary_key=True, autoincrement=True)
telegram_id = Column(BigInteger, nullable=False)
timestamp = Column(Time)
short_name = Column(String(100))
monday_in = Column(Time)
monday_out = Column(Time)
tuesday_in = Column(Time)
tuesday_out = Column(Time)
wednesday_in = Column(Time)
wednesday_out = Column(Time)
thursday_in = Column(Time)
thursday_out = Column(Time)
friday_in = Column(Time)
friday_out = Column(Time)
saturday_in = Column(Time)
saturday_out = Column(Time)

View File

@@ -1,4 +1,4 @@
from sqlalchemy import create_engine, Column, Integer, String, Enum, TIMESTAMP, Date, Text, BigInteger, DateTime from sqlalchemy import create_engine, Column, Integer, String, Enum, TIMESTAMP, Date, Text, BigInteger, DateTime, Time
from sqlalchemy.dialects.mysql import TINYINT from sqlalchemy.dialects.mysql import TINYINT
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func from sqlalchemy.sql import func
@@ -90,3 +90,15 @@ class Permisos(Base):
con_goce_sueldo = Column(TINYINT) con_goce_sueldo = Column(TINYINT)
afecta_nomina = Column(TINYINT) afecta_nomina = Column(TINYINT)
empleada = relationship("DataEmpleadas") empleada = relationship("DataEmpleadas")
class HorarioEmpleadas(Base):
__tablename__ = 'horario_empleadas'
__table_args__ = {'schema': 'vanity_hr'}
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="horarios_registrados")

View File

@@ -4,8 +4,8 @@ from datetime import datetime, date
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from models.users_alma_models import Base as BaseUsersAlma, User 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_hr_models import Base as BaseVanityHr, DataEmpleadas, Vacaciones, Permisos, HorarioEmpleadas
from models.vanity_attendance_models import Base as BaseVanityAttendance, AsistenciaRegistros, HorarioEmpleadas from models.vanity_attendance_models import Base as BaseVanityAttendance, AsistenciaRegistros
# --- DATABASE (MySQL) SETUP --- # --- DATABASE (MySQL) SETUP ---

View File

@@ -2,10 +2,10 @@ import os
import json import json
import logging import logging
import requests import requests
from datetime import datetime from datetime import datetime, time as time_cls
from sqlalchemy.orm import sessionmaker
from modules.database import get_engine from modules.database import SessionVanityHr
from models.vanity_attendance_models import HorariosConfigurados from models.vanity_hr_models import HorarioEmpleadas, DataEmpleadas
def _send_webhook(url: str, payload: dict): def _send_webhook(url: str, payload: dict):
"""Sends a POST request to a webhook.""" """Sends a POST request to a webhook."""
@@ -39,50 +39,87 @@ def _finalize_horario(telegram_id: int, data: dict):
"""Finalizes the 'horario' flow.""" """Finalizes the 'horario' flow."""
logging.info(f"Finalizing 'horario' flow for telegram_id: {telegram_id}") logging.info(f"Finalizing 'horario' flow for telegram_id: {telegram_id}")
# 1. Prepare data # 1. Prepare data for webhook and DB
day_pairs = [
("monday", "MONDAY_IN", "MONDAY_OUT"),
("tuesday", "TUESDAY_IN", "TUESDAY_OUT"),
("wednesday", "WEDNESDAY_IN", "WEDNESDAY_OUT"),
("thursday", "THURSDAY_IN", "THURSDAY_OUT"),
("friday", "FRIDAY_IN", "FRIDAY_OUT"),
("saturday", "SATURDAY_IN", None),
]
schedule_data = { schedule_data = {
"telegram_id": telegram_id, "telegram_id": telegram_id,
"short_name": data.get("SHORT_NAME"), "short_name": data.get("SHORT_NAME"),
"monday_in": _convert_to_time(data.get("MONDAY_IN")),
"monday_out": _convert_to_time(data.get("MONDAY_OUT")),
"tuesday_in": _convert_to_time(data.get("TUESDAY_IN")),
"tuesday_out": _convert_to_time(data.get("TUESDAY_OUT")),
"wednesday_in": _convert_to_time(data.get("WEDNESDAY_IN")),
"wednesday_out": _convert_to_time(data.get("WEDNESDAY_OUT")),
"thursday_in": _convert_to_time(data.get("THURSDAY_IN")),
"thursday_out": _convert_to_time(data.get("THURSDAY_OUT")),
"friday_in": _convert_to_time(data.get("FRIDAY_IN")),
"friday_out": _convert_to_time(data.get("FRIDAY_OUT")),
"saturday_in": _convert_to_time(data.get("SATURDAY_IN")),
"saturday_out": _convert_to_time("6:00 PM"), # Hardcoded as per flow
} }
rows_for_db = []
for day_key, in_key, out_key in day_pairs:
entrada = _convert_to_time(data.get(in_key))
salida_raw = data.get(out_key) if out_key else "6:00 PM"
salida = _convert_to_time(salida_raw)
schedule_data[f"{day_key}_in"] = entrada
schedule_data[f"{day_key}_out"] = salida
if not entrada or not salida:
logging.warning(f"Missing schedule data for {day_key}. Entrada: {entrada}, Salida: {salida}")
continue
rows_for_db.append(
{
"dia_semana": day_key,
"hora_entrada": entrada,
"hora_salida": salida,
}
)
# 2. Send to webhook # 2. Send to webhook
webhook_url = os.getenv("WEBHOOK_SCHEDULE") webhook_url = os.getenv("WEBHOOK_SCHEDULE")
if webhook_url: if webhook_url:
# Create a JSON-serializable payload json_payload = {
json_payload = {k: (v.isoformat() if isinstance(v, datetime.time) else v) for k, v in schedule_data.items()} k: (v.isoformat() if isinstance(v, time_cls) else v) for k, v in schedule_data.items()
}
json_payload["timestamp"] = datetime.now().isoformat() json_payload["timestamp"] = datetime.now().isoformat()
_send_webhook(webhook_url, json_payload) _send_webhook(webhook_url, json_payload)
# 3. Save to database # 3. Save to database (vanity_hr.horario_empleadas)
engine = get_engine() if not SessionVanityHr:
Session = sessionmaker(bind=engine) logging.error("SessionVanityHr is not initialized. Cannot persist horarios.")
session = Session() return False
session = SessionVanityHr()
try: try:
# Upsert logic: Check if a record for this telegram_id already exists empleada = session.query(DataEmpleadas).filter(DataEmpleadas.telegram_chat_id == telegram_id).first()
existing_schedule = session.query(HorariosConfigurados).filter_by(telegram_id=telegram_id).first() numero_empleado = empleada.numero_empleado if empleada else None
if existing_schedule: if not numero_empleado:
# Update existing record logging.warning(f"No se encontró numero_empleado para telegram_id={telegram_id}. Se guardará NULL.")
for key, value in schedule_data.items():
setattr(existing_schedule, key, value) existing_rows = {
existing_schedule.timestamp = datetime.now() row.dia_semana: row
logging.info(f"Updating existing schedule for telegram_id: {telegram_id}") for row in session.query(HorarioEmpleadas).filter_by(telegram_id=telegram_id).all()
}
for row in rows_for_db:
dia = row["dia_semana"]
entrada = row["hora_entrada"]
salida = row["hora_salida"]
existing = existing_rows.get(dia)
if existing:
existing.numero_empleado = numero_empleado or existing.numero_empleado
existing.hora_entrada_teorica = entrada
existing.hora_salida_teorica = salida
else: else:
# Create new record session.add(
new_schedule = HorariosConfigurados(**schedule_data) HorarioEmpleadas(
session.add(new_schedule) numero_empleado=numero_empleado,
logging.info(f"Creating new schedule for telegram_id: {telegram_id}") telegram_id=telegram_id,
dia_semana=dia,
hora_entrada_teorica=entrada,
hora_salida_teorica=salida,
)
)
session.commit() session.commit()
return True return True

View File

@@ -1,105 +1,153 @@
import ast
import json import json
import os
import logging import logging
import os
from functools import partial from functools import partial
from telegram import Update, ReplyKeyboardRemove, ReplyKeyboardMarkup
from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update
from telegram.ext import ( from telegram.ext import (
CommandHandler,
ContextTypes, ContextTypes,
ConversationHandler, ConversationHandler,
MessageHandler, MessageHandler,
CommandHandler,
filters, filters,
) )
from .finalizer import finalize_flow from .finalizer import finalize_flow
async def end_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.message.reply_text("Flujo cancelado.") def _build_keyboard(options):
keyboard = [options[i : i + 2] for i in range(0, len(options), 2)]
return ReplyKeyboardMarkup(
keyboard,
one_time_keyboard=True,
resize_keyboard=True,
)
def _preprocess_flow(flow: dict):
"""Populate missing next_step values assuming a linear order."""
steps = flow.get("steps", [])
for idx, step in enumerate(steps):
if "next_step" in step or "next_steps" in step:
continue
if idx + 1 < len(steps):
step["next_step"] = steps[idx + 1]["state"]
else:
step["next_step"] = -1
def _find_step(flow: dict, state_key):
return next((step for step in flow["steps"] if step["state"] == state_key), None)
ALLOWED_AST_NODES = (
ast.Expression,
ast.BoolOp,
ast.Compare,
ast.Name,
ast.Load,
ast.Constant,
ast.List,
ast.Tuple,
ast.And,
ast.Or,
ast.Eq,
ast.NotEq,
ast.In,
ast.NotIn,
)
def _evaluate_condition(condition: str, response: str) -> bool:
"""Safely evaluate expressions like `response in ['Hoy', 'Mañana']`."""
if not condition:
return False
try:
tree = ast.parse(condition, mode="eval")
for node in ast.walk(tree):
if not isinstance(node, ALLOWED_AST_NODES):
raise ValueError(f"Unsupported expression: {condition}")
compiled = compile(tree, "<condition>", "eval")
return bool(eval(compiled, {"__builtins__": {}}, {"response": response}))
except Exception as exc:
logging.warning("Failed to evaluate condition '%s': %s", condition, exc)
return False
def _determine_next_state(step: dict, user_answer: str):
"""Resolve the next state declared in the JSON step."""
if "next_steps" in step:
default_target = None
for option in step["next_steps"]:
value = option.get("value")
if value == "default":
default_target = option.get("go_to")
elif user_answer == value:
return option.get("go_to")
return default_target
next_step = step.get("next_step")
if isinstance(next_step, list):
default_target = None
for option in next_step:
condition = option.get("condition")
target = option.get("state")
if condition:
if _evaluate_condition(condition, user_answer):
return target
elif option.get("value") and user_answer == option["value"]:
return target
elif option.get("default"):
default_target = target
return default_target
return next_step
async def _go_to_state(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict, state_key):
"""Send the question for the requested state, skipping info-only steps."""
safety_counter = 0
while True:
safety_counter += 1
if safety_counter > len(flow["steps"]) + 2:
logging.error("Detected potential loop while traversing flow '%s'", flow.get("flow_name"))
await update.message.reply_text("Ocurrió un error al continuar con el flujo. Intenta iniciar de nuevo.")
return ConversationHandler.END return ConversationHandler.END
async def generic_callback(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict): if state_key == -1:
current_state_key = context.user_data.get("current_state", 0)
current_step = next((step for step in flow["steps"] if step["state"] == current_state_key), None)
if not current_step:
await update.message.reply_text("Hubo un error en el flujo. Por favor, inicia de nuevo.")
return ConversationHandler.END
user_answer = update.message.text
variable_name = current_step.get("variable")
if variable_name:
context.user_data[variable_name] = user_answer
next_state_key = None
if "next_steps" in current_step:
for condition in current_step["next_steps"]:
if condition.get("value") == user_answer:
next_state_key = condition["go_to"]
break
elif condition.get("value") == "default":
next_state_key = condition["go_to"]
elif "next_step" in current_step:
next_state_key = current_step["next_step"]
if next_state_key is None:
return await end_cancel(update, context)
if next_state_key == -1:
await finalize_flow(update, context) await finalize_flow(update, context)
return ConversationHandler.END return ConversationHandler.END
next_step = next((step for step in flow["steps"] if step["state"] == next_state_key), None) next_step = _find_step(flow, state_key)
if not next_step: if not next_step:
await update.message.reply_text("Error: No se encontró el siguiente paso del flujo.") await update.message.reply_text("Error: No se encontró el siguiente paso del flujo.")
return ConversationHandler.END return ConversationHandler.END
reply_markup = ReplyKeyboardRemove() reply_markup = ReplyKeyboardRemove()
if next_step.get("type") == "keyboard" and "options" in next_step: if next_step.get("type") == "keyboard" and "options" in next_step:
# Create a 2D array for the keyboard reply_markup = _build_keyboard(next_step["options"])
options = next_step["options"]
keyboard = [options[i:i+2] for i in range(0, len(options), 2)]
reply_markup = ReplyKeyboardMarkup(
keyboard,
one_time_keyboard=True, resize_keyboard=True
)
await update.message.reply_text(next_step["question"], reply_markup=reply_markup) await update.message.reply_text(next_step["question"], reply_markup=reply_markup)
context.user_data["current_state"] = state_key
context.user_data["current_state"] = next_state_key if next_step.get("type") == "info":
return next_state_key state_key = _determine_next_state(next_step, None)
if state_key is None:
await update.message.reply_text("No se pudo continuar con el flujo actual. Intenta iniciar de nuevo.")
return ConversationHandler.END
continue
return state_key
async def start_flow(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict):
context.user_data.clear()
context.user_data["flow_name"] = flow["flow_name"]
first_step = flow["steps"][0]
context.user_data["current_state"] = first_step["state"]
reply_markup = ReplyKeyboardRemove()
if first_step.get("type") == "keyboard" and "options" in first_step:
options = first_step["options"]
keyboard = [options[i:i+2] for i in range(0, len(options), 2)]
reply_markup = ReplyKeyboardMarkup(
keyboard,
one_time_keyboard=True, resize_keyboard=True
)
await update.message.reply_text(first_step["question"], reply_markup=reply_markup)
return first_step["state"]
def create_handler(flow: dict): def create_handler(flow: dict):
states = {} states = {}
all_states = sorted(list(set([step["state"] for step in flow["steps"]]))) all_states = sorted(list(set([step["state"] for step in flow["steps"]])))
for state_key in all_states: for state_key in all_states:
# Skip the end state
if state_key == -1: if state_key == -1:
continue continue
callback = partial(generic_callback, flow=flow) callback = partial(generic_callback, flow=flow)
states[state_key] = [MessageHandler(filters.TEXT & ~filters.COMMAND, callback)] states[state_key] = [MessageHandler(filters.TEXT & ~filters.COMMAND, callback)]
@@ -112,6 +160,40 @@ def create_handler(flow: dict):
allow_reentry=True, allow_reentry=True,
) )
async def end_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.message.reply_text("Flujo cancelado.")
return ConversationHandler.END
async def generic_callback(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict):
current_state_key = context.user_data.get("current_state", 0)
current_step = _find_step(flow, current_state_key)
if not current_step:
await update.message.reply_text("Hubo un error en el flujo. Por favor, inicia de nuevo.")
return ConversationHandler.END
user_answer = update.message.text
variable_name = current_step.get("variable")
if variable_name:
context.user_data[variable_name] = user_answer
next_state_key = _determine_next_state(current_step, user_answer)
if next_state_key is None:
return await end_cancel(update, context)
return await _go_to_state(update, context, flow, next_state_key)
async def start_flow(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict):
context.user_data.clear()
context.user_data["flow_name"] = flow["flow_name"]
first_state = flow["steps"][0]["state"]
return await _go_to_state(update, context, flow, first_state)
def load_flows(): def load_flows():
flow_handlers = [] flow_handlers = []
flow_dir = "conv-flows" flow_dir = "conv-flows"
@@ -125,6 +207,7 @@ def load_flows():
with open(filepath, "r", encoding="utf-8") as f: with open(filepath, "r", encoding="utf-8") as f:
try: try:
flow_definition = json.load(f) flow_definition = json.load(f)
_preprocess_flow(flow_definition)
handler = create_handler(flow_definition) handler = create_handler(flow_definition)
flow_handlers.append(handler) flow_handlers.append(handler)
logging.info(f"Flow '{flow_definition['flow_name']}' loaded successfully.") logging.info(f"Flow '{flow_definition['flow_name']}' loaded successfully.")

View File

@@ -434,7 +434,7 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.message.reply_text( await update.message.reply_text(
"Proceso cancelado. ⏸️\nPuedes retomarlo con /welcome o ir al menú con /start.", "Proceso cancelado. ⏸️\nPuedes retomarlo con /registro (alias /welcome) o ir al menú con /start.",
reply_markup=main_actions_keyboard() reply_markup=main_actions_keyboard()
) )
context.user_data.clear() context.user_data.clear()
@@ -450,8 +450,11 @@ states[34] = [MessageHandler(filters.TEXT & ~filters.COMMAND, finalizar)]
# Handler listo para importar en main.py # Handler listo para importar en main.py
onboarding_handler = ConversationHandler( onboarding_handler = ConversationHandler(
entry_points=[CommandHandler("welcome", start)], # Cambiado a /welcome entry_points=[
states=states, # Tu diccionario de estados CommandHandler("welcome", start),
CommandHandler("registro", start),
],
states=states,
fallbacks=[CommandHandler("cancelar", cancelar)], fallbacks=[CommandHandler("cancelar", cancelar)],
allow_reentry=True allow_reentry=True
) )

View File

@@ -5,7 +5,7 @@ def main_actions_keyboard(is_registered: bool = False) -> ReplyKeyboardMarkup:
"""Teclado inferior con comandos directos (un toque lanza el flujo).""" """Teclado inferior con comandos directos (un toque lanza el flujo)."""
keyboard = [] keyboard = []
if not is_registered: if not is_registered:
keyboard.append(["/welcome"]) keyboard.append(["/registro"])
keyboard.extend([ keyboard.extend([
["/vacaciones", "/permiso"], ["/vacaciones", "/permiso"],