Files
telegram_new_socias/modules/rh_requests.py

401 lines
17 KiB
Python

import os
import requests
import uuid
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.ui import main_actions_keyboard
from modules.ai import classify_reason
# Helpers de webhooks
def _get_webhook_list(env_name: str) -> list:
raw = os.getenv(env_name, "")
return [w.strip() for w in raw.split(",") if w.strip()]
def _send_webhooks(urls: list, payload: dict):
enviados = 0
for url in urls:
try:
res = requests.post(url, json=payload, timeout=15)
res.raise_for_status()
enviados += 1
except Exception as e:
print(f"[webhook] Error enviando a {url}: {e}")
return enviados
# Estados de conversación
(
INICIO_DIA,
INICIO_MES,
INICIO_ANIO,
FIN_DIA,
FIN_MES,
FIN_ANIO,
PERMISO_CUANDO,
PERMISO_ANIO,
HORARIO,
MOTIVO,
) = range(10)
# Teclados de apoyo
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)
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 _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."""
try:
inicio = date(datos.get("inicio_anio", ANIO_ACTUAL), 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_anio = datos.get("fin_anio", datos.get("inicio_anio", inicio.year))
fin = date(fin_anio, 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()
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()},
}
def _fmt_fecha(fecha_iso: str) -> str:
if not fecha_iso:
return "N/A"
try:
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'
await update.message.reply_text(
"🌴 **Solicitud de Vacaciones**\n\nVamos a registrar tu descanso. ¿Qué *día* inicia? (número, ej: 10)",
reply_markup=ReplyKeyboardRemove(),
)
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\n¿Para cuándo lo necesitas?",
reply_markup=TECLADO_PERMISO_CUANDO,
)
return PERMISO_CUANDO
# --- Selección de año / cuando ---
async def recibir_inicio_anio(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 INICIO_ANIO
context.user_data["inicio_anio"] = anio
await update.message.reply_text("¿Qué *día* termina tu descanso?", reply_markup=ReplyKeyboardRemove())
return FIN_DIA
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["inicio_anio"] = fecha.year
context.user_data["fin_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:
await update.message.reply_text("¿Para qué año es el permiso? (elige el actual o el siguiente)", reply_markup=TECLADO_ANIOS)
return PERMISO_ANIO
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["inicio_anio"] = anio
context.user_data["fin_anio"] = anio
if "inicio_dia" in context.user_data:
await update.message.reply_text("¿Qué *día* termina?", reply_markup=ReplyKeyboardRemove())
return FIN_DIA
await update.message.reply_text("¿En qué *día* inicia el permiso? (número, ej: 12)", reply_markup=ReplyKeyboardRemove())
return INICIO_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
if context.user_data.get("tipo") == "VACACIONES":
await update.message.reply_text("¿De qué *año* inicia?", reply_markup=TECLADO_ANIOS)
return INICIO_ANIO
context.user_data.setdefault("inicio_anio", ANIO_ACTUAL)
context.user_data.setdefault("fin_anio", context.user_data.get("inicio_anio", ANIO_ACTUAL))
try:
inicio_candidato = date(context.user_data["inicio_anio"], mes, context.user_data["inicio_dia"])
if inicio_candidato < date.today():
await update.message.reply_text(
"Esa fecha ya pasó este año. ¿Para qué año la agendamos?",
reply_markup=TECLADO_ANIOS
)
return 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":
context.user_data.setdefault("fin_anio", context.user_data.get("inicio_anio", ANIO_ACTUAL))
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
await update.message.reply_text("¿De qué *año* termina tu descanso?", reply_markup=TECLADO_ANIOS)
return FIN_ANIO
async def recibir_fin_anio(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 FIN_ANIO
context.user_data["fin_anio"] = anio
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
payload = {
"record_id": str(uuid.uuid4()),
"solicitante": {
"id_telegram": user.id,
"nombre": user.full_name,
"username": user.username
},
"tipo_solicitud": datos['tipo'],
"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()
}
webhooks = []
if datos['tipo'] == 'PERMISO':
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 = _calculate_vacation_metrics_from_dates(fechas)
if metrics["dias_totales"] > 0:
payload["metricas"] = metrics
dias = metrics["dias_totales"]
anticipacion = metrics.get("dias_anticipacion", 0)
if anticipacion < 0:
status = "RECHAZADO"
mensaje = "🔴 No puedo agendar vacaciones en el pasado. Ajusta tus fechas."
elif anticipacion > 30:
status = "RECHAZADO"
mensaje = "🔴 Debes solicitar vacaciones con máximo 30 días de anticipación."
elif dias < 6:
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 = "APROBACION_ESPECIAL"
mensaje = f"🟠 Solicitud de {dias} días: requiere aprobación especial."
else: # 12-30
status = "EN_ESPERA_APROBACION"
mensaje = f"🟡 Solicitud de {dias} días registrada. Queda en espera de aprobación."
payload["status_inicial"] = status
await update.message.reply_text(mensaje)
else:
payload["status_inicial"] = "ERROR_FECHAS"
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'
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:
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}",
reply_markup=main_actions_keyboard()
)
else:
await update.message.reply_text(
f"⚠️ No hay webhook configurado o falló el envío. RH lo revisará.\n\n{resumen}",
reply_markup=main_actions_keyboard()
)
except Exception as e:
print(f"Error enviando webhook: {e}")
await update.message.reply_text(
"⚠️ Error enviando la solicitud.",
reply_markup=main_actions_keyboard()
)
return ConversationHandler.END
async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.message.reply_text(
"Solicitud cancelada. ⏸️\nPuedes volver a iniciar con /vacaciones o /permiso, o ir al menú con /start.",
reply_markup=main_actions_keyboard(),
)
return ConversationHandler.END
# Handlers separados pero comparten lógica
vacaciones_handler = ConversationHandler(
entry_points=[CommandHandler("vacaciones", start_vacaciones)],
states={
INICIO_DIA: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_inicio_dia)],
INICIO_MES: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_inicio_mes)],
INICIO_ANIO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_inicio_anio)],
FIN_DIA: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fin_dia)],
FIN_MES: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fin_mes)],
FIN_ANIO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_fin_anio)],
MOTIVO: [MessageHandler(filters.TEXT & ~filters.COMMAND, recibir_motivo_fin)]
},
fallbacks=[CommandHandler("cancelar", cancelar)]
)
permiso_handler = ConversationHandler(
entry_points=[CommandHandler("permiso", start_permiso)],
states={
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)]
)