diff --git a/Readme.md b/Readme.md index abfdde6..0ebae12 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # 🤖 Vanessa Bot – Asistente de RH para Vanity -Vanessa es un bot de Telegram escrito en Python que automatiza procesos internos de Recursos Humanos en Vanity. Su objetivo es eliminar fricción operativa: onboarding, solicitudes de RH e impresión de documentos, todo orquestado desde Telegram y conectado a flujos de n8n o servicios de correo. +Vanessa es un bot de Telegram escrito en Python que automatiza procesos internos de Recursos Humanos en Vanity. Su objetivo es eliminar fricción operativa: onboarding y solicitudes de RH, todo orquestado desde Telegram y conectado a flujos de n8n o servicios de correo. Este repositorio está pensado como **proyecto Python profesional**, modular y listo para correr 24/7 en producción. @@ -11,11 +11,10 @@ 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. - Onboarding completo de nuevas socias (`/welcome`) -- Envío de archivos a impresión por correo electrónico (`/print`) - Solicitud de vacaciones (`/vacaciones`) - Solicitud de permisos por horas (`/permiso`) -Cada flujo es un módulo independiente, y los datos se envían a **webhooks de n8n** o se procesan directamente, como en el caso de la impresión. +Cada flujo es un módulo independiente, y los datos se envían a **webhooks de n8n**. --- @@ -36,7 +35,6 @@ vanity_bot/ ├── __init__.py ├── database.py # Módulo de conexión a la base de datos ├── onboarding.py # Flujo /welcome (onboarding RH) - ├── printer.py # Flujo /print (impresión por email) └── rh_requests.py # /vacaciones y /permiso ``` @@ -52,8 +50,8 @@ TELEGRAM_TOKEN=TU_TOKEN_AQUI # --- WEBHOOKS N8N --- WEBHOOK_ONBOARDING=https://... # Alias aceptado: WEBHOOK_CONTRATO -WEBHOOK_PRINT=https://... WEBHOOK_VACACIONES=https://... +WEBHOOK_PERMISOS=https://... # --- DATABASE --- # Usado por el servicio de la base de datos en docker-compose.yml @@ -62,13 +60,6 @@ MYSQL_USER=user MYSQL_PASSWORD=password MYSQL_ROOT_PASSWORD=rootpassword -# --- SMTP PARA IMPRESIÓN --- -# Usado por el módulo de impresión para enviar correos -SMTP_SERVER=smtp.hostinger.com -SMTP_PORT=465 -SMTP_USER=tu_email@dominio.com -SMTP_PASSWORD=tu_password_de_email -SMTP_RECIPIENT=email_destino@dominio.com # También puedes usar PRINTER_EMAIL ``` --- @@ -111,14 +102,12 @@ docker-compose down ### modules/onboarding.py Flujo conversacional complejo que recolecta datos de nuevas empleadas y los envía a un webhook de n8n. - -### modules/printer.py -- Recibe documentos o imágenes desde Telegram. -- Descarga el archivo de forma segura desde los servidores de Telegram. -- Se conecta a un servidor SMTP para enviar el archivo como un adjunto por correo electrónico a una dirección predefinida. +Incluye derivadas útiles: `num_ext_texto` (número en letras, con interior) y `numero_empleado` (primeras 4 del CURP + fecha de ingreso). ### modules/rh_requests.py - Maneja solicitudes simples de RH (Vacaciones y Permisos) y las envía a un webhook de n8n. +- Vacaciones: pregunta año (actual o siguiente), día/mes de inicio y fin, calcula métricas y aplica semáforo automático. +- Permisos: ofrece accesos rápidos (hoy/mañana/pasado) o fecha específica (año actual/siguiente, día/mes), pide horario, clasifica motivo con IA y envía al webhook. --- @@ -128,7 +117,6 @@ Flujo conversacional complejo que recolecta datos de nuevas empleadas y los env - **Python como cerebro**: Lógica de negocio y orquestación. - **Docker para despliegue**: Entornos consistentes y portátiles. - **MySQL para persistencia**: Registro auditable de todas las interacciones. -- **SMTP para acciones directas**: Integración con sistemas estándar como el correo. - **Modularidad total**: Cada habilidad es un componente independiente. --- diff --git a/main.py b/main.py index 9139996..ca6c4d9 100644 --- a/main.py +++ b/main.py @@ -5,13 +5,12 @@ from dotenv import load_dotenv # Cargar variables de entorno antes de importar módulos que las usan load_dotenv() -from telegram import Update, ReplyKeyboardMarkup +from telegram import Update, ReplyKeyboardMarkup, BotCommand from telegram.constants import ParseMode from telegram.ext import Application, Defaults, CommandHandler, ContextTypes # --- IMPORTAR HABILIDADES --- from modules.onboarding import onboarding_handler -from modules.printer import print_handler from modules.rh_requests import vacaciones_handler, permiso_handler from modules.database import log_request # from modules.finder import finder_handler (Si lo creas después) @@ -27,18 +26,38 @@ async def menu_principal(update: Update, context: ContextTypes.DEFAULT_TYPE): log_request(user.id, user.username, "start", update.message.text) texto = ( "👩‍💼 **Hola, soy Vanessa. ¿En qué puedo ayudarte hoy?**\n\n" - "Toca un comando en azul para lanzarlo rápido:" + "Comandos rápidos:\n" + "/welcome — Onboarding\n" + "/vacaciones — Solicitud de vacaciones\n" + "/permiso — Solicitud de permiso por horas\n\n" + "También tienes los botones rápidos abajo 👇" ) teclado = ReplyKeyboardMarkup( - [["/welcome", "/print"], ["/vacaciones", "/permiso"]], + [["/welcome"], ["/vacaciones", "/permiso"]], resize_keyboard=True ) await update.message.reply_text(texto, reply_markup=teclado) +async def post_init(application: Application): + # Mantén los comandos rápidos disponibles en el menú de Telegram + await application.bot.set_my_commands([ + BotCommand("start", "Mostrar menú principal"), + BotCommand("welcome", "Iniciar onboarding"), + BotCommand("vacaciones", "Solicitar vacaciones"), + BotCommand("permiso", "Solicitar permiso por horas"), + BotCommand("cancelar", "Cancelar flujo actual"), + ]) + def main(): # Configuración Global defaults = Defaults(parse_mode=ParseMode.MARKDOWN) - app = Application.builder().token(TOKEN).defaults(defaults).build() + app = ( + Application.builder() + .token(TOKEN) + .defaults(defaults) + .post_init(post_init) + .build() + ) # --- REGISTRO DE HABILIDADES --- @@ -48,7 +67,6 @@ def main(): # 2. Habilidades Complejas (Conversaciones) app.add_handler(onboarding_handler) - app.add_handler(print_handler) app.add_handler(vacaciones_handler) app.add_handler(permiso_handler) # app.add_handler(finder_handler) diff --git a/modules/onboarding.py b/modules/onboarding.py index f7dadd6..4c5aa0e 100644 --- a/modules/onboarding.py +++ b/modules/onboarding.py @@ -67,6 +67,55 @@ def limpiar_texto_general(texto: str) -> str: t = " ".join(texto.split()) return "N/A" if t == "0" else t +def _num_to_words_es_hasta_999(n: int) -> str: + """Convierte un número (0-999) a texto en español sin acentos.""" + if n < 0 or n > 999: + return str(n) + unidades = ["cero", "uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve"] + especiales = { + 10: "diez", 11: "once", 12: "doce", 13: "trece", 14: "catorce", 15: "quince", + 20: "veinte", 30: "treinta", 40: "cuarenta", 50: "cincuenta", + 60: "sesenta", 70: "setenta", 80: "ochenta", 90: "noventa", + 100: "cien", 200: "doscientos", 300: "trescientos", 400: "cuatrocientos", + 500: "quinientos", 600: "seiscientos", 700: "setecientos", + 800: "ochocientos", 900: "novecientos" + } + if n < 10: + return unidades[n] + if n in especiales: + return especiales[n] + if n < 20: + return "dieci" + unidades[n - 10] + if n < 30: + return "veinti" + unidades[n - 20] + if n < 100: + decenas = (n // 10) * 10 + resto = n % 10 + return f"{especiales[decenas]} y {unidades[resto]}" + centenas = (n // 100) * 100 + resto = n % 100 + if centenas == 100 and resto > 0: + prefijo = "ciento" + else: + prefijo = especiales.get(centenas, str(centenas)) + if resto == 0: + return prefijo + return f"{prefijo} { _num_to_words_es_hasta_999(resto)}" + +def numero_a_texto(num_ext: str, num_int: str) -> str: + """Devuelve un resumen textual del numero exterior (+ interior si aplica).""" + import re + texto_base = limpiar_texto_general(num_ext) + interior = limpiar_texto_general(num_int) + m = re.match(r"(\d+)", texto_base) + if not m: + return texto_base + numero = int(m.group(1)) + en_letras = _num_to_words_es_hasta_999(numero) + if interior and interior.upper() != "N/A": + return f"{en_letras}, interior {interior}".strip() + return en_letras + # --- 4. TECLADOS DINÁMICOS --- # Meses: Texto vs Valor @@ -130,7 +179,8 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: "telegram_id": user.id, "username": user.username or "N/A", "first_name": user.first_name, - "start_ts": datetime.now().timestamp() + "start_ts": datetime.now().timestamp(), + "msg_count": 0, } context.user_data["respuestas"] = {} @@ -146,6 +196,9 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: async def manejar_flujo(update: Update, context: ContextTypes.DEFAULT_TYPE, estado_actual: int) -> int: texto_recibido = update.message.text respuesta_procesada = limpiar_texto_general(texto_recibido) + meta = context.user_data.get("metadata", {}) + meta["msg_count"] = meta.get("msg_count", 0) + 1 + context.user_data["metadata"] = meta # --- LÓGICA DE PROCESAMIENTO ESPECÍFICA POR ESTADO --- @@ -233,6 +286,9 @@ async def manejar_flujo(update: Update, context: ContextTypes.DEFAULT_TYPE, esta async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: # Guardar última respuesta (Relación Emergencia) context.user_data["respuestas"][EMERGENCIA_RELACION] = limpiar_texto_general(update.message.text) + meta = context.user_data.get("metadata", {}) + meta["msg_count"] = meta.get("msg_count", 0) + 1 + context.user_data["metadata"] = meta await update.message.reply_text("¡Perfecto! 📝 Guardando tu expediente en el sistema... dame un momento.") @@ -246,6 +302,10 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: except Exception: fecha_nac = "ERROR_FECHA" fecha_ini = "ERROR_FECHA" + # Derivados + num_ext_texto = numero_a_texto(r.get(NUM_EXTERIOR, ""), r.get(NUM_INTERIOR, "")) + curp_base = (r.get(CURP) or "").upper() + n_empleado = f"{(curp_base + 'XXXX')[:4]}{fecha_ini.replace('-', '')}" # PAYLOAD ESTRUCTURADO PARA N8N payload = { @@ -267,6 +327,7 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: "calle": r.get(CALLE), "num_ext": r.get(NUM_EXTERIOR), "num_int": r.get(NUM_INTERIOR), + "num_ext_texto": num_ext_texto, "colonia": r.get(COLONIA), "cp": r.get(CODIGO_POSTAL), "ciudad": r.get(CIUDAD_RESIDENCIA), @@ -275,7 +336,8 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: "laboral": { "rol_id": r.get(ROL).lower(), # partner, manager... "sucursal_id": r.get(SUCURSAL), # plaza_cima, plaza_o - "fecha_inicio": fecha_ini + "fecha_inicio": fecha_ini, + "numero_empleado": n_empleado }, "referencias": [ {"nombre": r.get(REF1_NOMBRE), "telefono": r.get(REF1_TELEFONO), "relacion": r.get(REF1_TIPO)}, @@ -291,7 +353,9 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: "telegram_user": meta["username"], "chat_id": meta["telegram_id"], "bot_version": "welcome2soul_v2", - "fecha_registro": datetime.now().isoformat() + "fecha_registro": datetime.now().isoformat(), + "duracion_segundos": round(datetime.now().timestamp() - meta.get("start_ts", datetime.now().timestamp()), 2), + "mensajes_totales": meta.get("msg_count", 0) } } diff --git a/modules/rh_requests.py b/modules/rh_requests.py index 05cb68b..50d2c56 100644 --- a/modules/rh_requests.py +++ b/modules/rh_requests.py @@ -1,5 +1,4 @@ import os -import re import requests import uuid from datetime import datetime, date @@ -24,105 +23,224 @@ def _send_webhooks(urls: list, payload: dict): print(f"[webhook] Error enviando a {url}: {e}") return enviados -TIPO_SOLICITITUD, FECHAS, MOTIVO = range(3) +# Estados de conversación +( + VAC_ANIO, + INICIO_DIA, + INICIO_MES, + FIN_DIA, + FIN_MES, + PERMISO_CUANDO, + PERMISO_ANIO, + HORARIO, + MOTIVO, +) = range(9) # Teclados de apoyo -MESES = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"] +MESES = [ + "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", + "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre" +] TECLADO_MESES = ReplyKeyboardMarkup([MESES[i:i+3] for i in range(0, 12, 3)], one_time_keyboard=True, resize_keyboard=True) -TECLADO_PERMISO_RAPIDO = ReplyKeyboardMarkup( - [["Hoy 09:00-11:00", "Hoy 15:00-18:00"], ["Mañana 09:00-11:00", "Mañana 15:00-18:00"], ["Otra fecha/horario"]], +MESES_MAP = {nombre.lower(): idx + 1 for idx, nombre in enumerate(MESES)} +ANIO_ACTUAL = datetime.now().year +TECLADO_ANIOS = ReplyKeyboardMarkup([[str(ANIO_ACTUAL), str(ANIO_ACTUAL + 1)]], one_time_keyboard=True, resize_keyboard=True) +TECLADO_PERMISO_CUANDO = ReplyKeyboardMarkup( + [["Hoy", "Mañana"], ["Pasado mañana", "Fecha específica"]], one_time_keyboard=True, resize_keyboard=True, ) -def _calculate_vacation_metrics(date_string: str) -> dict: - """ - Calcula métricas de vacaciones a partir de un texto. - Asume un formato como "10 al 15 de Octubre". - """ +def _parse_dia(texto: str) -> int: + try: + dia = int(texto) + if 1 <= dia <= 31: + return dia + except Exception: + pass + return 0 + +def _parse_mes(texto: str) -> int: + return MESES_MAP.get(texto.strip().lower(), 0) + +def _parse_anio(texto: str) -> int: + try: + return int(texto) + except Exception: + return 0 + +def _build_dates(datos: dict) -> dict: + """Construye fechas ISO; si fin < inicio, se ajusta a inicio.""" + year = datos.get("anio") or datetime.now().year + try: + inicio = date(year, datos["inicio_mes"], datos["inicio_dia"]) + fin_dia = datos.get("fin_dia", datos.get("inicio_dia")) + fin_mes = datos.get("fin_mes", datos.get("inicio_mes")) + fin = date(year, fin_mes, fin_dia) + if fin < inicio: + fin = inicio + return {"inicio": inicio, "fin": fin} + except Exception: + return {} + +def _calculate_vacation_metrics_from_dates(fechas: dict) -> dict: today = date.today() - current_year = today.year - - # Mapeo de meses en español a número - meses = { - 'enero': 1, 'febrero': 2, 'marzo': 3, 'abril': 4, 'mayo': 5, 'junio': 6, - 'julio': 7, 'agosto': 8, 'septiembre': 9, 'octubre': 10, 'noviembre': 11, 'diciembre': 12 + inicio = fechas.get("inicio") + fin = fechas.get("fin") + if not inicio or not fin: + return {"dias_totales": 0, "dias_anticipacion": 0} + dias_totales = (fin - inicio).days + 1 + dias_anticipacion = (inicio - today).days + return { + "dias_totales": dias_totales, + "dias_anticipacion": dias_anticipacion, + "fechas_calculadas": {"inicio": inicio.isoformat(), "fin": fin.isoformat()}, } - # Regex para "10 al 15 de Octubre" - match = re.search(r'(\d{1,2})\s*al\s*(\d{1,2})\s*de\s*(\w+)', date_string, re.IGNORECASE) - - if not match: - return {"dias_totales": 0, "dias_anticipacion": 0} - - start_day, end_day, month_str = match.groups() - start_day, end_day = int(start_day), int(end_day) - month = meses.get(month_str.lower()) - - if not month: - return {"dias_totales": 0, "dias_anticipacion": 0} - +def _fmt_fecha(fecha_iso: str) -> str: + if not fecha_iso: + return "N/A" try: - start_date = date(current_year, month, start_day) - # Si la fecha ya pasó este año, asumir que es del próximo año - if start_date < today: - start_date = date(current_year + 1, month, start_day) - - end_date = date(start_date.year, month, end_day) - - dias_totales = (end_date - start_date).days + 1 - dias_anticipacion = (start_date - today).days - - return {"dias_totales": dias_totales, "dias_anticipacion": dias_anticipacion, "fechas_calculadas": {"inicio": start_date.isoformat(), "fin": end_date.isoformat()}} - except ValueError: - return {"dias_totales": 0, "dias_anticipacion": 0} - + return fecha_iso.split("T")[0] + except Exception: + return fecha_iso +# --- Vacaciones --- async def start_vacaciones(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: user = update.effective_user log_request(user.id, user.username, "vacaciones", update.message.text) + context.user_data.clear() context.user_data['tipo'] = 'VACACIONES' + context.user_data["anio"] = ANIO_ACTUAL await update.message.reply_text( - "🌴 **Solicitud de Vacaciones**\n\n¿Para qué fechas las necesitas?\nUsa el formato: `10 al 15 de Octubre`.", - reply_markup=TECLADO_MESES, + "🌴 **Solicitud de Vacaciones**\n\nUsaré el año actual. ¿En qué *día* inicia tu descanso? (número, ej: 10)", + reply_markup=ReplyKeyboardRemove(), ) - return FECHAS + return INICIO_DIA +# --- Permiso --- async def start_permiso(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: user = update.effective_user log_request(user.id, user.username, "permiso", update.message.text) + context.user_data.clear() context.user_data['tipo'] = 'PERMISO' await update.message.reply_text( - "⏱️ **Solicitud de Permiso**\n\nSelecciona o escribe el día y horario. Ej: `Jueves 15 09:00-11:00`.", - reply_markup=TECLADO_PERMISO_RAPIDO, + "⏱️ **Solicitud de Permiso**\n\n¿Para cuándo lo necesitas?", + reply_markup=TECLADO_PERMISO_CUANDO, ) - return FECHAS + return PERMISO_CUANDO -async def recibir_fechas(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - texto_fechas = update.message.text - es_vacaciones = context.user_data.get('tipo') == 'VACACIONES' +# --- Selección de año / cuando --- +async def recibir_anio_vacaciones(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + anio = _parse_anio(update.message.text) + if anio not in (ANIO_ACTUAL, ANIO_ACTUAL + 1): + await update.message.reply_text("Elige el año del teclado (actual o siguiente).", reply_markup=TECLADO_ANIOS) + return VAC_ANIO + context.user_data["anio"] = anio + await update.message.reply_text("¿Qué *día* termina?", reply_markup=ReplyKeyboardRemove()) + return FIN_DIA - if es_vacaciones: - metrics = _calculate_vacation_metrics(texto_fechas) - if metrics["dias_totales"] == 0: +async def recibir_cuando_permiso(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + texto = update.message.text.strip().lower() + hoy = date.today() + offset_map = {"hoy": 0, "mañana": 1, "manana": 1, "pasado mañana": 2, "pasado manana": 2} + if texto in offset_map: + delta = offset_map[texto] + fecha = hoy.fromordinal(hoy.toordinal() + delta) + context.user_data["anio"] = fecha.year + context.user_data["inicio_dia"] = fecha.day + context.user_data["inicio_mes"] = fecha.month + context.user_data["fin_dia"] = fecha.day + context.user_data["fin_mes"] = fecha.month + await update.message.reply_text("¿Cuál es el horario? Ej: `09:00-11:00` o `Todo el día`.", reply_markup=ReplyKeyboardRemove()) + return HORARIO + if "fecha" in texto: + context.user_data["anio"] = ANIO_ACTUAL + await update.message.reply_text("¿En qué *día* inicia el permiso? (número, ej: 12)", reply_markup=ReplyKeyboardRemove()) + return INICIO_DIA + await update.message.reply_text("Elige una opción: Hoy, Mañana, Pasado mañana o Fecha específica.", reply_markup=TECLADO_PERMISO_CUANDO) + return PERMISO_CUANDO + +async def recibir_anio_permiso(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + anio = _parse_anio(update.message.text) + if anio not in (ANIO_ACTUAL, ANIO_ACTUAL + 1): + await update.message.reply_text("Elige el año del teclado (actual o siguiente).", reply_markup=TECLADO_ANIOS) + return PERMISO_ANIO + context.user_data["anio"] = anio + await update.message.reply_text("¿Qué *día* termina?", reply_markup=ReplyKeyboardRemove()) + return FIN_DIA + +# --- Captura de fechas --- +async def recibir_inicio_dia(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + dia = _parse_dia(update.message.text) + if not dia: + await update.message.reply_text("Necesito un número de día válido (1-31). Intenta de nuevo.") + return INICIO_DIA + context.user_data["inicio_dia"] = dia + await update.message.reply_text("¿De qué *mes* inicia?", reply_markup=TECLADO_MESES) + return INICIO_MES + +async def recibir_inicio_mes(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + mes = _parse_mes(update.message.text) + if not mes: + await update.message.reply_text("Elige un mes del teclado o escríbelo igual que aparece.", reply_markup=TECLADO_MESES) + return INICIO_MES + context.user_data["inicio_mes"] = mes + context.user_data.setdefault("anio", ANIO_ACTUAL) + + try: + inicio_candidato = date(context.user_data["anio"], mes, context.user_data["inicio_dia"]) + if inicio_candidato < date.today(): await update.message.reply_text( - "No entendí las fechas. Usa un formato como `10 al 15 de Octubre`.", - reply_markup=TECLADO_MESES, + "Esa fecha ya pasó este año. ¿Para qué año la agendamos?", + reply_markup=TECLADO_ANIOS ) - return FECHAS - # Si se entiende, guardamos también las métricas preliminares - context.user_data['metricas_preliminares'] = metrics + return VAC_ANIO if context.user_data.get("tipo") == "VACACIONES" else PERMISO_ANIO + except Exception: + pass + + await update.message.reply_text("¿Qué *día* termina?", reply_markup=ReplyKeyboardRemove()) + return FIN_DIA + +async def recibir_fin_dia(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + dia = _parse_dia(update.message.text) + if not dia: + await update.message.reply_text("Día inválido. Dame un número de 1 a 31.") + return FIN_DIA + context.user_data["fin_dia"] = dia + await update.message.reply_text("¿De qué *mes* termina?", reply_markup=TECLADO_MESES) + return FIN_MES + +async def recibir_fin_mes(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + mes = _parse_mes(update.message.text) + if not mes: + await update.message.reply_text("Elige un mes válido.", reply_markup=TECLADO_MESES) + return FIN_MES + context.user_data["fin_mes"] = mes + + if context.user_data.get("tipo") == "PERMISO": + await update.message.reply_text("¿Cuál es el horario? Ej: `09:00-11:00` o `Todo el día`.", reply_markup=ReplyKeyboardRemove()) + return HORARIO - context.user_data['fechas'] = texto_fechas await update.message.reply_text("Entendido. ¿Cuál es el motivo o comentario adicional?", reply_markup=ReplyKeyboardRemove()) return MOTIVO +async def recibir_horario(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data["horario"] = update.message.text.strip() + await update.message.reply_text("Entendido. ¿Cuál es el motivo o comentario adicional?", reply_markup=ReplyKeyboardRemove()) + return MOTIVO + +# --- Motivo y cierre --- async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: motivo = update.message.text datos = context.user_data user = update.effective_user + + fechas = _build_dates(datos) + if not fechas: + await update.message.reply_text("🤔 No entendí las fechas. Por favor, inicia otra vez con /vacaciones o /permiso.") + return ConversationHandler.END - # Generar payload base payload = { "record_id": str(uuid.uuid4()), "solicitante": { @@ -131,7 +249,10 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE) "username": user.username }, "tipo_solicitud": datos['tipo'], - "fechas_texto_original": datos['fechas'], + "fechas": { + "inicio": fechas.get("inicio").isoformat() if fechas else None, + "fin": fechas.get("fin").isoformat() if fechas else None, + }, "motivo_usuario": motivo, "created_at": datetime.now().isoformat() } @@ -141,11 +262,12 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE) webhooks = _get_webhook_list("WEBHOOK_PERMISOS") categoria = classify_reason(motivo) payload["categoria_detectada"] = categoria + payload["horario"] = datos.get("horario", "N/A") await update.message.reply_text(f"Categoría detectada → **{categoria}** 🚨") elif datos['tipo'] == 'VACACIONES': webhooks = _get_webhook_list("WEBHOOK_VACACIONES") - metrics = datos.get('metricas_preliminares') or _calculate_vacation_metrics(datos['fechas']) + metrics = _calculate_vacation_metrics_from_dates(fechas) if metrics["dias_totales"] > 0: payload["metricas"] = metrics @@ -154,27 +276,49 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE) if dias <= 5: status = "RECHAZADO" mensaje = f"🔴 {dias} días es un periodo muy corto. Las vacaciones deben ser de al menos 6 días." + elif dias > 30: + status = "RECHAZADO" + mensaje = "🔴 Las vacaciones no pueden exceder 30 días. Ajusta tus fechas, por favor." elif 6 <= dias <= 11: status = "REVISION_MANUAL" mensaje = f"🟡 Solicitud de {dias} días recibida. Tu manager la revisará pronto." - else: # 12+ + else: # 12-30 status = "PRE_APROBADO" - mensaje = f"🟢 ¡Excelente planeación! Tu solicitud de {dias} días ha sido pre-aprobada." + mensaje = f"🟢 ¡Excelente planeación! Tu solicitud de {dias} días ha sido pre-aprobada (ideal: 12 días)." payload["status_inicial"] = status await update.message.reply_text(mensaje) else: - # Si no se pudieron parsear las fechas payload["status_inicial"] = "ERROR_FECHAS" - await update.message.reply_text("🤔 No entendí las fechas. Por favor, usa un formato como '10 al 15 de Octubre'.") + await update.message.reply_text("🤔 No entendí las fechas. Por favor, comparte día y mes otra vez con /vacaciones.") try: enviados = _send_webhooks(webhooks, payload) if webhooks else 0 tipo_solicitud_texto = "Permiso" if datos['tipo'] == 'PERMISO' else 'Vacaciones' - if enviados > 0: - await update.message.reply_text(f"✅ Solicitud de *{tipo_solicitud_texto}* enviada a tu Manager.") + inicio_txt = _fmt_fecha(payload["fechas"]["inicio"]) + fin_txt = _fmt_fecha(payload["fechas"]["fin"]) + if datos['tipo'] == 'PERMISO': + resumen = ( + "📝 Resumen enviado:\n" + f"- Fecha: {inicio_txt} a {fin_txt}\n" + f"- Horario: {payload.get('horario', 'N/A')}\n" + f"- Categoría: {payload.get('categoria_detectada', 'N/A')}\n" + f"- Motivo: {motivo}" + ) else: - await update.message.reply_text("⚠️ No hay webhook configurado o falló el envío. RH lo revisará.") + m = payload.get("metricas", {}) + resumen = ( + "📝 Resumen enviado:\n" + f"- Inicio: {inicio_txt}\n" + f"- Fin: {fin_txt}\n" + f"- Días totales: {m.get('dias_totales', 'N/A')}\n" + f"- Anticipación: {m.get('dias_anticipacion', 'N/A')} días\n" + f"- Estatus inicial: {payload.get('status_inicial', 'N/A')}" + ) + if enviados > 0: + await update.message.reply_text(f"✅ Solicitud de *{tipo_solicitud_texto}* enviada a tu Manager.\n\n{resumen}") + else: + await update.message.reply_text(f"⚠️ No hay webhook configurado o falló el envío. RH lo revisará.\n\n{resumen}") except Exception as e: print(f"Error enviando webhook: {e}") await update.message.reply_text("⚠️ Error enviando la solicitud.") @@ -190,7 +334,11 @@ async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: vacaciones_handler = ConversationHandler( entry_points=[CommandHandler("vacaciones", start_vacaciones)], states={ - FECHAS: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fechas)], + VAC_ANIO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_anio_vacaciones)], + INICIO_DIA: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_inicio_dia)], + INICIO_MES: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_inicio_mes)], + FIN_DIA: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fin_dia)], + FIN_MES: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fin_mes)], MOTIVO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_motivo_fin)] }, fallbacks=[CommandHandler("cancelar", cancelar)] @@ -199,7 +347,13 @@ vacaciones_handler = ConversationHandler( permiso_handler = ConversationHandler( entry_points=[CommandHandler("permiso", start_permiso)], states={ - FECHAS: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fechas)], + PERMISO_CUANDO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_cuando_permiso)], + PERMISO_ANIO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_anio_permiso)], + INICIO_DIA: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_inicio_dia)], + INICIO_MES: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_inicio_mes)], + FIN_DIA: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fin_dia)], + FIN_MES: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fin_mes)], + HORARIO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_horario)], MOTIVO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_motivo_fin)] }, fallbacks=[CommandHandler("cancelar", cancelar)]