Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 941cd569d8 | |||
| 7c2a3f1c4a | |||
| a9a4926e3e | |||
| 427f6ed000 | |||
| cc9d3ca78a | |||
| f0d927bcb6 | |||
| d2e7dd6234 | |||
| 9e34b0597b | |||
| 4db994f6de | |||
| ed669b6aac | |||
| cecec601e0 | |||
| 6c466dc0f6 | |||
| 974ecbb36d | |||
| a189c361d2 | |||
| 613102d0c9 | |||
| 4a464911d6 | |||
| 2fe74b08d8 | |||
| 5f1c023652 | |||
| 4fbca23c6b | |||
| edbb1bbc95 | |||
| 0973b83ba0 | |||
| 996cfc7ad3 | |||
| 81714f4bc2 | |||
| 6366f2a503 | |||
| c7e0fd8cc6 | |||
| d3f6206113 | |||
| 3d6c99ed24 | |||
| 03808a553a | |||
| 6f187591b5 | |||
| 6c43c4315e | |||
| 1e4770ea16 | |||
| c5edcbdd06 | |||
| a3b9796fe8 | |||
| aaf0c66ed6 | |||
| 0fa11655d5 | |||
| 7523a4849e | |||
| bfb3a0f359 | |||
| 9f737d3282 | |||
| e550e657fe | |||
| 3e98471e21 | |||
| 042ef92e98 | |||
| 6f0f40a29e | |||
| 3555d378b2 | |||
| e1a772ba0c | |||
| 597aa56651 | |||
| 5f604e52d8 | |||
| 1d8b4ef3c1 | |||
| 17cf93eed9 | |||
| 1ab303ca60 | |||
| 2c5b296eaf | |||
| 8d4dcb006e | |||
| 90b4d554bb | |||
| 1585206707 | |||
| 03c0d6f759 | |||
| 9ccda24a55 | |||
| 8bb0e27028 | |||
| 8aea92b84f | |||
| dd14853362 | |||
| c42854c3a5 | |||
| 6ad50dcd4d | |||
| 84a03f79ee | |||
| ddbcb11685 | |||
| 80b15c86c9 | |||
| 36dbae5a56 | |||
| 708684fdfe | |||
| baeac6b1cc | |||
| 09a2ccc6b4 | |||
| e07b0af6b4 | |||
| be16c4fe17 | |||
| cc71f00506 | |||
| 9a24f48259 | |||
| 9b65ec1e7e | |||
| 354c6b0a51 | |||
| 1ac0035b4d | |||
| c987f14cf9 | |||
| f1709f16b6 | |||
| 0cfef14722 | |||
| d2fe4aa1dc | |||
| 0ff681b84f | |||
| 3f239267d2 | |||
| 94bd664be8 | |||
| c6bc67a98d | |||
| 005b728d7c | |||
| 866654a132 | |||
| 3c160afe20 | |||
| 347a909e06 | |||
| dff39f025c | |||
| 523ed89519 | |||
| d1ad0b16a1 | |||
| 6a6d59394a | |||
| 32f1cd9513 | |||
| 51b632ec7d | |||
| 54b019f3b9 | |||
| 7c900e5502 | |||
| 7d67e3ffc2 | |||
| af0caee963 | |||
| 0dca28ef20 | |||
| de47c39e76 | |||
| f0be37b18f | |||
| 9aedbd4c1f | |||
| 977e7369e3 | |||
| 23a7d20722 | |||
| 1caae96a97 | |||
| 90bac4e343 | |||
| 7517448508 |
Binary file not shown.
Binary file not shown.
+9
-1
@@ -95,7 +95,9 @@ ENABLE_WHITELIST = True # Включить режим закрытого
|
|||||||
ALLOWED_USERS = [ # Список TrueConf ID разрешенных пользователей
|
ALLOWED_USERS = [ # Список TrueConf ID разрешенных пользователей
|
||||||
"ds.krivochenko@tcs.sibcem.ru",
|
"ds.krivochenko@tcs.sibcem.ru",
|
||||||
"mm.norenberg@tcs.sibcem.ru",
|
"mm.norenberg@tcs.sibcem.ru",
|
||||||
"a.kirpichev@tcs.sibcem.ru"
|
"a.kirpichev@tcs.sibcem.ru",
|
||||||
|
"bot_test@tcs.sibcem.ru",
|
||||||
|
"v.s.sholohov@tcs.sibcem.ru"
|
||||||
]
|
]
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -221,3 +223,9 @@ OTP_MAX_ATTEMPTS = 3
|
|||||||
OTP_BLOCK_DURATION_SEC = 1800
|
OTP_BLOCK_DURATION_SEC = 1800
|
||||||
# Глобальный таймаут неактивности пользователя в любом подменю (1800 секунд = 30 минут)
|
# Глобальный таймаут неактивности пользователя в любом подменю (1800 секунд = 30 минут)
|
||||||
SESSION_TIMEOUT = 1800
|
SESSION_TIMEOUT = 1800
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 11. МОДУЛЬ INSTRUCT: НАСТРОЙКИ SERVICE DESK: ГРУППА И ИСПОЛНИТЕЛЬ
|
||||||
|
# =========================================================
|
||||||
|
SD_INSTRUCT_GROUP_NAME = "Техподдержка ХК" # Название группы в ServiceDesk
|
||||||
|
SD_INSTRUCT_TECHNICIAN_NAME = "Кривоченко Денис Сергеевич" # ФИО техника в ServiceDesk
|
||||||
+464
-60
@@ -1,70 +1,166 @@
|
|||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
|
||||||
|
from trueconf import Router, Message
|
||||||
|
from trueconf.types import FSInputFile
|
||||||
|
|
||||||
from utils.texts import (
|
from utils.texts import (
|
||||||
UNKNOWN_MAIN_CMD_TEXT,
|
UNKNOWN_MAIN_CMD_TEXT,
|
||||||
EMOJI_DIGITS,
|
EMOJI_DIGITS,
|
||||||
INSTRUCT_MAIN_MENU_TEXT,
|
INSTRUCT_MAIN_MENU_TEXT,
|
||||||
INSTRUCT_TRUECONF_TEXT,
|
INSTRUCT_TRUECONF_TEXT,
|
||||||
INSTRUCT_TRUECONF_WITH_AD,
|
INSTRUCT_TRUECONF_WITH_AD,
|
||||||
INSTRUCT_EMAIL_TEXT,
|
INSTRUCT_TRUECONF_PENDING_TEXT,
|
||||||
|
INSTRUCT_EMAIL_AVAILABLE,
|
||||||
|
INSTRUCT_EMAIL_HISTORY_ASK,
|
||||||
|
INSTRUCT_EMAIL_NOT_HAD,
|
||||||
|
INSTRUCT_EMAIL_UNAVAILABLE,
|
||||||
|
INSTRUCT_ERROR_MSG,
|
||||||
)
|
)
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
import httpx
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
from trueconf import Router, Message
|
|
||||||
from trueconf.types import FSInputFile
|
|
||||||
|
|
||||||
from utils.states import get_state, set_state, clear_state
|
from utils.states import get_state, set_state, clear_state
|
||||||
|
|
||||||
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
|
||||||
from utils.stats_logger import log_menu_stats
|
from utils.stats_logger import log_menu_stats
|
||||||
|
|
||||||
|
# Безопасный импорт конфигурации
|
||||||
|
try:
|
||||||
|
from config.config import (
|
||||||
|
DEFAULT_REQUESTER, SD_URL, SD_TOKEN,
|
||||||
|
SD_INSTRUCT_GROUP_NAME, SD_INSTRUCT_TECHNICIAN_NAME,
|
||||||
|
SMTP_SERVER, SMTP_PORT, DEFAULT_EMAIL_FROM, ALERTS_SUPPORT_EMAILS
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
import importlib.util
|
||||||
|
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "config.py")
|
||||||
|
spec = importlib.util.spec_from_file_location("custom_config", config_path)
|
||||||
|
custom_config = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(custom_config)
|
||||||
|
DEFAULT_REQUESTER = custom_config.DEFAULT_REQUESTER
|
||||||
|
SD_URL = custom_config.SD_URL
|
||||||
|
SD_TOKEN = custom_config.SD_TOKEN
|
||||||
|
SD_INSTRUCT_GROUP_NAME = custom_config.SD_INSTRUCT_GROUP_NAME
|
||||||
|
SD_INSTRUCT_TECHNICIAN_NAME = custom_config.SD_INSTRUCT_TECHNICIAN_NAME
|
||||||
|
SMTP_SERVER = getattr(custom_config, "SMTP_SERVER", "srvkem-mail.sibcem.ru")
|
||||||
|
SMTP_PORT = getattr(custom_config, "SMTP_PORT", 25)
|
||||||
|
DEFAULT_EMAIL_FROM = getattr(custom_config, "DEFAULT_EMAIL_FROM", "ai@sibcem.ru")
|
||||||
|
ALERTS_SUPPORT_EMAILS = getattr(custom_config, "ALERTS_SUPPORT_EMAILS", ["ds.krivochenko@sibcem.ru"])
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def sd_create_ticket(login: str, subject: str, description: str) -> str:
|
|
||||||
"""Создать заявку в SD."""
|
|
||||||
from config.config import SD_URL, SD_TOKEN
|
|
||||||
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
|
|
||||||
endpoint = f"{SD_URL}/api/v3/requests"
|
|
||||||
html_desc = f"<p>{description}</p><br><hr><p style='color:#555;font-size:12px;'><i>Создано через TrueConf КЛЕВЕР</i></p>"
|
|
||||||
payload = {"request": {"subject": subject, "description": html_desc, "requester": {"email_id": None}, "udf_fields": {}}}
|
|
||||||
async with httpx.AsyncClient(verify=False) as client:
|
|
||||||
resp = await client.post(endpoint, headers=headers, data={"input_data": json.dumps(payload)}, timeout=15.0)
|
|
||||||
data = resp.json()
|
|
||||||
if data.get("response_status", {}).get("status_code") == 2000:
|
|
||||||
return str(data.get("request", {}).get("id"))
|
|
||||||
return None
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
# Путь к файлу инструкции Trueconf
|
TRUECONF_INSTRUCT_PATH = os.path.join(
|
||||||
TRUECONF_INSTRUCT_PATH = os.path.join(os.path.dirname(__file__), "Проверка_наличия_и_авторизация_на_мобильном_устройстве_Trueconf.docx")
|
os.path.dirname(__file__),
|
||||||
|
"Проверка_наличия_и_авторизация_на_мобильном_устройстве_Trueconf.docx"
|
||||||
|
)
|
||||||
|
|
||||||
|
GMAIL_ANDROID_INSTRUCT_PATH = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"Инструкция по настройке электронной почты GMAIL на Android.docx"
|
||||||
|
)
|
||||||
|
|
||||||
|
APPLE_INSTRUCT_PATH = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"Инструкция по настройке электронной почты на устройствах Apple.docx"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ВСПОМОГАТЕЛЬНАЯ ФУНКЦИЯ ОТПРАВКИ EMAIL УВЕДОМЛЕНИЙ
|
||||||
|
|
||||||
|
def _send_email_sync(to_emails: list[str], subject: str, body_html: str):
|
||||||
|
"""Синхронная отправка письма по SMTP"""
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = DEFAULT_EMAIL_FROM
|
||||||
|
msg["To"] = ", ".join(to_emails)
|
||||||
|
|
||||||
|
part_html = MIMEText(body_html, "html", "utf-8")
|
||||||
|
msg.attach(part_html)
|
||||||
|
|
||||||
|
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
|
||||||
|
server.sendmail(DEFAULT_EMAIL_FROM, to_emails, msg.as_string())
|
||||||
|
logger.info(f"📧 Уведомление о заявке 2FA успешно отправлено на {to_emails}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка отправки почтового уведомления 2FA: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_2fa_alert_email(cn: str, user_id: str, ticket_id: str):
|
||||||
|
"""Фоновая асинхронная задача формирования и отправки уведомления"""
|
||||||
|
if not ALERTS_SUPPORT_EMAILS:
|
||||||
|
logger.warning("ALERTS_SUPPORT_EMAILS пуст. Уведомление на почту пропущено.")
|
||||||
|
return
|
||||||
|
|
||||||
|
clean_user_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
||||||
|
subject = f"🎫 [2FA Request] Создана заявка #{ticket_id} на доступ к Trueconf ({cn})"
|
||||||
|
|
||||||
|
ticket_link = f"{SD_URL.rstrip('/')}/WorkOrder.do?woMode=viewWO&woID={ticket_id}"
|
||||||
|
|
||||||
|
body_html = f"""
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; color: #333;">
|
||||||
|
<h2>🎫 Создана новая заявка на доступ к Trueconf (2FA)</h2>
|
||||||
|
<p><b>Сотрудник:</b> {cn}</p>
|
||||||
|
<p><b>Логин / Email:</b> {clean_user_id}</p>
|
||||||
|
<p><b>Номер заявки в ServiceDesk:</b> <a href="{ticket_link}">#{ticket_id}</a></p>
|
||||||
|
<p><b>Назначенная группа:</b> {SD_INSTRUCT_GROUP_NAME}</p>
|
||||||
|
<p><b>Исполнитель:</b> {SD_INSTRUCT_TECHNICIAN_NAME}</p>
|
||||||
|
<hr style="border: 0; border-top: 1px solid #ccc;">
|
||||||
|
<p style="font-size: 12px; color: #777;"><i>Сообщение сгенерировано автоматически цифровым ассистентом КЛЕВЕР.</i></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
await asyncio.to_thread(_send_email_sync, ALERTS_SUPPORT_EMAILS, subject, body_html)
|
||||||
|
|
||||||
|
|
||||||
|
# ВСПОМОГАТЕЛЬНАЯ ФУНКЦИЯ ПРОВЕРКИ ЗАКРЫТОГО СТАТУСА ЗАЯВКИ
|
||||||
|
|
||||||
|
def _is_ticket_closed(status_data) -> bool:
|
||||||
|
"""
|
||||||
|
Проверяет, является ли статус заявки закрытым / исполненным.
|
||||||
|
Использует поиск по основам слов для улавливания всех форматов и склонений.
|
||||||
|
"""
|
||||||
|
if not status_data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if isinstance(status_data, dict):
|
||||||
|
status_str = str(status_data.get("name", "")).strip().lower()
|
||||||
|
else:
|
||||||
|
status_str = str(status_data).strip().lower()
|
||||||
|
|
||||||
|
closed_keywords = [
|
||||||
|
"closed", "close", "resolved", "resolv", "solved", "solv",
|
||||||
|
"rejected", "reject", "canceled", "cancel", "completed", "complete",
|
||||||
|
"закрыт", "решен", "исполн", "выполн", "отмен", "отклон", "заверш"
|
||||||
|
]
|
||||||
|
|
||||||
|
return any(kw in status_str for kw in closed_keywords)
|
||||||
|
|
||||||
|
|
||||||
|
# ОСНОВНАЯ ЛОГИКА АКТИВНОСТИ
|
||||||
|
|
||||||
def get_user_cn(user_id: str) -> str | None:
|
def get_user_cn(user_id: str) -> str | None:
|
||||||
"""
|
"""Получить CN пользователя из AD по его user_id."""
|
||||||
Получить CN пользователя из AD по его user_id (email или логин).
|
|
||||||
user_id из TrueConf — это обычно email, например 'ds.krivochenko@tcs.sibcem.ru'
|
|
||||||
Заменяет tcs.sibcem.ru на sibcem.ru для поиска в AD.
|
|
||||||
Ищет по всем OU из AD_BASES.
|
|
||||||
"""
|
|
||||||
from utils.ad_search import search_by_user_id
|
from utils.ad_search import search_by_user_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Заменяем tcs.sibcem.ru на sibcem.ru
|
|
||||||
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
||||||
short_username = search_id.split("@")[0] if "@" in search_id else user_id
|
short_username = search_id.split("@")[0] if "@" in search_id else user_id
|
||||||
|
|
||||||
logger.info(f"AD lookup: user_id={user_id}, search_id={search_id}, short={short_username}")
|
logger.info(f"AD lookup CN: user_id={user_id}, search_id={search_id}, short={short_username}")
|
||||||
|
|
||||||
entries = search_by_user_id(search_id, ["cn", "sAMAccountName", "mail", "userPrincipalName"])
|
entries = search_by_user_id(search_id, ["cn", "sAMAccountName", "mail", "userPrincipalName", "l", "userAccountControl"])
|
||||||
|
|
||||||
if entries:
|
if entries:
|
||||||
entry = entries[0]
|
entry = entries[0]
|
||||||
cn = str(entry.cn)
|
cn = str(entry.cn)
|
||||||
mail = str(entry.mail) if 'mail' in entry else "N/A"
|
mail = str(entry.mail) if 'mail' in entry else ""
|
||||||
sam = str(entry.sAMAccountName) if 'sAMAccountName' in entry else "N/A"
|
sam = str(entry.sAMAccountName) if 'sAMAccountName' in entry else "N/A"
|
||||||
upn = str(entry.userPrincipalName) if 'userPrincipalName' in entry else "N/A"
|
upn = str(entry.userPrincipalName) if 'userPrincipalName' in entry else "N/A"
|
||||||
logger.info(f"AD found: cn={cn}, mail={mail}, sam={sam}, upn={upn}")
|
logger.info(f"AD found: cn={cn}, mail={mail}, sam={sam}, upn={upn}")
|
||||||
@@ -73,7 +169,218 @@ def get_user_cn(user_id: str) -> str | None:
|
|||||||
logger.warning(f"AD not found for user_id={user_id}, search_id={search_id}, short={short_username}")
|
logger.warning(f"AD not found for user_id={user_id}, search_id={search_id}, short={short_username}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка получения CN из AD для {user_id}: {e}", exc_info=True)
|
logger.error(f"Ошибка получения CN из AD для {user_id}: {e}", exc_info=True)
|
||||||
return user_id # fallback — используем user_id как CN
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_ad_user_info(user_id: str) -> dict | None:
|
||||||
|
"""Получить полную информацию о пользователе из AD."""
|
||||||
|
from utils.ad_search import search_by_user_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
||||||
|
entries = search_by_user_id(search_id, ["cn", "mail", "l", "userAccountControl", "userPrincipalName"])
|
||||||
|
|
||||||
|
if entries:
|
||||||
|
entry = entries[0]
|
||||||
|
cn = str(entry.cn)
|
||||||
|
raw_mail = str(entry.mail) if 'mail' in entry else ""
|
||||||
|
if raw_mail in ("[]", "N/A", ""):
|
||||||
|
raw_mail = None
|
||||||
|
mail = raw_mail if raw_mail else None
|
||||||
|
if not mail:
|
||||||
|
raw_upn = str(entry.userPrincipalName) if 'userPrincipalName' in entry else ""
|
||||||
|
if raw_upn and raw_upn not in ("[]", "N/A", ""):
|
||||||
|
mail = raw_upn
|
||||||
|
city = str(entry.l) if 'l' in entry and entry.l.value else "Кемерово"
|
||||||
|
return {"cn": cn, "mail": mail, "raw_mail": raw_mail, "city": city}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка получения AD info для {user_id}: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_sd_api_request(
|
||||||
|
requester_cn: str,
|
||||||
|
requester_email: str,
|
||||||
|
subject: str,
|
||||||
|
description: str,
|
||||||
|
city: str
|
||||||
|
) -> str | None:
|
||||||
|
"""Автономная прямая отправка HTTP-запроса в Service Desk API v3."""
|
||||||
|
if requester_cn and requester_cn != "N/A":
|
||||||
|
requester_payload = {"name": requester_cn}
|
||||||
|
elif requester_email and requester_email not in (DEFAULT_REQUESTER, "ai@sibcem.ru"):
|
||||||
|
requester_payload = {"email_id": requester_email}
|
||||||
|
else:
|
||||||
|
requester_payload = {"name": "Искусственный Интеллект"}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"request": {
|
||||||
|
"subject": subject,
|
||||||
|
"description": description,
|
||||||
|
"requester": requester_payload,
|
||||||
|
"udf_fields": {
|
||||||
|
"udf_pick_301": city
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if SD_INSTRUCT_GROUP_NAME:
|
||||||
|
payload["request"]["group"] = {"name": SD_INSTRUCT_GROUP_NAME}
|
||||||
|
|
||||||
|
if SD_INSTRUCT_TECHNICIAN_NAME:
|
||||||
|
payload["request"]["technician"] = {"name": SD_INSTRUCT_TECHNICIAN_NAME}
|
||||||
|
|
||||||
|
url = f"{SD_URL.rstrip('/')}/api/v3/requests"
|
||||||
|
headers = {"TECHNICIAN_KEY": SD_TOKEN}
|
||||||
|
data = {"input_data": json.dumps(payload)}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(verify=False) as client:
|
||||||
|
response = await client.post(url, headers=headers, data=data, timeout=15.0)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201):
|
||||||
|
res_json = response.json()
|
||||||
|
ticket_id = res_json.get("request", {}).get("id")
|
||||||
|
return str(ticket_id) if ticket_id else None
|
||||||
|
|
||||||
|
elif response.status_code == 400 and requester_payload != {"name": "Искусственный Интеллект"}:
|
||||||
|
logger.warning(
|
||||||
|
f"ServiceDesk не смог привязать requester={requester_payload}. "
|
||||||
|
f"Повторяем запрос от имени системного заявителя..."
|
||||||
|
)
|
||||||
|
payload["request"]["requester"] = {"name": "Искусственный Интеллект"}
|
||||||
|
payload["request"]["description"] = f"[Пользователь: {requester_cn}]\n\n{description}"
|
||||||
|
data_fb = {"input_data": json.dumps(payload)}
|
||||||
|
|
||||||
|
resp_fb = await client.post(url, headers=headers, data=data_fb, timeout=15.0)
|
||||||
|
if resp_fb.status_code in (200, 201):
|
||||||
|
ticket_id = resp_fb.json().get("request", {}).get("id")
|
||||||
|
return str(ticket_id) if ticket_id else None
|
||||||
|
|
||||||
|
logger.error(f"Service Desk API Error ({response.status_code}): {response.text}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_existing_trueconf_ticket(cn: str, requester_email: str = None) -> str | None:
|
||||||
|
"""Находит открытую или находящуюся в работе заявку по Trueconf."""
|
||||||
|
subject_keyword = "запрос доступа к trueconf"
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{SD_URL.rstrip('/')}/api/v3/requests"
|
||||||
|
headers = {"TECHNICIAN_KEY": SD_TOKEN}
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
"list_info": {
|
||||||
|
"row_count": 100,
|
||||||
|
"sort_field": "created_time",
|
||||||
|
"sort_order": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(verify=False) as client:
|
||||||
|
resp = await client.get(url, headers=headers, params={"input_data": json.dumps(input_data)}, timeout=10.0)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning(f"SD GET list_info returned status {resp.status_code}. Retrying pure GET...")
|
||||||
|
resp = await client.get(url, headers=headers, timeout=10.0)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
requests_list = data.get("requests", [])
|
||||||
|
logger.info(f"Scanning {len(requests_list)} recent tickets for CN='{cn}', Email='{requester_email}'")
|
||||||
|
|
||||||
|
for req in requests_list:
|
||||||
|
req_subject = str(req.get("subject", "")).lower()
|
||||||
|
|
||||||
|
if subject_keyword not in req_subject:
|
||||||
|
continue
|
||||||
|
|
||||||
|
req_id = req.get("id")
|
||||||
|
req_status = req.get("status")
|
||||||
|
|
||||||
|
# 1. Проверяем статус в общем списке
|
||||||
|
if _is_ticket_closed(req_status):
|
||||||
|
logger.info(f"Skipping closed ticket #{req_id} (list status: '{req_status}')")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Checking candidate open ticket #{req_id}")
|
||||||
|
|
||||||
|
detail_url = f"{SD_URL.rstrip('/')}/api/v3/requests/{req_id}"
|
||||||
|
detail_resp = await client.get(detail_url, headers=headers, timeout=10.0)
|
||||||
|
|
||||||
|
if detail_resp.status_code == 200:
|
||||||
|
detail = detail_resp.json().get("request", {})
|
||||||
|
|
||||||
|
# 2. Повторно проверяем статус в деталях заявки
|
||||||
|
detail_status = detail.get("status")
|
||||||
|
if _is_ticket_closed(detail_status):
|
||||||
|
logger.info(f"Skipping closed ticket #{req_id} (detail status: '{detail_status}')")
|
||||||
|
continue
|
||||||
|
|
||||||
|
requester = detail.get("requester", {})
|
||||||
|
req_name = str(requester.get("name", "") or requester.get("display_name", "")).strip().lower()
|
||||||
|
req_email = str(requester.get("email", "") or requester.get("email_id", "")).strip().lower()
|
||||||
|
req_desc = str(detail.get("description", "") or "").lower()
|
||||||
|
|
||||||
|
cn_clean = cn.strip().lower()
|
||||||
|
|
||||||
|
if cn_clean and cn_clean in req_name:
|
||||||
|
logger.info(f"Matched ticket #{req_id} by requester name ('{req_name}')")
|
||||||
|
return str(req_id)
|
||||||
|
|
||||||
|
if (
|
||||||
|
requester_email
|
||||||
|
and requester_email.lower() not in (DEFAULT_REQUESTER.lower(), "ai@sibcem.ru")
|
||||||
|
and req_email == requester_email.lower()
|
||||||
|
):
|
||||||
|
logger.info(f"Matched ticket #{req_id} by email ('{req_email}')")
|
||||||
|
return str(req_id)
|
||||||
|
|
||||||
|
if cn_clean and (f"[пользователь: {cn_clean}]" in req_desc or cn_clean in req_desc):
|
||||||
|
logger.info(f"Matched ticket #{req_id} by user mention in description")
|
||||||
|
return str(req_id)
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to fetch SD requests list completely, HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to search existing tickets: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_sd_ticket_for_trueconf(user_id: str, cn: str) -> str | None:
|
||||||
|
"""Создать заявку в Service Desk для запроса доступа к Trueconf."""
|
||||||
|
subject = "Запрос доступа к Trueconf"
|
||||||
|
|
||||||
|
clean_user_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
||||||
|
|
||||||
|
ad_info = get_ad_user_info(user_id)
|
||||||
|
requester_email = (ad_info.get("mail") if ad_info else None) or DEFAULT_REQUESTER
|
||||||
|
city = ad_info.get("city", "Кемерово") if ad_info else "Кемерово"
|
||||||
|
|
||||||
|
# Защита от дублей
|
||||||
|
existing_ticket = await _find_existing_trueconf_ticket(cn, requester_email)
|
||||||
|
if existing_ticket:
|
||||||
|
logger.info(f"Ticket creation skipped: open ticket #{existing_ticket} already exists for {cn}")
|
||||||
|
return existing_ticket
|
||||||
|
|
||||||
|
description = (
|
||||||
|
f"Пользователь {cn} ({clean_user_id}) запросил инструкцию по Trueconf, "
|
||||||
|
f"но не имеет группы 2FA. Требуется выдача доступа."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Calling SD API directly: cn='{cn}', email='{requester_email}', city='{city}'")
|
||||||
|
ticket_id = await _send_sd_api_request(cn, requester_email, subject, description, city)
|
||||||
|
if ticket_id:
|
||||||
|
logger.info(f"SD ticket created for {cn}: #{ticket_id}")
|
||||||
|
# 📧 ОТПРАВЛЯЕМ ПОЧТОВОЕ УВЕДОМЛЕНИЕ В ФОНЕ
|
||||||
|
asyncio.create_task(send_2fa_alert_email(cn, user_id, ticket_id))
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to create SD ticket for {cn}")
|
||||||
|
return ticket_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка создания SD заявки для {cn}: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.message()
|
@router.message()
|
||||||
@@ -114,29 +421,126 @@ async def instruct_router_handler(msg: Message):
|
|||||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||||
elif cmd == "1":
|
elif cmd == "1":
|
||||||
log_menu_stats(user_id, "Инструкции", "Trueconf")
|
log_menu_stats(user_id, "Инструкции", "Trueconf")
|
||||||
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
|
|
||||||
cn = get_user_cn(user_id)
|
cn = get_user_cn(user_id)
|
||||||
login = user_id.split("@")[0] if "@" in user_id else user_id
|
if cn is None:
|
||||||
ticket_id = None
|
cn = user_id
|
||||||
try:
|
|
||||||
from utils.ad_checker import check_group_membership
|
from utils.ad_checker import check_group_membership
|
||||||
result = check_group_membership(cn, "2FA")
|
ad_check = check_group_membership(cn, "2FA")
|
||||||
if not result.get("in_group", True):
|
|
||||||
subject = f"Запрос доступа к Trueconf"
|
if ad_check.get("in_group", False):
|
||||||
description = f"Пользователь {cn} ({user_id}) запросил инструкцию по Trueconf, но не имеет группы 2FA. Требуется выдача доступа."
|
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
|
||||||
ticket_id = await sd_create_ticket(login, subject, description)
|
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, None)
|
||||||
logger.info(f"SD ticket created for {cn}: #{ticket_id}")
|
if os.path.isfile(TRUECONF_INSTRUCT_PATH):
|
||||||
except Exception as e:
|
await msg.bot.send_document(
|
||||||
logger.error(f"Ошибка создания SD заявки: {e}", exc_info=True)
|
chat_id=msg.chat_id,
|
||||||
ticket_id = None
|
file=FSInputFile(TRUECONF_INSTRUCT_PATH, file_name="Инструкция.docx"),
|
||||||
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, ticket_id)
|
caption=text,
|
||||||
await msg.answer(text, parse_mode="html")
|
parse_mode="html"
|
||||||
if os.path.isfile(TRUECONF_INSTRUCT_PATH):
|
)
|
||||||
await msg.answer_document(FSInputFile(TRUECONF_INSTRUCT_PATH))
|
else:
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
else:
|
||||||
|
ad_info = get_ad_user_info(user_id)
|
||||||
|
requester_email = (ad_info.get("mail") if ad_info else None) or DEFAULT_REQUESTER
|
||||||
|
logger.info(f"[DEBUG] Checking existing ticket for cn={cn}, email={requester_email}")
|
||||||
|
existing_ticket = await _find_existing_trueconf_ticket(cn, requester_email)
|
||||||
|
logger.info(f"[DEBUG] existing_ticket result: {existing_ticket}")
|
||||||
|
if existing_ticket:
|
||||||
|
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
|
||||||
|
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, existing_ticket)
|
||||||
|
if os.path.isfile(TRUECONF_INSTRUCT_PATH):
|
||||||
|
await msg.bot.send_document(
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
file=FSInputFile(TRUECONF_INSTRUCT_PATH, file_name="Инструкция.docx"),
|
||||||
|
caption=text,
|
||||||
|
parse_mode="html"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
else:
|
||||||
|
set_state(user_id, "INSTRUCT_TRUECONF_PENDING")
|
||||||
|
text = INSTRUCT_TRUECONF_PENDING_TEXT(EMOJI_DIGITS, cn)
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
elif cmd == "2":
|
elif cmd == "2":
|
||||||
log_menu_stats(user_id, "Инструкции", "Почта")
|
log_menu_stats(user_id, "Инструкции", "Почта")
|
||||||
|
cn = get_user_cn(user_id)
|
||||||
|
if cn is None:
|
||||||
|
cn = user_id
|
||||||
|
ad_info = get_ad_user_info(user_id)
|
||||||
|
raw_mail = ad_info.get("raw_mail") if ad_info else None
|
||||||
|
if raw_mail:
|
||||||
|
# Пользователь с доступом к почте — спрашиваем про прошлый доступ
|
||||||
|
set_state(user_id, "INSTRUCT_EMAIL_HISTORY")
|
||||||
|
text = INSTRUCT_EMAIL_HISTORY_ASK(EMOJI_DIGITS, cn)
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
else:
|
||||||
|
set_state(user_id, "INSTRUCT_EMAIL_VIEW")
|
||||||
|
text = INSTRUCT_EMAIL_UNAVAILABLE(EMOJI_DIGITS, cn)
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
else:
|
||||||
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
|
elif current_state == "INSTRUCT_TRUECONF_PENDING":
|
||||||
|
msg.handled = True
|
||||||
|
if cmd == "0":
|
||||||
|
clear_state(user_id)
|
||||||
|
from utils.menu import MENU_TEXT
|
||||||
|
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||||
|
elif cmd == "9":
|
||||||
|
set_state(user_id, "INSTRUCT_MODE")
|
||||||
|
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
|
||||||
|
elif cmd == "1":
|
||||||
|
cn = get_user_cn(user_id)
|
||||||
|
if cn is None:
|
||||||
|
cn = user_id
|
||||||
|
|
||||||
|
ticket_id = await create_sd_ticket_for_trueconf(user_id, cn)
|
||||||
|
if ticket_id:
|
||||||
|
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
|
||||||
|
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, ticket_id)
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
else:
|
||||||
|
await msg.answer(INSTRUCT_ERROR_MSG, parse_mode="html")
|
||||||
|
else:
|
||||||
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
|
elif current_state == "INSTRUCT_EMAIL_HISTORY":
|
||||||
|
msg.handled = True
|
||||||
|
if cmd == "0":
|
||||||
|
clear_state(user_id)
|
||||||
|
from utils.menu import MENU_TEXT
|
||||||
|
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||||
|
elif cmd == "9":
|
||||||
|
set_state(user_id, "INSTRUCT_MODE")
|
||||||
|
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
|
||||||
|
elif cmd == "1":
|
||||||
|
# Да, был доступ — показываем инструкции
|
||||||
|
log_menu_stats(user_id, "Инструкции", "Почта (был доступ)")
|
||||||
|
cn = get_user_cn(user_id)
|
||||||
|
if cn is None:
|
||||||
|
cn = user_id
|
||||||
set_state(user_id, "INSTRUCT_EMAIL_VIEW")
|
set_state(user_id, "INSTRUCT_EMAIL_VIEW")
|
||||||
await msg.answer(INSTRUCT_EMAIL_TEXT, parse_mode="html")
|
text = INSTRUCT_EMAIL_AVAILABLE(EMOJI_DIGITS, cn, "")
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
for instruct_path, file_name in [
|
||||||
|
(GMAIL_ANDROID_INSTRUCT_PATH, "Инструкция Android.docx"),
|
||||||
|
(APPLE_INSTRUCT_PATH, "Инструкция Apple.docx"),
|
||||||
|
]:
|
||||||
|
if os.path.isfile(instruct_path):
|
||||||
|
await msg.bot.send_document(
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
file=FSInputFile(instruct_path, file_name=file_name),
|
||||||
|
)
|
||||||
|
elif cmd == "2":
|
||||||
|
# Нет, не было доступа — показываем текст про заявку
|
||||||
|
log_menu_stats(user_id, "Инструкции", "Почта (не было доступа)")
|
||||||
|
cn = get_user_cn(user_id)
|
||||||
|
if cn is None:
|
||||||
|
cn = user_id
|
||||||
|
set_state(user_id, "INSTRUCT_EMAIL_VIEW")
|
||||||
|
text = INSTRUCT_EMAIL_NOT_HAD(EMOJI_DIGITS, cn)
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
else:
|
else:
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
@@ -150,4 +554,4 @@ async def instruct_router_handler(msg: Message):
|
|||||||
set_state(user_id, "INSTRUCT_MODE")
|
set_state(user_id, "INSTRUCT_MODE")
|
||||||
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
|
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
|
||||||
else:
|
else:
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
Binary file not shown.
Binary file not shown.
+99
-35
@@ -57,7 +57,7 @@ EMOJI_DIGITS = {
|
|||||||
"10": "🔟"
|
"10": "🔟"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Список из 7 справок строго по структуру портала
|
# Список из 7 справок строго по структуре портала
|
||||||
DOC_TYPES = {
|
DOC_TYPES = {
|
||||||
"1": "Справка о доходах физического лица (2-НДФЛ)",
|
"1": "Справка о доходах физического лица (2-НДФЛ)",
|
||||||
"2": "Справка об удержаниях за ДМС",
|
"2": "Справка об удержаниях за ДМС",
|
||||||
@@ -74,7 +74,7 @@ LK_DOCS_MENU_TEXT = (
|
|||||||
f"\n\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
|
f"\n\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
|
||||||
)
|
)
|
||||||
|
|
||||||
USER_COOLDOWN_SEC = 1.5
|
USER_COOLDOWN_SEC = 0.5
|
||||||
EMAIL_COOLDOWN_SEC = 10.0
|
EMAIL_COOLDOWN_SEC = 10.0
|
||||||
|
|
||||||
user_last_request = {}
|
user_last_request = {}
|
||||||
@@ -122,25 +122,24 @@ def log_and_notify_error(user_id: str, action: str, error_details: str):
|
|||||||
|
|
||||||
async def handle_lk_error(msg: Message, user_id: str, action: str, error_details: str):
|
async def handle_lk_error(msg: Message, user_id: str, action: str, error_details: str):
|
||||||
await asyncio.to_thread(log_and_notify_error, user_id, action, error_details)
|
await asyncio.to_thread(log_and_notify_error, user_id, action, error_details)
|
||||||
error_msg = lk_error_handling(EMOJI_DIGITS)
|
error_msg = system_error_text(EMOJI_DIGITS)
|
||||||
await msg.answer(error_msg, parse_mode="html")
|
await msg.answer(error_msg, parse_mode="html")
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# ИНТЕГРАЦИЯ С ACTIVE DIRECTORY ЧЕРЕЗ extensionAttribute2
|
# ИНТЕГРАЦИЯ С ACTIVE DIRECTORY ЧЕРЕЗ extensionAttribute2
|
||||||
# =========================================================
|
# =========================================================
|
||||||
def get_card_id_from_ad(user_id: str) -> str:
|
def get_card_id_from_ad(user_id: str) -> str:
|
||||||
# Поиск CARD_ID по всем OU из AD_BASES
|
|
||||||
from utils.ad_search import search_by_user_id
|
from utils.ad_search import search_by_user_id
|
||||||
try:
|
try:
|
||||||
entries = search_by_user_id(user_id, ['extensionAttribute2'])
|
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
||||||
if not entries:
|
entries = search_by_user_id(search_id, attributes=['extensionAttribute2'])
|
||||||
raise Exception("Пользователь не найден в AD.")
|
|
||||||
entry = entries[0]
|
if entries and 'extensionAttribute2' in entries[0] and entries[0].extensionAttribute2.value:
|
||||||
if 'extensionAttribute2' in entry and entry.extensionAttribute2.value:
|
return str(entries[0].extensionAttribute2.value).strip()
|
||||||
return str(entry.extensionAttribute2.value).strip()
|
|
||||||
raise Exception("Поле extensionAttribute2 не заполнено в AD.")
|
raise Exception("Поле extensionAttribute2 не заполнено в AD.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception("AD Card Query Error: {}".format(e))
|
raise Exception(f"AD Card Query Error: {e}")
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА
|
# МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА
|
||||||
@@ -199,18 +198,64 @@ async def send_dynamic_lk_main_menu(msg: Message, user_id: str):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Не удалось собрать динамический заголовок ЛК: {e}")
|
logger.error(f"Не удалось собрать динамический заголовок ЛК: {e}")
|
||||||
|
await handle_lk_error(msg, user_id, "Сборка заголовка ЛК", str(e))
|
||||||
header = "👤 <b>Личный кабинет</b>\n\n"
|
header = "👤 <b>Личный кабинет</b>\n\n"
|
||||||
|
|
||||||
menu_text = lk_main_menu_text(header, EMOJI_DIGITS)
|
menu_text = lk_main_menu_text(header, EMOJI_DIGITS)
|
||||||
await msg.answer(menu_text, parse_mode="html")
|
await msg.answer(menu_text, parse_mode="html")
|
||||||
|
|
||||||
async def send_tabel_months_menu(msg: Message, user_id: str):
|
async def send_tabel_months_menu(msg: Message, user_id: str):
|
||||||
now = datetime(2026, 6, 3)
|
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||||
prev_1 = now.replace(day=1) - timedelta(days=1)
|
months_ru = {
|
||||||
prev_2 = prev_1.replace(day=1) - timedelta(days=1)
|
1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь",
|
||||||
months_ru = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
|
7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"
|
||||||
|
}
|
||||||
|
|
||||||
menu_text = lk_tabel_months_menu_text(prev_1, prev_2, months_ru, EMOJI_DIGITS)
|
now = datetime.now()
|
||||||
|
# Генерируем список из 4 месяцев: текущий, прошлый, позапрошлый, за два месяца
|
||||||
|
candidates = []
|
||||||
|
m = now
|
||||||
|
for _ in range(4):
|
||||||
|
candidates.append((m.year, m.month))
|
||||||
|
m = m.replace(day=1) - timedelta(days=1)
|
||||||
|
|
||||||
|
# Проверяем какие месяцы имеют данные в БД
|
||||||
|
import lk.info_service as info_service
|
||||||
|
conn = info_service.get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
available = []
|
||||||
|
for year, month in candidates:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT TOP 1 1 FROM UOV_SELFSERVICE_TB_TABEL WHERE EMP_ID IN (SELECT ID FROM UOV_SELFSERVICE_PR_EMP WHERE CARD_ID=%s) AND YEAR(D)=%s AND MONTH(D)=%s",
|
||||||
|
(card_id, year, month)
|
||||||
|
)
|
||||||
|
if cursor.fetchone():
|
||||||
|
available.append((year, month))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not available:
|
||||||
|
await msg.answer("⚠️ <b>Данные табеля отсутствуют.</b>\n\nЗа указанный период данные в системе не найдены.", parse_mode="html")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Строим динамическое меню
|
||||||
|
lines = ["⏱ <b>Выберите месяц для просмотра табеля:</b>\n\n"]
|
||||||
|
for idx, (year, month) in enumerate(available, 1):
|
||||||
|
digit = EMOJI_DIGITS.get(str(idx), str(idx))
|
||||||
|
lines.append(f"{digit} — {months_ru[month]} {year} г.")
|
||||||
|
|
||||||
|
lines.append(f"\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>")
|
||||||
|
menu_text = "\n".join(lines)
|
||||||
|
|
||||||
|
# Сохраняем доступные месяцы в сессию для обработчика
|
||||||
|
user_page_sessions[user_id] = {
|
||||||
|
"tabel_months": available,
|
||||||
|
"current_page": 0,
|
||||||
|
"per_page": 5,
|
||||||
|
"static_header": "",
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
set_state(user_id, "LK_TABEL_MONTHS")
|
set_state(user_id, "LK_TABEL_MONTHS")
|
||||||
await msg.answer(menu_text, parse_mode="html")
|
await msg.answer(menu_text, parse_mode="html")
|
||||||
|
|
||||||
@@ -266,13 +311,22 @@ async def build_confirmation_text(user_id: str, doc: dict) -> str:
|
|||||||
async def lk_router_handler(msg: Message):
|
async def lk_router_handler(msg: Message):
|
||||||
user_id = msg.from_user.id
|
user_id = msg.from_user.id
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if current_time - user_last_request.get(user_id, 0) < USER_COOLDOWN_SEC: return
|
current_state = get_state(user_id)
|
||||||
|
|
||||||
|
# Если пользователь находится внутри меню ЛК, помечаем сообщение обработанным,
|
||||||
|
# чтобы оно не улетало в ServiceDesk / Instruct роутеры
|
||||||
|
if current_state and current_state.startswith("LK_"):
|
||||||
|
msg.handled = True
|
||||||
|
|
||||||
|
# Проверка на слишком частые клики (кулдаун)
|
||||||
|
if current_time - user_last_request.get(user_id, 0) < USER_COOLDOWN_SEC:
|
||||||
|
return
|
||||||
user_last_request[user_id] = current_time
|
user_last_request[user_id] = current_time
|
||||||
|
|
||||||
current_state = get_state(user_id)
|
|
||||||
nav_text = lk_nav_text(EMOJI_DIGITS)
|
nav_text = lk_nav_text(EMOJI_DIGITS)
|
||||||
|
|
||||||
if not msg.text: return
|
if not msg.text:
|
||||||
|
return
|
||||||
cmd = msg.text.strip().lower()
|
cmd = msg.text.strip().lower()
|
||||||
|
|
||||||
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
||||||
@@ -371,7 +425,6 @@ async def lk_router_handler(msg: Message):
|
|||||||
set_state(user_id, "LK_DOCS_MENU")
|
set_state(user_id, "LK_DOCS_MENU")
|
||||||
await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
|
await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Вместо спама копии меню ЛК выводим строгое предупреждение
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
elif current_state == "LK_TABEL_MONTHS":
|
elif current_state == "LK_TABEL_MONTHS":
|
||||||
@@ -383,17 +436,31 @@ async def lk_router_handler(msg: Message):
|
|||||||
elif cmd == "9":
|
elif cmd == "9":
|
||||||
set_state(user_id, "LK_MODE")
|
set_state(user_id, "LK_MODE")
|
||||||
await send_dynamic_lk_main_menu(msg, user_id)
|
await send_dynamic_lk_main_menu(msg, user_id)
|
||||||
elif cmd in ["1", "2"]:
|
elif cmd.isdigit():
|
||||||
log_menu_stats(user_id, "Личный кабинет", "Просмотр табеля за месяц")
|
session = user_page_sessions.get(user_id)
|
||||||
|
if not session or not session.get("tabel_months"):
|
||||||
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
return
|
||||||
|
idx = int(cmd) - 1
|
||||||
|
available = session["tabel_months"]
|
||||||
|
if idx < 0 or idx >= len(available):
|
||||||
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
return
|
||||||
|
target_year, target_month = available[idx]
|
||||||
|
log_menu_stats(user_id, "Личный кабинет", f"Просмотр табеля ({target_month}.{target_year})")
|
||||||
await msg.answer(lk_tabel_loading(EMOJI_DIGITS), parse_mode="html")
|
await msg.answer(lk_tabel_loading(EMOJI_DIGITS), parse_mode="html")
|
||||||
try:
|
try:
|
||||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||||
now = datetime(2026, 6, 3)
|
tabel_report = await asyncio.to_thread(
|
||||||
target_date = now.replace(day=1) - timedelta(days=1) if cmd == "1" else (now.replace(day=1) - timedelta(days=1)).replace(day=1) - timedelta(days=1)
|
get_tabel_report_text,
|
||||||
tabel_report = await asyncio.to_thread(get_tabel_report_text, card_id, target_date.year, target_date.month)
|
card_id,
|
||||||
|
target_year,
|
||||||
|
target_month
|
||||||
|
)
|
||||||
set_state(user_id, "LK_TABEL_VIEW")
|
set_state(user_id, "LK_TABEL_VIEW")
|
||||||
await msg.answer(tabel_report + nav_text, parse_mode="html")
|
await msg.answer(tabel_report + nav_text, parse_mode="html")
|
||||||
except Exception as e: await handle_lk_error(msg, user_id, "Формирование табеля", str(e))
|
except Exception as e:
|
||||||
|
await handle_lk_error(msg, user_id, "Формирование табеля", str(e))
|
||||||
else:
|
else:
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
@@ -439,7 +506,8 @@ async def lk_router_handler(msg: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not session:
|
if not session:
|
||||||
await msg.answer("⚠️ <i>Сессия авторизации не найдена. Начните сначала.</i>", parse_mode="html")
|
logger.warning(f"Сессия авторизации не найдена для пользователя {user_id}")
|
||||||
|
await handle_lk_error(msg, user_id, "Сессия авторизации не найдена", f"user_id={user_id}")
|
||||||
set_state(user_id, "LK_MODE")
|
set_state(user_id, "LK_MODE")
|
||||||
await send_dynamic_lk_main_menu(msg, user_id)
|
await send_dynamic_lk_main_menu(msg, user_id)
|
||||||
return
|
return
|
||||||
@@ -553,7 +621,8 @@ async def lk_router_handler(msg: Message):
|
|||||||
else:
|
else:
|
||||||
raise Exception(f"Внутренний каскадный шлюз недоступен: {channel_info}")
|
raise Exception(f"Внутренний каскадный шлюз недоступен: {channel_info}")
|
||||||
else:
|
else:
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
logger.warning(f"Неверный индекс месяца для пользователя {user_id}: cmd={cmd}")
|
||||||
|
await handle_lk_error(msg, user_id, "Неверный индекс месяца", f"user_id={user_id}, cmd={cmd}")
|
||||||
except Exception as e: await handle_lk_error(msg, user_id, "Инициализация 2FA расчетного листа", str(e))
|
except Exception as e: await handle_lk_error(msg, user_id, "Инициализация 2FA расчетного листа", str(e))
|
||||||
else:
|
else:
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
@@ -577,7 +646,6 @@ async def lk_router_handler(msg: Message):
|
|||||||
set_state(user_id, "LK_DOCS_QUANTITY")
|
set_state(user_id, "LK_DOCS_QUANTITY")
|
||||||
await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html")
|
await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html")
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Заменен спам списка документов на чистую ошибку ввода
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
elif current_state == "LK_DOCS_QUANTITY":
|
elif current_state == "LK_DOCS_QUANTITY":
|
||||||
@@ -605,7 +673,6 @@ async def lk_router_handler(msg: Message):
|
|||||||
confirm_txt = await build_confirmation_text(user_id, user_docs_session[user_id])
|
confirm_txt = await build_confirmation_text(user_id, user_docs_session[user_id])
|
||||||
await msg.answer(confirm_txt, parse_mode="html")
|
await msg.answer(confirm_txt, parse_mode="html")
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Вместо кастомной подсказки выводим единый стандарт
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
elif current_state == "LK_DOCS_PERIOD":
|
elif current_state == "LK_DOCS_PERIOD":
|
||||||
@@ -618,14 +685,12 @@ async def lk_router_handler(msg: Message):
|
|||||||
if cmd in ["1", "2", "3", "4", "5", "6", "7", "8"]:
|
if cmd in ["1", "2", "3", "4", "5", "6", "7", "8"]:
|
||||||
user_docs_session[user_id]['period'] = f"за {datetime.now().year - (int(cmd) - 1)} год"
|
user_docs_session[user_id]['period'] = f"за {datetime.now().year - (int(cmd) - 1)} год"
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Убран спам меню годов при абракадабре
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
return
|
return
|
||||||
elif doc_id == "5":
|
elif doc_id == "5":
|
||||||
if cmd == "1": user_docs_session[user_id]['period'] = "до 1.5 лет"
|
if cmd == "1": user_docs_session[user_id]['period'] = "до 1.5 лет"
|
||||||
elif cmd == "2": user_docs_session[user_id]['period'] = "до 3 лет"
|
elif cmd == "2": user_docs_session[user_id]['period'] = "до 3 лет"
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Убран спам меню возраста при некорректном вводе
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
return
|
return
|
||||||
elif doc_id == "3":
|
elif doc_id == "3":
|
||||||
@@ -669,7 +734,6 @@ async def lk_router_handler(msg: Message):
|
|||||||
except Exception as e: await handle_lk_error(msg, user_id, f"Заказ справки {user_docs_session.get(user_id, {}).get('doc_id')}", str(e)); set_state(user_id, "LK_MODE")
|
except Exception as e: await handle_lk_error(msg, user_id, f"Заказ справки {user_docs_session.get(user_id, {}).get('doc_id')}", str(e)); set_state(user_id, "LK_MODE")
|
||||||
finally: user_docs_session.pop(user_id, None)
|
finally: user_docs_session.pop(user_id, None)
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Удален спам-текст подтверждения
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
elif current_state == "LK_DOCS_SUCCESS":
|
elif current_state == "LK_DOCS_SUCCESS":
|
||||||
@@ -677,5 +741,5 @@ async def lk_router_handler(msg: Message):
|
|||||||
if cmd == "0": clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html")
|
if cmd == "0": clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html")
|
||||||
elif cmd == "9": set_state(user_id, "LK_DOCS_MENU"); await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
|
elif cmd == "9": set_state(user_id, "LK_DOCS_MENU"); await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Унифицировано под общий шаблон ошибки
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Диагностика данных сотрудника для модуля ЛК (расчетnye listki)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
BASE_DIR = "/opt/trueconf_bot"
|
||||||
|
os.chdir(BASE_DIR)
|
||||||
|
|
||||||
|
# Загрузка .env
|
||||||
|
def load_env(filepath):
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
value = value.strip(' "')
|
||||||
|
value = value.strip("'")
|
||||||
|
value = value.strip()
|
||||||
|
os.environ[key.strip()] = value
|
||||||
|
|
||||||
|
load_env(os.path.join(BASE_DIR, "config", ".env"))
|
||||||
|
|
||||||
|
# Загрузка passwork
|
||||||
|
for pw_path in ["/opt/passwork", os.path.join(BASE_DIR, "passwork")]:
|
||||||
|
if os.path.isdir(pw_path) and pw_path not in sys.path:
|
||||||
|
sys.path.insert(0, pw_path)
|
||||||
|
|
||||||
|
from passwork import get_passwork_secrets
|
||||||
|
card_name = os.getenv("PW_ID_SQL", "").strip()
|
||||||
|
passwork_pool = get_passwork_secrets(required_cards=[card_name])
|
||||||
|
card_data = passwork_pool[card_name]
|
||||||
|
|
||||||
|
DB_USER = card_data["login"]
|
||||||
|
DB_PASSWORD = card_data["password"]
|
||||||
|
SQL_SERVER = os.getenv("SQL_SERVER", "SRVKEM-MOBILEIN.sibcem.ru")
|
||||||
|
SQL_DB_NAME = os.getenv("SQL_DB_NAME", "BossCopy")
|
||||||
|
|
||||||
|
import pymssql
|
||||||
|
import ldap3
|
||||||
|
import calendar
|
||||||
|
|
||||||
|
# --- Подключение к AD ---
|
||||||
|
print("=" * 60)
|
||||||
|
print("Диагностика сотрудника: la.guseynova@sibcem.ru")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
email = "la.guseynova@sibcem.ru"
|
||||||
|
search_id = email.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
||||||
|
short_name = search_id.split("@")[0]
|
||||||
|
|
||||||
|
print(f"\n--- 0. Поиск в AD ---")
|
||||||
|
print(f" Email: {email}")
|
||||||
|
print(f" Search ID: {search_id}")
|
||||||
|
print(f" Short (без домена): {short_name}")
|
||||||
|
|
||||||
|
AD_SERVER = "172.16.20.20"
|
||||||
|
AD_BASE = "DC=sibcem,DC=ru"
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = ldap3.Connection(
|
||||||
|
ldap3.Server(AD_SERVER, connect_timeout=5),
|
||||||
|
auto_bind=True,
|
||||||
|
read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
search_filter = f"(|(mail={search_id}*)(sAMAccountName={short_name})(userPrincipalName={search_id}*)(anr={short_name}))"
|
||||||
|
print(f" LDAP-фильтр: {search_filter}")
|
||||||
|
|
||||||
|
conn.search(
|
||||||
|
search_base=AD_BASE,
|
||||||
|
search_filter=search_filter,
|
||||||
|
attributes=["cn", "sAMAccountName", "mail", "userPrincipalName", "extensionAttribute2", "mobile", "personalPager"]
|
||||||
|
)
|
||||||
|
if conn.entries:
|
||||||
|
entry = conn.entries[0]
|
||||||
|
cn = str(entry.cn)
|
||||||
|
sam = str(entry.sAMAccountName)
|
||||||
|
mail = str(entry.mail) if 'mail' in entry else ""
|
||||||
|
upn = str(entry.userPrincipalName) if 'userPrincipalName' in entry else ""
|
||||||
|
ext2 = str(entry.extensionAttribute2) if 'extensionAttribute2' in entry else ""
|
||||||
|
mobile = str(entry.mobile) if 'mobile' in entry else ""
|
||||||
|
pager = str(entry.personalPager) if 'personalPager' in entry else ""
|
||||||
|
|
||||||
|
print(f"\n НАЙДЕН:")
|
||||||
|
print(f" CN: {cn}")
|
||||||
|
print(f" sAMAccountName: {sam}")
|
||||||
|
print(f" mail: {mail}")
|
||||||
|
print(f" UPN: {upn}")
|
||||||
|
print(f" extensionAttribute2 (CARD_ID): {ext2}")
|
||||||
|
print(f" mobile: {mobile}")
|
||||||
|
print(f" pager: {pager}")
|
||||||
|
else:
|
||||||
|
print(" Не найден!")
|
||||||
|
conn.unbind()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
conn.unbind()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Ошибка подключения к AD: {e}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# --- Подключение к SQL ---
|
||||||
|
print(f"\n--- 1. Подключение к SQL ---")
|
||||||
|
try:
|
||||||
|
conn_sql = pymssql.connect(
|
||||||
|
server=SQL_SERVER,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASSWORD,
|
||||||
|
database=SQL_DB_NAME,
|
||||||
|
charset="cp1251",
|
||||||
|
login_timeout=10
|
||||||
|
)
|
||||||
|
cursor = conn_sql.cursor()
|
||||||
|
print(f" Подключено к {SQL_SERVER}/{SQL_DB_NAME}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Ошибка подключения к SQL: {e}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# --- Получаем CARD_ID ---
|
||||||
|
card_id = None
|
||||||
|
print(f"\n--- 2. Поиск CARD_ID в PR_CARD ---")
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT c.ID FROM UOV_SELFSERVICE_PR_CARD c "
|
||||||
|
"JOIN UOV_SELFSERVICE_PR_EMP e ON e.CARD_ID = c.ID "
|
||||||
|
"WHERE e.EMAIL_ID LIKE %s OR e.USERNAME LIKE %s",
|
||||||
|
(f"%{short_name}%", f"%{short_name}%")
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
if rows:
|
||||||
|
for r in rows:
|
||||||
|
print(f" CARD_ID (по username): {r[0]}")
|
||||||
|
card_id = rows[0][0]
|
||||||
|
else:
|
||||||
|
print(f" Не найден по username")
|
||||||
|
|
||||||
|
if not card_id:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT c.ID FROM UOV_SELFSERVICE_PR_CARD c "
|
||||||
|
"JOIN UOV_SELFSERVICE_PR_EMP e ON e.CARD_ID = c.ID "
|
||||||
|
"WHERE e.EMAIL_ID LIKE %s",
|
||||||
|
(f"%{search_id.split('@')[0]}%",)
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
if rows:
|
||||||
|
for r in rows:
|
||||||
|
print(f" CARD_ID (по email): {r[0]}")
|
||||||
|
card_id = rows[0][0]
|
||||||
|
else:
|
||||||
|
print(f" CARD_ID не найден!")
|
||||||
|
conn_sql.close()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f"\n Используем CARD_ID: {card_id}")
|
||||||
|
|
||||||
|
# --- PR_EMP ---
|
||||||
|
print(f"\n--- 3. PR_EMP (профиль сотрудника) ---")
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT ID, FIRM_ID, EMAIL_ID, USERNAME FROM UOV_SELFSERVICE_PR_EMP WHERE CARD_ID = %s",
|
||||||
|
(card_id,)
|
||||||
|
)
|
||||||
|
emp_rows = cursor.fetchall()
|
||||||
|
if emp_rows:
|
||||||
|
for r in emp_rows:
|
||||||
|
print(f" EMP_ID: {r[0]}, FIRM_ID: {r[1]}, EMAIL: {r[2]}, USERNAME: {r[3]}")
|
||||||
|
else:
|
||||||
|
print(" ПУСТО - нет записей в PR_EMP")
|
||||||
|
|
||||||
|
# --- PR_TRANS ---
|
||||||
|
print(f"\n--- 4. PR_TRANS (кадровые назначения) ---")
|
||||||
|
if emp_rows:
|
||||||
|
emp_ids = [r[0] for r in emp_rows]
|
||||||
|
placeholders = ",".join(["%s"] * len(emp_ids))
|
||||||
|
cursor.execute(
|
||||||
|
f"SELECT EMP_ID, D_FROM, D_TO, TP_ABBR, SYSPAY_ID "
|
||||||
|
f"FROM UOV_SELFSERVICE_PR_TRANS "
|
||||||
|
f"WHERE EMP_ID IN ({placeholders}) "
|
||||||
|
f"ORDER BY D_FROM DESC",
|
||||||
|
emp_ids
|
||||||
|
)
|
||||||
|
trans_rows = cursor.fetchall()
|
||||||
|
if trans_rows:
|
||||||
|
print(f" Найдено записей: {len(trans_rows)}")
|
||||||
|
for r in trans_rows[:10]:
|
||||||
|
print(f" EMP_ID: {r[0]}, D_FROM: {r[1]}, D_TO: {r[2]}, TP: {r[3]}, SYSPAY: {r[4]}")
|
||||||
|
else:
|
||||||
|
print(" ПУСТО - нет записей в PR_TRANS")
|
||||||
|
else:
|
||||||
|
print(" Пропуск (нет PR_EMP)")
|
||||||
|
|
||||||
|
# --- SL_YTAX ---
|
||||||
|
print(f"\n--- 5. SL_YTAX (периоды с данными) ---")
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT DISTINCT TYEAR, TMONTH, CMONTH FROM UOV_SELFSERVICE_SL_YTAX "
|
||||||
|
"WHERE CARD_ID = %s ORDER BY CMONTH DESC",
|
||||||
|
(card_id,)
|
||||||
|
)
|
||||||
|
tax_rows = cursor.fetchall()
|
||||||
|
if tax_rows:
|
||||||
|
print(f" Найдено периодов: {len(tax_rows)}")
|
||||||
|
for r in tax_rows:
|
||||||
|
print(f" {r[0]}-{r[1]:02d} (CMONTH={r[2]})")
|
||||||
|
else:
|
||||||
|
print(" ПУСТО - нет периодов в SL_YTAX")
|
||||||
|
|
||||||
|
# --- 6. Проверка q_firm для ВСЕХ периодов ---
|
||||||
|
print(f"\n--- 6. Проверка q_firm для КАЖДОГО периода ---")
|
||||||
|
if tax_rows:
|
||||||
|
for tax_row in tax_rows:
|
||||||
|
tyear = tax_row[0]
|
||||||
|
tmonth = tax_row[1]
|
||||||
|
cmonth = tax_row[2]
|
||||||
|
_, last_day = calendar.monthrange(tyear, tmonth)
|
||||||
|
date_start = f"{tyear}-{tmonth:02d}-01"
|
||||||
|
date_end = f"{tyear}-{tmonth:02d}-{last_day:02d}"
|
||||||
|
date_params = (date_start, date_start, date_end, date_end, date_end)
|
||||||
|
|
||||||
|
q_firm = """
|
||||||
|
select e.FIRM_ID, f.SNAME from (select e.id, e.FIRM_ID from UOV_SELFSERVICE_PR_EMP e
|
||||||
|
join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID where c.ID = %s
|
||||||
|
group by e.ID, e.FIRM_ID ) as e
|
||||||
|
join UOV_SELFSERVICE_PR_TRANS t on t.EMP_ID = e.ID
|
||||||
|
join UOV_SELFSERVICE_HR_FIRM f on f.ID = e.FIRM_ID
|
||||||
|
where ((t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO = '1999-12-31'))
|
||||||
|
group by e.FIRM_ID, f.SNAME
|
||||||
|
"""
|
||||||
|
cursor.execute(q_firm, (card_id, *date_params))
|
||||||
|
firm_row = cursor.fetchone()
|
||||||
|
if firm_row:
|
||||||
|
print(f" {tyear}-{tmonth:02d} (CMONTH={cmonth}): ✅ FIRM_ID={firm_row[0]}, SNAME={firm_row[1]}")
|
||||||
|
else:
|
||||||
|
print(f" {tyear}-{tmonth:02d} (CMONTH={cmonth}): ❌ q_firm НЕ НАШЁЛ FIRM_ID!")
|
||||||
|
|
||||||
|
# Фоллбэк
|
||||||
|
q_firm_fb = """
|
||||||
|
select e.FIRM_ID from UOV_SELFSERVICE_PR_EMP e
|
||||||
|
join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID
|
||||||
|
where c.ID = %s
|
||||||
|
group by e.ID, e.FIRM_ID
|
||||||
|
"""
|
||||||
|
cursor.execute(q_firm_fb, (card_id,))
|
||||||
|
fb_row = cursor.fetchone()
|
||||||
|
if fb_row:
|
||||||
|
print(f" Фоллбэк: FIRM_ID={fb_row[0]}")
|
||||||
|
else:
|
||||||
|
print(f" Фоллбэк НЕ НАШЁЛ - критическая ошибка!")
|
||||||
|
else:
|
||||||
|
print(" Нет периодов для проверки")
|
||||||
|
|
||||||
|
# --- 7. Сводка ---
|
||||||
|
print(f"\n--- 7. Сводка ---")
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT COUNT(DISTINCT CMONTH), MIN(CMONTH), MAX(CMONTH) FROM UOV_SELFSERVICE_SL_YTAX WHERE CARD_ID = %s",
|
||||||
|
(card_id,)
|
||||||
|
)
|
||||||
|
summary = cursor.fetchone()
|
||||||
|
if summary:
|
||||||
|
print(f" Периодов в SL_YTAX: {summary[0]}")
|
||||||
|
print(f" Диапазон: {summary[1]} - {summary[2]}")
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("Диагностика завершена")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
conn_sql.close()
|
||||||
@@ -19,7 +19,7 @@ from photo_bot.photo_processor import prepare_ad_photo
|
|||||||
from utils.texts import (
|
from utils.texts import (
|
||||||
EMOJI_DIGITS,
|
EMOJI_DIGITS,
|
||||||
PHOTO_MAIN_MENU_TEXT,
|
PHOTO_MAIN_MENU_TEXT,
|
||||||
PHOTO_UNKNOWN_CMD_TEXT,
|
system_error_text,
|
||||||
photo_error_handling,
|
photo_error_handling,
|
||||||
PHOTO_UPLOADING_TEXT,
|
PHOTO_UPLOADING_TEXT,
|
||||||
photo_success_sent,
|
photo_success_sent,
|
||||||
@@ -188,7 +188,7 @@ async def photo_module_handler(msg: Message):
|
|||||||
|
|
||||||
# --- ИСПРАВЛЕНО: Защита от мусорного ввода при висящем превью ---
|
# --- ИСПРАВЛЕНО: Защита от мусорного ввода при висящем превью ---
|
||||||
else:
|
else:
|
||||||
await msg.answer(PHOTO_UNKNOWN_CMD_TEXT, parse_mode="html")
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- ПРИЕМ НОВОГО ФОТО ---
|
# --- ПРИЕМ НОВОГО ФОТО ---
|
||||||
@@ -240,3 +240,4 @@ async def photo_module_handler(msg: Message):
|
|||||||
except Exception as cleanup_err:
|
except Exception as cleanup_err:
|
||||||
await asyncio.to_thread(log_and_notify_photo_error, user_id, "Очистка временных файлов", str(cleanup_err))
|
await asyncio.to_thread(log_and_notify_photo_error, user_id, "Очистка временных файлов", str(cleanup_err))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
+177
-136
@@ -23,7 +23,7 @@ if not sd_workflow_logger.handlers:
|
|||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
sd_workflow_logger.addHandler(handler)
|
sd_workflow_logger.addHandler(handler)
|
||||||
# ----------------------------------
|
# ----------------------------------
|
||||||
|
|
||||||
# Абсолютный импорт конфигурации
|
# Абсолютный импорт конфигурации
|
||||||
spec = importlib.util.spec_from_file_location("custom_config", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "config.py"))
|
spec = importlib.util.spec_from_file_location("custom_config", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "config.py"))
|
||||||
custom_config = importlib.util.module_from_spec(spec)
|
custom_config = importlib.util.module_from_spec(spec)
|
||||||
@@ -44,14 +44,14 @@ from utils.texts import (
|
|||||||
sd_uploading_file,
|
sd_uploading_file,
|
||||||
sd_file_attached,
|
sd_file_attached,
|
||||||
sd_file_upload_error,
|
sd_file_upload_error,
|
||||||
|
system_error_text,
|
||||||
|
UNKNOWN_MAIN_CMD_TEXT,
|
||||||
)
|
)
|
||||||
from utils.texts import UNKNOWN_MAIN_CMD_TEXT
|
|
||||||
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
|
||||||
from utils.stats_logger import log_menu_stats
|
from utils.stats_logger import log_menu_stats
|
||||||
|
|
||||||
# Таймаут ожидания вложений после текста (секунды)
|
# Таймаут ожидания вложений после текста (секунды)
|
||||||
ATTACHMENT_WAIT_TIMEOUT = 2.0
|
ATTACHMENT_WAIT_TIMEOUT = 2.0
|
||||||
|
|
||||||
SD_DISPATCHER_SYSTEM_PROMPT = (
|
SD_DISPATCHER_SYSTEM_PROMPT = (
|
||||||
"Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\n"
|
"Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\n"
|
||||||
"ПРАВИЛА:\n"
|
"ПРАВИЛА:\n"
|
||||||
@@ -96,14 +96,14 @@ async def generate_smart_subject(text: str) -> str:
|
|||||||
OLLAMA_SUBJECT_MODEL = custom_config.OLLAMA_SUBJECT_MODEL
|
OLLAMA_SUBJECT_MODEL = custom_config.OLLAMA_SUBJECT_MODEL
|
||||||
def has_chinese(t): return bool(re.search(r'[\u4e00-\u9fff]', str(t)))
|
def has_chinese(t): return bool(re.search(r'[\u4e00-\u9fff]', str(t)))
|
||||||
if not text or len(text.split()) < 2: return "Заявка из КЛЕВЕР"
|
if not text or len(text.split()) < 2: return "Заявка из КЛЕВЕР"
|
||||||
|
|
||||||
user_msg = f"Сформулируй краткую тему для следующей ИТ-заявки:\n{text}"
|
user_msg = f"Сформулируй краткую тему для следующей ИТ-заявки:\n{text}"
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": SD_DISPATCHER_SYSTEM_PROMPT},
|
{"role": "system", "content": SD_DISPATCHER_SYSTEM_PROMPT},
|
||||||
{"role": "user", "content": user_msg}
|
{"role": "user", "content": user_msg}
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = {"model": OLLAMA_SUBJECT_MODEL, "messages": messages, "temperature": 0.0}
|
payload = {"model": OLLAMA_SUBJECT_MODEL, "messages": messages, "temperature": 0.0}
|
||||||
sd_workflow_logger.info(f"📝 [Subject Gen] Payload: {payload}")
|
sd_workflow_logger.info(f"📝 [Subject Gen] Payload: {payload}")
|
||||||
@@ -131,17 +131,27 @@ async def create_ticket_in_sd(requester_email: str, subject: str, description: s
|
|||||||
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
|
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
|
||||||
endpoint = f"{SD_URL}/api/v3/requests"
|
endpoint = f"{SD_URL}/api/v3/requests"
|
||||||
html_desc = f"<p>{description.replace(chr(10), '<br>')}</p><br><hr><p style='color:#555;font-size:12px;'><i>Создано через TrueConf КЛЕВЕР</i></p>"
|
html_desc = f"<p>{description.replace(chr(10), '<br>')}</p><br><hr><p style='color:#555;font-size:12px;'><i>Создано через TrueConf КЛЕВЕР</i></p>"
|
||||||
|
|
||||||
async def _send_req(email, current_city):
|
async def _send_req(email, current_city):
|
||||||
payload = {"request": {"subject": subject, "description": html_desc, "requester": {"email_id": email}, "udf_fields": {"udf_pick_301": current_city}}}
|
payload = {"request": {"subject": subject, "description": html_desc, "requester": {"email_id": email}, "udf_fields": {"udf_pick_301": current_city}}}
|
||||||
async with httpx.AsyncClient(verify=False) as client:
|
async with httpx.AsyncClient(verify=False) as client:
|
||||||
return await client.post(endpoint, headers=headers, data={"input_data": json.dumps(payload)}, timeout=15.0)
|
return await client.post(endpoint, headers=headers, data={"input_data": json.dumps(payload)}, timeout=15.0)
|
||||||
resp = await _send_req(requester_email, city)
|
|
||||||
data = resp.json() if resp.status_code in [200, 201] else {}
|
# Безопасная обработка сбоев сети / отсуствия доступа к серверу SD
|
||||||
if data.get("response_status", {}).get("status_code") != 2000:
|
try:
|
||||||
resp = await _send_req(requester_email, "Кемерово")
|
resp = await _send_req(requester_email, city)
|
||||||
data = resp.json() if resp.status_code in [200, 201] else {}
|
data = resp.json() if resp.status_code in [200, 201] else {}
|
||||||
if data.get("response_status", {}).get("status_code") == 2000:
|
if data.get("response_status", {}).get("status_code") != 2000:
|
||||||
return data.get("request", {}).get("id")
|
resp = await _send_req(requester_email, "Кемерово")
|
||||||
|
data = resp.json() if resp.status_code in [200, 201] else {}
|
||||||
|
if data.get("response_status", {}).get("status_code") == 2000:
|
||||||
|
return data.get("request", {}).get("id")
|
||||||
|
except (httpx.RequestError, httpx.HTTPStatusError, Exception) as e:
|
||||||
|
logger.error(f"❌ [SD Network Error] Не удалось подключиться к ServiceDesk: {e}")
|
||||||
|
sd_workflow_logger.error(f"❌ [SD Network Error] {e}")
|
||||||
|
_sd_log_and_notify_email("Подключение к ServiceDesk", str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def process_and_upload_file(file_id: str, filename: str, ticket_id: str):
|
async def process_and_upload_file(file_id: str, filename: str, ticket_id: str):
|
||||||
@@ -219,7 +229,8 @@ def _extract_attachments_from_content(content, is_attachment_type=False):
|
|||||||
async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login):
|
async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login):
|
||||||
"""Логика генерации темы, отправки в SD и загрузки всех очередей вложений."""
|
"""Логика генерации темы, отправки в SD и загрузки всех очередей вложений."""
|
||||||
session["step"] = "creating_ticket"
|
session["step"] = "creating_ticket"
|
||||||
try:
|
|
||||||
|
try:
|
||||||
ad_user = await asyncio.to_thread(get_ad_user_sync, login)
|
ad_user = await asyncio.to_thread(get_ad_user_sync, login)
|
||||||
sd_workflow_logger.info(f"👤 [AD Lookup] User: {login} -> Found: {ad_user is not None}")
|
sd_workflow_logger.info(f"👤 [AD Lookup] User: {login} -> Found: {ad_user is not None}")
|
||||||
sender_email = ad_user.get("mail") if ad_user else DEFAULT_REQUESTER
|
sender_email = ad_user.get("mail") if ad_user else DEFAULT_REQUESTER
|
||||||
@@ -233,7 +244,7 @@ async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login
|
|||||||
if ticket_id:
|
if ticket_id:
|
||||||
session["ticket_id"] = ticket_id
|
session["ticket_id"] = ticket_id
|
||||||
|
|
||||||
# 1. Загрузка основных вложений (накопленных до и во время таймаута)
|
# 1. Загрузка основных вложений
|
||||||
queued_files = list(session.get("files_queue", []))
|
queued_files = list(session.get("files_queue", []))
|
||||||
if queued_files:
|
if queued_files:
|
||||||
sd_workflow_logger.info(f"📎 [Upload] Uploading {len(queued_files)} file(s) to ticket #{ticket_id}")
|
sd_workflow_logger.info(f"📎 [Upload] Uploading {len(queued_files)} file(s) to ticket #{ticket_id}")
|
||||||
@@ -241,7 +252,7 @@ async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login
|
|||||||
await process_and_upload_file(f['file_id'], f['file_name'], ticket_id)
|
await process_and_upload_file(f['file_id'], f['file_name'], ticket_id)
|
||||||
sd_workflow_logger.info(f"✅ [Upload] All queued file(s) uploaded")
|
sd_workflow_logger.info(f"✅ [Upload] All queued file(s) uploaded")
|
||||||
|
|
||||||
# 2. Загрузка вложений, прилетевших во время выполнения API-запросов (из "черной дыры")
|
# 2. Загрузка вложений, прилетевших во время выполнения API-запросов
|
||||||
post_files = list(session.get("post_create_queue", []))
|
post_files = list(session.get("post_create_queue", []))
|
||||||
if post_files:
|
if post_files:
|
||||||
sd_workflow_logger.info(f"📎 [Upload Extra] Uploading {len(post_files)} late file(s) to ticket #{ticket_id}")
|
sd_workflow_logger.info(f"📎 [Upload Extra] Uploading {len(post_files)} late file(s) to ticket #{ticket_id}")
|
||||||
@@ -259,137 +270,167 @@ async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login
|
|||||||
log_menu_stats(user_id, "Service Desk", f"Создание заявки #{ticket_id}")
|
log_menu_stats(user_id, "Service Desk", f"Создание заявки #{ticket_id}")
|
||||||
else:
|
else:
|
||||||
session["step"] = "need_text"
|
session["step"] = "need_text"
|
||||||
await msg.answer(sd_ticket_create_error(), parse_mode="html")
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Ошибка: {e}")
|
logger.exception(f"❌ [SD Ticket Error] {user_id}: {e}")
|
||||||
sd_workflow_logger.error(f"❌ [SD Error] {e}")
|
sd_workflow_logger.error(f"❌ [SD Ticket Error] {user_id}: {e}")
|
||||||
session["step"] = "need_text"
|
_sd_log_and_notify_email(f"Создание заявки для {user_id}", str(e))
|
||||||
await msg.answer(sd_system_error(), parse_mode="html")
|
session["step"] = "need_text"
|
||||||
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
|
|
||||||
async def _wait_for_attachments_and_create(user_id, session):
|
async def _wait_for_attachments_and_create(user_id, session):
|
||||||
"""Фоновый таймер с возможностью отмены (Debounce)."""
|
"""Фоновый таймер с возможностью отмены (Debounce)."""
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(ATTACHMENT_WAIT_TIMEOUT)
|
await asyncio.sleep(ATTACHMENT_WAIT_TIMEOUT)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return # Таймер сброшен, прерываем выполнение текущей таски
|
return
|
||||||
|
|
||||||
if session.get("step") != "waiting_for_attachments":
|
if session.get("step") != "waiting_for_attachments":
|
||||||
return
|
return
|
||||||
|
|
||||||
sd_workflow_logger.info(f"⏰ [SD] Timeout reached. Initiating ticket creation for user {user_id}")
|
sd_workflow_logger.info(f"⏰ [SD] Timeout reached. Initiating ticket creation for user {user_id}")
|
||||||
msg = session.get("msg")
|
msg = session.get("msg")
|
||||||
login = session.get("login")
|
login = session.get("login")
|
||||||
if msg:
|
if msg:
|
||||||
await _create_ticket_and_attach_files(user_id, session["msg_text"], session, msg, login)
|
try:
|
||||||
|
await _create_ticket_and_attach_files(user_id, session["msg_text"], session, msg, login)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"❌ [SD Ticket Failed] user={user_id}: {e}")
|
||||||
|
sd_workflow_logger.error(f"❌ [SD Ticket] {e}")
|
||||||
|
_sd_log_and_notify_email(f"Создание заявки для {user_id}", str(e))
|
||||||
|
session["step"] = "need_text"
|
||||||
|
try:
|
||||||
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
|
except Exception:
|
||||||
|
sd_workflow_logger.error(f"❌ [SD] Failed to send error message to {user_id}")
|
||||||
|
|
||||||
|
def _on_sd_timer_done(task: asyncio.Task, user_id: str):
|
||||||
|
"""Безопасный коллбэк завершения таймера создания заявки"""
|
||||||
|
try:
|
||||||
|
task.result()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка в таске таймера SD для {user_id}: {e}")
|
||||||
|
sd_workflow_logger.error(f"❌ [SD Timer Error] {user_id}: {e}")
|
||||||
|
_sd_log_and_notify_email(f"Таймер создания заявки для {user_id}", str(e))
|
||||||
|
|
||||||
@router.message()
|
@router.message()
|
||||||
async def sd_module_handler(msg: Message):
|
async def sd_module_handler(msg: Message):
|
||||||
user_id = msg.from_user.id
|
try:
|
||||||
sd_workflow_logger.info(f"🚀 [SD Workflow Start] User: {user_id}")
|
user_id = msg.from_user.id
|
||||||
if states.get_state(user_id) != "SD_MODE":
|
sd_workflow_logger.info(f"🚀 [SD Workflow Start] User: {user_id}")
|
||||||
return
|
if states.get_state(user_id) != "SD_MODE":
|
||||||
|
return
|
||||||
msg_text = _extract_text_from_content(msg.content)
|
|
||||||
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
|
msg_text = _extract_text_from_content(msg.content)
|
||||||
inline_attachments = _extract_attachments_from_content(msg.content, is_attachment)
|
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
|
||||||
|
inline_attachments = _extract_attachments_from_content(msg.content, is_attachment)
|
||||||
sd_workflow_logger.info(f"🚀 [SD Debug] msg.type={msg.type}, content_type={type(msg.content).__name__}, msg_text='{msg_text}', is_attachment={is_attachment}, inline_attachments={len(inline_attachments)}")
|
|
||||||
|
sd_workflow_logger.info(f"🚀 [SD Debug] msg.type={msg.type}, content_type={type(msg.content).__name__}, msg_text='{msg_text}', is_attachment={is_attachment}, inline_attachments={len(inline_attachments)}")
|
||||||
msg.handled = True
|
|
||||||
login = user_id.split("@")[0] if "@" in user_id else user_id
|
msg.handled = True
|
||||||
cmd = msg_text.lower()
|
login = user_id.split("@")[0] if "@" in user_id else user_id
|
||||||
|
cmd = msg_text.lower()
|
||||||
# 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ
|
|
||||||
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
# 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ
|
||||||
if cmd == emoji_num:
|
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
||||||
cmd = raw_num
|
if cmd == emoji_num:
|
||||||
break
|
cmd = raw_num
|
||||||
|
break
|
||||||
# --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню) ---
|
|
||||||
if cmd in ["0", "9", "/start", "меню"]:
|
# --- ВЫХОД ---
|
||||||
log_menu_stats(user_id, "Service Desk", "Выход в главное меню")
|
if cmd in ["0", "9", "/start", "меню"]:
|
||||||
states.clear_state(user_id)
|
log_menu_stats(user_id, "Service Desk", "Выход в главное меню")
|
||||||
sd_sessions.pop(user_id, None)
|
states.clear_state(user_id)
|
||||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
sd_sessions.pop(user_id, None)
|
||||||
return
|
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||||
|
return
|
||||||
if user_id not in sd_sessions:
|
|
||||||
sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": []}
|
if user_id not in sd_sessions:
|
||||||
session = sd_sessions[user_id]
|
sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": []}
|
||||||
|
session = sd_sessions[user_id]
|
||||||
# --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА ---
|
|
||||||
if session["step"] == "need_text":
|
# --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА ---
|
||||||
if not msg_text.strip() and not inline_attachments:
|
if session["step"] == "need_text":
|
||||||
await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html")
|
if not msg_text.strip() and not inline_attachments:
|
||||||
return
|
await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html")
|
||||||
|
return
|
||||||
# Если только вложение без текста — сохраняем и ждем текст
|
|
||||||
if not msg_text.strip() and inline_attachments:
|
if not msg_text.strip() and inline_attachments:
|
||||||
session["files_queue"].extend(inline_attachments)
|
session["files_queue"].extend(inline_attachments)
|
||||||
sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text")
|
sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text")
|
||||||
await msg.answer(SD_TEXT_REQUIRED, parse_mode="html")
|
await msg.answer(SD_TEXT_REQUIRED, parse_mode="html")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Если пришел текст (с вложениями или без)
|
if inline_attachments:
|
||||||
if inline_attachments:
|
session["files_queue"].extend(inline_attachments)
|
||||||
session["files_queue"].extend(inline_attachments)
|
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
|
||||||
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
|
|
||||||
|
session["step"] = "waiting_for_attachments"
|
||||||
# Настраиваем параметры сессии для ожидания
|
session["msg_text"] = msg_text
|
||||||
session["step"] = "waiting_for_attachments"
|
session["msg"] = msg
|
||||||
session["msg_text"] = msg_text
|
session["user_id"] = user_id
|
||||||
session["msg"] = msg
|
session["login"] = login
|
||||||
session["user_id"] = user_id
|
|
||||||
session["login"] = login
|
await msg.answer(SD_CREATING, parse_mode="html")
|
||||||
|
|
||||||
# Сразу выдаем ОДНО сообщение пользователю, чтобы он видел реакцию бота
|
if "timer_task" in session and not session["timer_task"].done():
|
||||||
await msg.answer(SD_CREATING, parse_mode="html")
|
session["timer_task"].cancel()
|
||||||
|
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
||||||
# Запускаем фоновый таймер с механизмом сброса (Debounce)
|
session["timer_task"].add_done_callback(lambda t: _on_sd_timer_done(t, user_id))
|
||||||
if "timer_task" in session and not session["timer_task"].done():
|
sd_workflow_logger.info(f"⏳ [SD] Timer started: waiting {ATTACHMENT_WAIT_TIMEOUT}s for potentially more attachments...")
|
||||||
session["timer_task"].cancel()
|
return
|
||||||
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
|
||||||
sd_workflow_logger.info(f"⏳ [SD] Timer started: waiting {ATTACHMENT_WAIT_TIMEOUT}s for potentially more attachments...")
|
# --- ШАГ 2: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ ---
|
||||||
return
|
if session["step"] == "waiting_for_attachments":
|
||||||
|
if inline_attachments:
|
||||||
# --- ШАГ 2: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ (Сброс таймаута) ---
|
session["files_queue"].extend(inline_attachments)
|
||||||
if session["step"] == "waiting_for_attachments":
|
sd_workflow_logger.info(f"📎 [SD] Received {len(inline_attachments)} more files. Total queue: {len(session['files_queue'])}")
|
||||||
if inline_attachments:
|
if msg_text.strip():
|
||||||
session["files_queue"].extend(inline_attachments)
|
session["msg_text"] = session["msg_text"] + " " + msg_text.strip()
|
||||||
sd_workflow_logger.info(f"📎 [SD] Received {len(inline_attachments)} more files. Total queue: {len(session['files_queue'])}")
|
sd_workflow_logger.info(f"📝 [SD] Additional text appended")
|
||||||
if msg_text.strip():
|
|
||||||
session["msg_text"] = session["msg_text"] + " " + msg_text.strip()
|
if "timer_task" in session and not session["timer_task"].done():
|
||||||
sd_workflow_logger.info(f"📝 [SD] Additional text appended")
|
session["timer_task"].cancel()
|
||||||
|
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
||||||
# Перезапускаем таймер (пользователь активен, сдвигаем окно создания вперед)
|
session["timer_task"].add_done_callback(lambda t: _on_sd_timer_done(t, user_id))
|
||||||
if "timer_task" in session and not session["timer_task"].done():
|
sd_workflow_logger.info(f"🔄 [SD] Timer reset due to user activity. Waiting another {ATTACHMENT_WAIT_TIMEOUT}s")
|
||||||
session["timer_task"].cancel()
|
return
|
||||||
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
|
||||||
sd_workflow_logger.info(f"🔄 [SD] Timer reset due to user activity. Waiting another {ATTACHMENT_WAIT_TIMEOUT}s")
|
# --- ШАГ 3: ЗАЯВКА СОЗДАЕТСЯ ---
|
||||||
return
|
if session["step"] == "creating_ticket":
|
||||||
|
if inline_attachments:
|
||||||
# --- ШАГ 3: ЗАЯВКА СОЗДАЕТСЯ (Перехват файлов из "черной дыры") ---
|
if "post_create_queue" not in session:
|
||||||
if session["step"] == "creating_ticket":
|
session["post_create_queue"] = []
|
||||||
if inline_attachments:
|
session["post_create_queue"].extend(inline_attachments)
|
||||||
if "post_create_queue" not in session:
|
sd_workflow_logger.info(f"📥 [SD Hole-Fix] Captured {len(inline_attachments)} file(s) DURING ticket creation API call.")
|
||||||
session["post_create_queue"] = []
|
return
|
||||||
session["post_create_queue"].extend(inline_attachments)
|
|
||||||
sd_workflow_logger.info(f"📥 [SD Hole-Fix] Captured {len(inline_attachments)} file(s) DURING ticket creation API call.")
|
# --- ШАГ 4: ПРИЕМ ДОП. ФАЙЛОВ К УЖЕ СОЗДАННОЙ ЗАЯВКЕ ---
|
||||||
return
|
if session["step"] == "ticket_created":
|
||||||
|
if inline_attachments:
|
||||||
# --- ШАГ 4: ПРИЕМ ДОП. ФАЙЛОВ К УЖЕ СОЗДАННОЙ ЗАЯВКЕ ---
|
ticket_id = session["ticket_id"]
|
||||||
if session["step"] == "ticket_created":
|
for att in inline_attachments:
|
||||||
if inline_attachments:
|
await msg.answer(sd_uploading_file(ticket_id), parse_mode="html")
|
||||||
ticket_id = session["ticket_id"]
|
ok = await process_and_upload_file(att['file_id'], att['file_name'], ticket_id)
|
||||||
for att in inline_attachments:
|
sd_workflow_logger.info(f"📎 [Late File Upload] Name: {att['file_name']} -> Success: {ok}")
|
||||||
await msg.answer(sd_uploading_file(ticket_id), parse_mode="html")
|
if ok:
|
||||||
ok = await process_and_upload_file(att['file_id'], att['file_name'], ticket_id)
|
log_menu_stats(user_id, "Service Desk", f"Добавление файла к заявке #{ticket_id}")
|
||||||
sd_workflow_logger.info(f"📎 [Late File Upload] Name: {att['file_name']} -> Success: {ok}")
|
await msg.answer(sd_file_attached(), parse_mode="html")
|
||||||
if ok:
|
else:
|
||||||
log_menu_stats(user_id, "Service Desk", f"Добавление файла к заявке #{ticket_id}")
|
_sd_log_and_notify_email(f"Загрузка файла к заявке #{ticket_id}", f"file={att.get('file_name', 'unknown')}")
|
||||||
await msg.answer(sd_file_attached(), parse_mode="html")
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
else:
|
elif msg_text.strip():
|
||||||
await msg.answer(sd_file_upload_error(), parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
elif msg_text.strip():
|
return
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
except Exception as e:
|
||||||
return
|
user_id = getattr(msg.from_user, 'id', 'unknown')
|
||||||
|
logger.exception(f"❌ [SD Fatal Error] user={user_id}: {e}")
|
||||||
|
sd_workflow_logger.error(f"❌ [SD Fatal] {e}")
|
||||||
|
_sd_log_and_notify_email(f"Обработка сообщения от {user_id}", str(e))
|
||||||
|
try:
|
||||||
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Проверка подключения к SQL Server и загрузки данных табеля
|
||||||
|
за последние 5 месяцев для ds.krivochenko@sibcem.ru.
|
||||||
|
|
||||||
|
Цепочка поиска сотрудника (как в LK модуле бота):
|
||||||
|
1. AD: search_by_user_id(email) → extensionAttribute2 = CARD_ID
|
||||||
|
2. SQL: SELECT ID FROM UOV_SELFSERVICE_PR_EMP WHERE CARD_ID = %s → EMP_ID
|
||||||
|
3. SQL: SELECT ... FROM UOV_SELFSERVICE_TB_TABEL WHERE EMP_ID = %s → табель
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
cd /opt/trueconf_bot
|
||||||
|
python3 test_tabel_check.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import calendar
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. Загрузка .env
|
||||||
|
# ============================================================
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ENV_PATH = os.path.join(BASE_DIR, "config", ".env")
|
||||||
|
|
||||||
|
def load_env(filepath):
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#"):
|
||||||
|
if "=" in line:
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
os.environ[key.strip()] = value.strip(' "\'\r\n')
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"⚠️ Файл {filepath} не найден.")
|
||||||
|
|
||||||
|
load_env(ENV_PATH)
|
||||||
|
|
||||||
|
# Карта соответствия: Имя переменной в боте -> Название карточки в сейфе
|
||||||
|
CREDENTIALS_MAP = {
|
||||||
|
"DB_PASSWORD": os.getenv("PW_ID_SQL", "").strip(' "\'\r\n'),
|
||||||
|
"AD_PASSWORD": os.getenv("PW_ID_AD", "").strip(' "\'\r\n'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 2. Загрузка паролей из Passwork
|
||||||
|
# ============================================================
|
||||||
|
DB_USER = ""
|
||||||
|
DB_PASSWORD = ""
|
||||||
|
AD_USER = ""
|
||||||
|
AD_PASSWORD = ""
|
||||||
|
|
||||||
|
for pw_path in ["/opt/passwork", os.path.join(BASE_DIR, "passwork"), os.path.join(BASE_DIR, "config", "passwork")]:
|
||||||
|
if os.path.isdir(pw_path) and pw_path not in sys.path:
|
||||||
|
sys.path.insert(0, pw_path)
|
||||||
|
if BASE_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, BASE_DIR)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from passwork import get_passwork_secrets
|
||||||
|
print("🔐 Загрузка секретов из Passwork...")
|
||||||
|
required_cards = [name for name in CREDENTIALS_MAP.values() if name]
|
||||||
|
passwork_pool = get_passwork_secrets(required_cards=required_cards)
|
||||||
|
for var_name, card_name in CREDENTIALS_MAP.items():
|
||||||
|
if not card_name:
|
||||||
|
continue
|
||||||
|
clean_card = card_name.strip(' "\'\r\n')
|
||||||
|
card_data = passwork_pool.get(clean_card)
|
||||||
|
if card_data is None:
|
||||||
|
print(f" ⚠️ Карточка '{clean_card}' не найдена")
|
||||||
|
continue
|
||||||
|
globals()[var_name] = card_data["password"].strip(' "\'\r\n')
|
||||||
|
if var_name == "AD_PASSWORD":
|
||||||
|
globals()["AD_USER"] = card_data.get("login", "").strip(' "\'\r\n')
|
||||||
|
elif var_name == "DB_PASSWORD":
|
||||||
|
globals()["DB_USER"] = card_data.get("login", "").strip(' "\'\r\n')
|
||||||
|
print(f" ✓ {var_name} из карточки '{clean_card}'")
|
||||||
|
except ImportError:
|
||||||
|
print("⚠️ Модуль passwork не найден")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Ошибка Passwork: {e}")
|
||||||
|
|
||||||
|
if not DB_PASSWORD:
|
||||||
|
DB_PASSWORD = os.getenv("DB_PASSWORD", "").strip()
|
||||||
|
DB_USER = os.getenv("DB_USER", DB_USER).strip()
|
||||||
|
if not AD_PASSWORD:
|
||||||
|
AD_PASSWORD = os.getenv("AD_PASSWORD", "").strip()
|
||||||
|
AD_USER = os.getenv("AD_USER", AD_USER).strip()
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 3. Настройки подключения
|
||||||
|
# ============================================================
|
||||||
|
DB_SERVER = os.getenv("SQL_SERVER", "SRVKEM-MOBILEIN.sibcem.ru")
|
||||||
|
DB_NAME = os.getenv("SQL_DB_NAME", "BossCopy")
|
||||||
|
AD_SERVER = os.getenv("AD_SERVER", "ldap://172.16.20.20")
|
||||||
|
AD_BASES = (
|
||||||
|
"OU=-Пользователи,DC=sibcem,DC=ru",
|
||||||
|
"OU=Планшеты,OU=enabled,OU=БезКомпьютеров,DC=sibcem,DC=ru",
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print(" Проверка подключения к SQL Server")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f" Сервер : {DB_SERVER}")
|
||||||
|
print(f" База : {DB_NAME}")
|
||||||
|
print(f" Логин : {DB_USER}")
|
||||||
|
print(f" Пароль : {'*' * len(DB_PASSWORD) if DB_PASSWORD else '(не задан)'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 4. Проверка pymssql
|
||||||
|
# ============================================================
|
||||||
|
try:
|
||||||
|
import pymssql
|
||||||
|
print(f"✅ pymssql загружен (версия {pymssql.__version__})")
|
||||||
|
except ImportError:
|
||||||
|
print("❌ pymssql не установлен!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 5. Подключение к SQL Server
|
||||||
|
# ============================================================
|
||||||
|
try:
|
||||||
|
conn = pymssql.connect(
|
||||||
|
server=DB_SERVER,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASSWORD,
|
||||||
|
database=DB_NAME,
|
||||||
|
charset='cp1251',
|
||||||
|
login_timeout=10
|
||||||
|
)
|
||||||
|
print("✅ Подключение к SQL Server успешно!")
|
||||||
|
cursor = conn.cursor()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка подключения: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 6. Поиск сотрудника в AD по email → CARD_ID (как в LK модуле)
|
||||||
|
# ============================================================
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print(" Поиск сотрудника в Active Directory")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
TARGET_EMAIL = "ds.krivochenko@sibcem.ru"
|
||||||
|
print(f" Ищем: {TARGET_EMAIL}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ldap3 import Server, Connection, ALL
|
||||||
|
|
||||||
|
server = Server(AD_SERVER, get_info=ALL)
|
||||||
|
ldap_conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
|
||||||
|
|
||||||
|
# Как в ad_search.py: ищем по mail, userPrincipalName, sAMAccountName
|
||||||
|
short_username = TARGET_EMAIL.split("@")[0] if "@" in TARGET_EMAIL else TARGET_EMAIL
|
||||||
|
|
||||||
|
or_parts = [
|
||||||
|
"(mail={})".format(TARGET_EMAIL),
|
||||||
|
"(userPrincipalName={})".format(TARGET_EMAIL),
|
||||||
|
"(userPrincipalName={})".format(short_username + "@sibcem.ru"),
|
||||||
|
"(sAMAccountName={})".format(short_username),
|
||||||
|
]
|
||||||
|
or_filter = "(|{})".format("".join(or_parts))
|
||||||
|
search_filter = "(&{}{})".format("(objectClass=user)", or_filter)
|
||||||
|
|
||||||
|
print(f" LDAP-фильтр: {search_filter}")
|
||||||
|
|
||||||
|
card_id_from_ad = None
|
||||||
|
|
||||||
|
for base in AD_BASES:
|
||||||
|
ldap_conn.search(
|
||||||
|
search_base=base,
|
||||||
|
search_filter=search_filter,
|
||||||
|
attributes=["cn", "sAMAccountName", "mail", "userPrincipalName", "extensionAttribute2"],
|
||||||
|
)
|
||||||
|
if ldap_conn.entries:
|
||||||
|
entry = ldap_conn.entries[0]
|
||||||
|
print(f" ✅ Найден в OU: {base}")
|
||||||
|
print(f" CN : {entry.cn}")
|
||||||
|
print(f" sAMAccountName : {entry.sAMAccountName}")
|
||||||
|
print(f" mail : {entry.mail}")
|
||||||
|
ext2 = str(entry.extensionAttribute2) if hasattr(entry, 'extensionAttribute2') and entry.extensionAttribute2 else "(не задан)"
|
||||||
|
print(f" extensionAttribute2: {ext2}")
|
||||||
|
card_id_from_ad = ext2
|
||||||
|
break
|
||||||
|
|
||||||
|
if not ldap_conn.entries:
|
||||||
|
print(f" ❌ Сотрудник не найден в AD по email {TARGET_EMAIL}")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print("❌ ldap3 не установлен")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка LDAP: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 7. Поиск EMP_ID по CARD_ID в SQL (как в LK модуле)
|
||||||
|
# ============================================================
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print(" Поиск EMP_ID по CARD_ID в SQL")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
emp_id = None
|
||||||
|
card_id = None
|
||||||
|
|
||||||
|
if card_id_from_ad:
|
||||||
|
card_id = card_id_from_ad
|
||||||
|
print(f" CARD_ID из AD: {card_id}")
|
||||||
|
|
||||||
|
# Как в info_service.py: SELECT e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s
|
||||||
|
cursor.execute("SELECT e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s", (card_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
emp_id = row[0]
|
||||||
|
print(f" ✅ Найден: emp_id={emp_id}")
|
||||||
|
else:
|
||||||
|
print(f" ❌ Сотрудник с CARD_ID={card_id} не найден в UOV_SELFSERVICE_PR_EMP")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 8. Загрузка табеля за последние 5 месяцев
|
||||||
|
# ============================================================
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print(" Загрузка табеля за последние 5 месяцев")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if not emp_id:
|
||||||
|
print(" ❌ Не удалось найти сотрудника — завершение")
|
||||||
|
conn.close()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
months_back = 5
|
||||||
|
|
||||||
|
for i in range(months_back):
|
||||||
|
month = now.month - i
|
||||||
|
year = now.year
|
||||||
|
while month <= 0:
|
||||||
|
month += 12
|
||||||
|
year -= 1
|
||||||
|
|
||||||
|
month_name = calendar.month_name[month]
|
||||||
|
print(f"\n📅 {month_name} {year}")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT e.D, e.TDAY_ID, e.H
|
||||||
|
FROM UOV_SELFSERVICE_TB_TABEL e
|
||||||
|
WHERE e.EMP_ID = %s AND YEAR(e.D) = %s AND MONTH(e.D) = %s
|
||||||
|
ORDER BY e.D ASC
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cursor.execute(query, (emp_id, year, month))
|
||||||
|
records = cursor.fetchall()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Ошибка запроса: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
print(f" ⚠️ Данные табеля отсутствуют")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" Записей: {len(records)}")
|
||||||
|
|
||||||
|
total_days = 0
|
||||||
|
total_hours = 0.0
|
||||||
|
codes = {}
|
||||||
|
|
||||||
|
for r in records:
|
||||||
|
tday_id = str(r[1]).strip() if r[1] else "?"
|
||||||
|
hours = float(r[2]) if r[2] is not None else 0.0
|
||||||
|
codes[tday_id] = codes.get(tday_id, 0) + 1
|
||||||
|
if tday_id == 'Я':
|
||||||
|
total_days += 1
|
||||||
|
total_hours += hours
|
||||||
|
|
||||||
|
print(f" Явки (Я): {codes.get('Я', 0)} дн., часов: {total_hours:.1f}")
|
||||||
|
print(f" Коды: {', '.join(f'{k}={v}' for k, v in codes.items())}")
|
||||||
|
|
||||||
|
print(f" Первые 5 записей:")
|
||||||
|
for r in records[:5]:
|
||||||
|
d = r[0].strftime('%d.%m.%Y') if hasattr(r[0], 'strftime') else str(r[0])
|
||||||
|
print(f" {d} | {str(r[1]).strip():>3} | {float(r[2]) if r[2] else 0:.1f} ч.")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Закрытие
|
||||||
|
# ============================================================
|
||||||
|
conn.close()
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print(" ✅ Проверка завершена")
|
||||||
|
print("=" * 60)
|
||||||
@@ -7,7 +7,9 @@ import httpx
|
|||||||
from ldap3 import Server, Connection, ALL
|
from ldap3 import Server, Connection, ALL
|
||||||
from trueconf import Router, Message
|
from trueconf import Router, Message
|
||||||
|
|
||||||
|
import config.config as config
|
||||||
from config.config import *
|
from config.config import *
|
||||||
|
from config.config import DEFAULT_EMAIL_FROM, SMTP_SERVER, SMTP_PORT, ALERTS_SUPPORT_EMAILS
|
||||||
from utils import states
|
from utils import states
|
||||||
from utils.menu import MENU_TEXT
|
from utils.menu import MENU_TEXT
|
||||||
from utils.texts import (
|
from utils.texts import (
|
||||||
@@ -16,14 +18,64 @@ from utils.texts import (
|
|||||||
transcription_error_bad_extension,
|
transcription_error_bad_extension,
|
||||||
TRANSCRIPTION_UPLOADING_TEXT,
|
TRANSCRIPTION_UPLOADING_TEXT,
|
||||||
transcription_success_received,
|
transcription_success_received,
|
||||||
transcription_error_api,
|
system_error_text,
|
||||||
transcription_error_system,
|
|
||||||
UNKNOWN_MAIN_CMD_TEXT,
|
UNKNOWN_MAIN_CMD_TEXT,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
||||||
from utils.stats_logger import log_menu_stats
|
from utils.stats_logger import log_menu_stats
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# СИСТЕМА ОТПРАВКИ ОШИБОК НА ПОЧТУ
|
||||||
|
# =========================================================
|
||||||
|
global _last_transcription_email_time
|
||||||
|
_last_transcription_email_time = 0.0
|
||||||
|
|
||||||
|
def _transcription_log_and_notify_email(action: str, error_details: str):
|
||||||
|
global _last_transcription_email_time
|
||||||
|
current_time = time.time()
|
||||||
|
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
logger.info(f"📧 [EMAIL ALERT] Attempting to send alert for: {action}")
|
||||||
|
|
||||||
|
if current_time - _last_transcription_email_time < 10.0:
|
||||||
|
logger.info(f"📧 [EMAIL ALERT] Cooldown active, skipping. Last: {_last_transcription_email_time}")
|
||||||
|
return
|
||||||
|
_last_transcription_email_time = current_time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
|
||||||
|
default_from = getattr(config, 'DEFAULT_EMAIL_FROM', 'bot@noreply.com')
|
||||||
|
smtp_server = getattr(config, 'SMTP_SERVER', 'localhost')
|
||||||
|
smtp_port = getattr(config, 'SMTP_PORT', 25)
|
||||||
|
to_emails = getattr(config, 'ALERTS_SUPPORT_EMAILS', ['admin@example.com'])
|
||||||
|
|
||||||
|
logger.info(f"📧 [EMAIL ALERT] From={default_from}, To={to_emails}, SMTP={smtp_server}:{smtp_port}")
|
||||||
|
|
||||||
|
msg['From'] = str(default_from)
|
||||||
|
msg['To'] = ', '.join(to_emails) if isinstance(to_emails, list) else str(to_emails)
|
||||||
|
msg['Subject'] = f'TrueConf Bot Error: Транскрипция - {action}'
|
||||||
|
body = f'Обнаружена ошибка в транскрипции.\nВремя: {timestamp}\nДействие: {action}\nДетали:\n{error_details}'
|
||||||
|
msg.attach(MIMEText(body, 'plain', 'utf-8'))
|
||||||
|
|
||||||
|
logger.info(f"📧 [EMAIL ALERT] Connecting to SMTP...")
|
||||||
|
with smtplib.SMTP(smtp_server, int(smtp_port)) as server:
|
||||||
|
logger.info(f"📧 [EMAIL ALERT] Sending message...")
|
||||||
|
server.send_message(msg)
|
||||||
|
logger.info(f"📧 [EMAIL ALERT] SUCCESS!")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"📧 [EMAIL ALERT] FAILED: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(f"📧 [EMAIL ALERT] Traceback: {traceback.format_exc()}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
@@ -51,6 +103,7 @@ def get_user_email_sync(login: str) -> str:
|
|||||||
return str(entries[0].mail.value)
|
return str(entries[0].mail.value)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка поиска email в AD: {e}")
|
logger.error(f"Ошибка поиска email в AD: {e}")
|
||||||
|
_transcription_log_and_notify_email("Поиск email в AD", str(e))
|
||||||
return DEFAULT_REQUESTER
|
return DEFAULT_REQUESTER
|
||||||
|
|
||||||
@router.message()
|
@router.message()
|
||||||
@@ -115,12 +168,22 @@ async def transcription_handler(msg: Message):
|
|||||||
log_menu_stats(user_id, "Speech-to-Text", f"Отправка встречи на расшифровку ({ext})")
|
log_menu_stats(user_id, "Speech-to-Text", f"Отправка встречи на расшифровку ({ext})")
|
||||||
await msg.answer(transcription_success_received(user_email), parse_mode="html")
|
await msg.answer(transcription_success_received(user_email), parse_mode="html")
|
||||||
else:
|
else:
|
||||||
await msg.answer(transcription_error_api(response.status_code), parse_mode="html")
|
_transcription_log_and_notify_email(f"API транскрипции вернул статус {response.status_code}", str(response.text[:500]))
|
||||||
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Transcription Upload Error: {e}")
|
logger.error(f"Transcription Upload Error: {e}")
|
||||||
await msg.answer(transcription_error_system(), parse_mode="html")
|
_transcription_log_and_notify_email("Загрузка/отправка файла на транскрипцию", str(e))
|
||||||
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- ЗАГЛУШКА НА НЕИЗВЕСТНЫЙ ТЕКСТ / СТАНДАРТНАЯ ОШИБКА ВВОДА ---
|
# --- ЗАГЛУШКА НА НЕИЗВЕСТНЫЙ ТЕКСТ / СТАНДАРТНАЯ ОШИБКА ВВОДА ---
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+48
-47
@@ -1,47 +1,48 @@
|
|||||||
# /opt/trueconf_bot/utils/states.py
|
# /opt/trueconf_bot/utils/states.py
|
||||||
import time
|
import time
|
||||||
import config.config as config
|
import config.config as config
|
||||||
|
|
||||||
user_states = {}
|
user_states = {}
|
||||||
tc_bot = None # Общая переменная для экземпляра бота
|
tc_bot = None # Общая переменная для экземпляра бота
|
||||||
|
|
||||||
# --- Константы состояний ---
|
# --- Константы состояний ---
|
||||||
PHOTO_MODE = "PHOTO_MODE"
|
PHOTO_MODE = "PHOTO_MODE"
|
||||||
SD_MODE = "SD_MODE"
|
SD_MODE = "SD_MODE"
|
||||||
TRANSCRIPTION_MODE = "TRANSCRIPTION_MODE"
|
TRANSCRIPTION_MODE = "TRANSCRIPTION_MODE"
|
||||||
SEARCH_MODE = "SEARCH_MODE"
|
SEARCH_MODE = "SEARCH_MODE"
|
||||||
|
INSTRUCT_EMAIL_HISTORY = "INSTRUCT_EMAIL_HISTORY"
|
||||||
def set_state(user_id, state):
|
|
||||||
"""Фиксирует новое состояние пользователя и обновляет метку времени активности"""
|
def set_state(user_id, state):
|
||||||
user_states[user_id] = {
|
"""Фиксирует новое состояние пользователя и обновляет метку времени активности"""
|
||||||
"state": state,
|
user_states[user_id] = {
|
||||||
"last_active": time.time()
|
"state": state,
|
||||||
}
|
"last_active": time.time()
|
||||||
|
}
|
||||||
def get_state(user_id):
|
|
||||||
"""
|
def get_state(user_id):
|
||||||
Возвращает текущее состояние пользователя.
|
"""
|
||||||
Если время неактивности превысило лимит из config.py, сессия сбрасывается.
|
Возвращает текущее состояние пользователя.
|
||||||
"""
|
Если время неактивности превысило лимит из config.py, сессия сбрасывается.
|
||||||
if user_id in user_states:
|
"""
|
||||||
session = user_states[user_id]
|
if user_id in user_states:
|
||||||
|
session = user_states[user_id]
|
||||||
# Динамически забираем значение таймаута из конфига (дефолт — 1800 сек / 30 мин)
|
|
||||||
session_timeout = getattr(config, 'SESSION_TIMEOUT', 1800)
|
# Динамически забираем значение таймаута из конфига (дефолт — 1800 сек / 30 мин)
|
||||||
|
session_timeout = getattr(config, 'SESSION_TIMEOUT', 1800)
|
||||||
# Проверяем, сколько секунд прошло с момента последнего сообщения
|
|
||||||
if time.time() - session["last_active"] > session_timeout:
|
# Проверяем, сколько секунд прошло с момента последнего сообщения
|
||||||
# Время вышло! Аппаратно сносим сессию навигации
|
if time.time() - session["last_active"] > session_timeout:
|
||||||
del user_states[user_id]
|
# Время вышло! Аппаратно сносим сессию навигации
|
||||||
return None
|
del user_states[user_id]
|
||||||
|
return None
|
||||||
# Если время не вышло, обновляем таймер (пользователь проявил активность)
|
|
||||||
session["last_active"] = time.time()
|
# Если время не вышло, обновляем таймер (пользователь проявил активность)
|
||||||
return session["state"]
|
session["last_active"] = time.time()
|
||||||
|
return session["state"]
|
||||||
return None
|
|
||||||
|
return None
|
||||||
def clear_state(user_id):
|
|
||||||
"""Принудительно очищает состояние пользователя (выход в корень)"""
|
def clear_state(user_id):
|
||||||
if user_id in user_states:
|
"""Принудительно очищает состояние пользователя (выход в корень)"""
|
||||||
del user_states[user_id]
|
if user_id in user_states:
|
||||||
|
del user_states[user_id]
|
||||||
|
|||||||
+102
-76
@@ -2,18 +2,16 @@
|
|||||||
# Все текстовые сообщения бота, отправляемые пользователю
|
# Все текстовые сообщения бота, отправляемые пользователю
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# GLOBAL CONSTANTS
|
# GLOBAL CONSTANTS & HELPERS
|
||||||
# =========================================================
|
# =========================================================
|
||||||
|
|
||||||
# --- Main Bot ---
|
# --- Main Bot ---
|
||||||
# Общее для главного бота
|
|
||||||
UNKNOWN_MAIN_CMD_TEXT = (
|
UNKNOWN_MAIN_CMD_TEXT = (
|
||||||
"⚠️ <b>Неизвестная команда.</b>\n\n"
|
"⚠️ <b>Неизвестная команда.</b>\n\n"
|
||||||
"Пожалуйста, используйте только цифры, соответствующие нужным пунктам меню, и отправьте выбранную цифру мне в сообщении."
|
"Пожалуйста, используйте только цифры, соответствующие нужным пунктам меню, и отправьте выбранную цифру мне в сообщении."
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Emoji Digits ---
|
# --- Emoji Digits ---
|
||||||
# Используются для навигации и выбора пунктов меню
|
|
||||||
EMOJI_DIGITS = {
|
EMOJI_DIGITS = {
|
||||||
"0": "0⃣",
|
"0": "0⃣",
|
||||||
"1": "1⃣",
|
"1": "1⃣",
|
||||||
@@ -29,7 +27,6 @@ EMOJI_DIGITS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Footer Templates ---
|
# --- Footer Templates ---
|
||||||
# Шаблоны подвалов сообщений для навигации
|
|
||||||
|
|
||||||
def _footer(emojis: dict) -> str:
|
def _footer(emojis: dict) -> str:
|
||||||
"""Стандартный футер с кнопками Назад и Меню"""
|
"""Стандартный футер с кнопками Назад и Меню"""
|
||||||
@@ -39,6 +36,17 @@ def _footer_main_only(emojis: dict) -> str:
|
|||||||
"""Футер только с кнопкой Меню"""
|
"""Футер только с кнопкой Меню"""
|
||||||
return f"\n\n<i>{emojis['0']} — В главное меню</i>"
|
return f"\n\n<i>{emojis['0']} — В главное меню</i>"
|
||||||
|
|
||||||
|
# --- Universal System Error Stub ---
|
||||||
|
|
||||||
|
def system_error_text(emojis: dict = EMOJI_DIGITS, show_back: bool = True) -> str:
|
||||||
|
"""Универсальная системная заглушка ошибки для всех модулей бота."""
|
||||||
|
footer = _footer(emojis) if show_back else _footer_main_only(emojis)
|
||||||
|
return (
|
||||||
|
"⚠️ <i>В данный момент выполнить это действие невозможно. "
|
||||||
|
"Информация о проблеме передана ответственным.\n"
|
||||||
|
f"Приносим извинения за доставленные неудобства.</i>{footer}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# TRANSCRIPTION BOT
|
# TRANSCRIPTION BOT
|
||||||
@@ -140,16 +148,14 @@ def _sd_ticket_created(ticket_id: int, subject: str, description: str, emojis: d
|
|||||||
clean_desc = clean_desc[:147] + "..."
|
clean_desc = clean_desc[:147] + "..."
|
||||||
return (
|
return (
|
||||||
f"✅ <b>Заявка #{ticket_id} создана!</b>\n\n"
|
f"✅ <b>Заявка #{ticket_id} создана!</b>\n\n"
|
||||||
|
|
||||||
f"📌 <b>Тема: {subject}</b>\n\n"
|
f"📌 <b>Тема: {subject}</b>\n\n"
|
||||||
f"📝 <b>Текст: {clean_desc}</b>\n\n"
|
f"📝 <b>Текст: {clean_desc}</b>\n\n"
|
||||||
|
|
||||||
f"Если нужно добавить скриншоты или документ — отправьте их сейчас.\n\n"
|
f"Если нужно добавить скриншоты или документ — отправьте их сейчас.\n\n"
|
||||||
f"{_footer(emojis)}"
|
f"{_footer(emojis)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def sd_ticket_created(ticket_id: int, subject: str, description: str) -> str:
|
def _sd_ticket_create_error(emojis: dict) -> str:
|
||||||
return _sd_ticket_created(ticket_id, subject, description, EMOJI_DIGITS)
|
return f"❌ <b>Ошибка создания заявки.</b>{_footer_main_only(emojis)}"
|
||||||
|
|
||||||
def _sd_system_error(emojis: dict) -> str:
|
def _sd_system_error(emojis: dict) -> str:
|
||||||
return f"❌ <b>Системная ошибка.</b> Попробуйте позже.{_footer_main_only(emojis)}"
|
return f"❌ <b>Системная ошибка.</b> Попробуйте позже.{_footer_main_only(emojis)}"
|
||||||
@@ -170,6 +176,9 @@ SD_UNKNOWN_CMD_TEXT = _sd_unknown(EMOJI_DIGITS)
|
|||||||
SD_TEXT_REQUIRED = _sd_text_required(EMOJI_DIGITS)
|
SD_TEXT_REQUIRED = _sd_text_required(EMOJI_DIGITS)
|
||||||
SD_CREATING = _sd_creating()
|
SD_CREATING = _sd_creating()
|
||||||
|
|
||||||
|
def sd_ticket_created(ticket_id: int, subject: str, description: str) -> str:
|
||||||
|
return _sd_ticket_created(ticket_id, subject, description, EMOJI_DIGITS)
|
||||||
|
|
||||||
def sd_ticket_create_error() -> str:
|
def sd_ticket_create_error() -> str:
|
||||||
return _sd_ticket_create_error(EMOJI_DIGITS)
|
return _sd_ticket_create_error(EMOJI_DIGITS)
|
||||||
|
|
||||||
@@ -208,14 +217,6 @@ def _photo_unknown(emojis: dict) -> str:
|
|||||||
f"{emojis['0']} — В главное меню"
|
f"{emojis['0']} — В главное меню"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _photo_error_handling(emojis: dict) -> str:
|
|
||||||
return (
|
|
||||||
f"⚠️ <i>В данный момент выполнить это действие невозможно. "
|
|
||||||
f"Информация о проблеме передана ответственным.\n"
|
|
||||||
f"Приносим извинения за доставленные неудобства.</i>\n\n"
|
|
||||||
f"{emojis['0']} — В главное меню"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _photo_uploading() -> str:
|
def _photo_uploading() -> str:
|
||||||
return "⏳ Отправка..."
|
return "⏳ Отправка..."
|
||||||
|
|
||||||
@@ -252,7 +253,7 @@ PHOTO_MAIN_MENU_TEXT = _photo_welcome(EMOJI_DIGITS)
|
|||||||
PHOTO_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS)
|
PHOTO_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS)
|
||||||
|
|
||||||
def photo_error_handling() -> str:
|
def photo_error_handling() -> str:
|
||||||
return _photo_error_handling(EMOJI_DIGITS)
|
return system_error_text(EMOJI_DIGITS, show_back=False)
|
||||||
|
|
||||||
PHOTO_UPLOADING_TEXT = _photo_uploading()
|
PHOTO_UPLOADING_TEXT = _photo_uploading()
|
||||||
|
|
||||||
@@ -318,14 +319,6 @@ def _search_feedback_info(emojis: dict) -> str:
|
|||||||
f"{emojis['0']} — Выйти в главное меню"
|
f"{emojis['0']} — Выйти в главное меню"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _search_unavailable(emojis: dict) -> str:
|
|
||||||
return (
|
|
||||||
"⚠️ <i>В данный момент выполнить это действие невозможно. "
|
|
||||||
"Информация о проблеме передана ответственным.\n"
|
|
||||||
"Приносим извинения за доставленные неудобства.</i>\n\n"
|
|
||||||
f"<i>{emojis['0']} — В главное меню</i>"
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Public constants and functions ---
|
# --- Public constants and functions ---
|
||||||
|
|
||||||
SEARCH_MAIN_MENU_TEXT = _search_welcome(EMOJI_DIGITS)
|
SEARCH_MAIN_MENU_TEXT = _search_welcome(EMOJI_DIGITS)
|
||||||
@@ -344,7 +337,7 @@ def search_feedback_info_text(emojis: dict) -> str:
|
|||||||
return _search_feedback_info(emojis)
|
return _search_feedback_info(emojis)
|
||||||
|
|
||||||
def search_unavailable_action(emojis: dict) -> str:
|
def search_unavailable_action(emojis: dict) -> str:
|
||||||
return _search_unavailable(emojis)
|
return system_error_text(emojis, show_back=False)
|
||||||
|
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -352,8 +345,6 @@ def search_unavailable_action(emojis: dict) -> str:
|
|||||||
# =========================================================
|
# =========================================================
|
||||||
# Модуль Личного Кабинета (финансовые документы, табель и др.)
|
# Модуль Личного Кабинета (финансовые документы, табель и др.)
|
||||||
|
|
||||||
# --- Constants ---
|
|
||||||
|
|
||||||
DOC_TYPES = {
|
DOC_TYPES = {
|
||||||
"1": "Справка о доходах физического лица (2-НДФЛ)",
|
"1": "Справка о доходах физического лица (2-НДФЛ)",
|
||||||
"2": "Справка об удержаниях за ДМС",
|
"2": "Справка об удержаниях за ДМС",
|
||||||
@@ -366,12 +357,6 @@ DOC_TYPES = {
|
|||||||
|
|
||||||
# --- Private functions (Internal logic) ---
|
# --- Private functions (Internal logic) ---
|
||||||
|
|
||||||
def _lk_error_handling(emojis: dict) -> str:
|
|
||||||
return (
|
|
||||||
"⚠️ <i>В данный момент выполнить это действие невозможно. Информация о проблеме передана ответственным. Приносим извинения за доставленные неудобства.</i>\n\n"
|
|
||||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _lk_auth_processing(emojis: dict) -> str:
|
def _lk_auth_processing(emojis: dict) -> str:
|
||||||
return "⏳ <i>Проверка безопасности... Запрашиваю контакты авторизации...</i>"
|
return "⏳ <i>Проверка безопасности... Запрашиваю контакты авторизации...</i>"
|
||||||
|
|
||||||
@@ -459,6 +444,15 @@ def _lk_docs_quantity_prompt(emojis: dict) -> str:
|
|||||||
def _lk_docs_year_prompt(emojis: dict) -> str:
|
def _lk_docs_year_prompt(emojis: dict) -> str:
|
||||||
return f"📅 <b>Выберите год, за который требуется справка:</b>\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
return f"📅 <b>Выберите год, за который требуется справка:</b>\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
|
|
||||||
|
def _lk_years_menu(current_year: int, emojis: dict) -> str:
|
||||||
|
lines = ["📅 <b>Выберите год запрашиваемого периода:</b>\n"]
|
||||||
|
for i in range(1, 9):
|
||||||
|
year = current_year - (i - 1)
|
||||||
|
emoji = emojis.get(str(i), f"{i}⃣")
|
||||||
|
lines.append(f"{emoji} — {year} год")
|
||||||
|
lines.append(f"\n<i>{emojis.get('9', '9⃣')} — Назад\n{emojis.get('0', '0⃣')} — В главное меню</i>")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _lk_docs_avg_salary_prompt(emojis: dict) -> str:
|
def _lk_docs_avg_salary_prompt(emojis: dict) -> str:
|
||||||
return f"Укажите количество месяцев для расчета средней ЗП (например: 3):\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
return f"Укажите количество месяцев для расчета средней ЗП (например: 3):\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
|
|
||||||
@@ -489,9 +483,8 @@ LK_UNKNOWN_CMD_TEXT = (
|
|||||||
f"<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
|
f"<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def lk_error_handling(emojis: dict) -> str:
|
def lk_error_handling(emojis: dict) -> str:
|
||||||
return _lk_error_handling(emojis)
|
return system_error_text(emojis, show_back=True)
|
||||||
|
|
||||||
def lk_auth_processing(emojis: dict) -> str:
|
def lk_auth_processing(emojis: dict) -> str:
|
||||||
return _lk_auth_processing(emojis)
|
return _lk_auth_processing(emojis)
|
||||||
@@ -569,7 +562,7 @@ def lk_nav_text(emojis: dict) -> str:
|
|||||||
return _lk_nav_text(emojis)
|
return _lk_nav_text(emojis)
|
||||||
|
|
||||||
def lk_years_menu(current_year: int, emojis: dict) -> str:
|
def lk_years_menu(current_year: int, emojis: dict) -> str:
|
||||||
return lk_years_menu(current_year, emojis)
|
return _lk_years_menu(current_year, emojis)
|
||||||
|
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -582,66 +575,88 @@ def lk_years_menu(current_year: int, emojis: dict) -> str:
|
|||||||
def _instruct_main_menu(emojis: dict) -> str:
|
def _instruct_main_menu(emojis: dict) -> str:
|
||||||
return (
|
return (
|
||||||
f"📋 <b>Выберите инструкцию:</b>\n\n"
|
f"📋 <b>Выберите инструкцию:</b>\n\n"
|
||||||
f"{emojis['1']} — 🖥 Trueconf\n"
|
f"{emojis['1']} — 🖥 Настройка TrueConf на мобильном устройстве\n"
|
||||||
f"{emojis['2']} — 📧 Почта\n\n"
|
f"{emojis['2']} — 📧 Настройка почты на мобильном устройстве\n\n"
|
||||||
f"<i>{emojis['0']} — В главное меню</i>"
|
f"<i>{emojis['0']} — В главное меню</i>"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _instruct_trueconf(emojis: dict) -> str:
|
def _instruct_trueconf(emojis: dict) -> str:
|
||||||
"""Статический текст Trueconf — без проверки группы."""
|
"""Статический текст Trueconf — без проверки группы."""
|
||||||
return (
|
return (
|
||||||
"🖥 <b>Инструкция по Trueconf</b>\n\n"
|
"🖥 <b>Инструкция по TrueConf</b>\n\n"
|
||||||
"Данная функция находится в разработке.\n\n"
|
"Данная функция находится в разработке.\n\n"
|
||||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _instruct_trueconf_pending(emojis: dict, cn: str) -> str:
|
||||||
|
"""Промпт для запроса доступа к Trueconf (когда заявка ещё не создана)."""
|
||||||
|
return (
|
||||||
|
f"⚠ <b>У Вас отсутствует доступ к TrueConf с мобильных устройств.</b>\n\n"
|
||||||
|
f"Если Вам требуется доступ, то напишите цифру {emojis['1']}, либо выберите другие пункты меню.\n\n"
|
||||||
|
f"{emojis['1']} — Запросить доступ\n"
|
||||||
|
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _instruct_trueconf_no_ticket(emojis: dict, cn: str) -> str:
|
||||||
|
"""Текст Trueconf, когда пользователь не найден в AD."""
|
||||||
|
return (
|
||||||
|
f"🖥 <b>Инструкция по TrueConf</b>\n\n"
|
||||||
|
f"Пользователь <b>{cn}</b> не найден в Active Directory.\n\n"
|
||||||
|
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
|
)
|
||||||
|
|
||||||
def _instruct_trueconf_with_ad(emojis: dict, cn: str, ticket_id: str = None) -> str:
|
def _instruct_trueconf_with_ad(emojis: dict, cn: str, ticket_id: str = None) -> str:
|
||||||
"""
|
"""
|
||||||
Текст Trueconf с проверкой группы 2FA.
|
Чистый шаблон сообщения Trueconf:
|
||||||
cn — CN пользователя из AD.
|
- Если ticket_id передан: показываем статус заявки в ServiceDesk.
|
||||||
ticket_id — номер созданной заявки в SD.
|
- Если ticket_id is None: у пользователя есть доступ, выводим подпись к файлу.
|
||||||
"""
|
"""
|
||||||
from utils.ad_checker import check_group_membership
|
if ticket_id:
|
||||||
|
|
||||||
result = check_group_membership(cn, "2FA")
|
|
||||||
|
|
||||||
if not result["found"]:
|
|
||||||
ticket_part = f"\n\n🎫 <b>Заявка #{ticket_id} создана!</b>" if ticket_id else ""
|
|
||||||
return (
|
return (
|
||||||
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
|
f"⚠ <b>У Вас отсутствует доступ к TrueConf с мобильных устройств.</b>\n\n"
|
||||||
f"Пользователь {cn} не найден в Active Directory.{ticket_part}\n\n"
|
f"🎫 Заявка #{ticket_id} для настройки создана в ServiceDesk!\n\n"
|
||||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
f"После выполнения заявки воспользуйтесь приложенной к этому сообщению инструкцией.\n\n"
|
||||||
)
|
|
||||||
|
|
||||||
if result["in_group"]:
|
|
||||||
body = (
|
|
||||||
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
|
|
||||||
f"✅ <b>Доступ к Trueconf разрешён.</b>\n\n"
|
|
||||||
f"У вас есть доступ к функциям Trueconf.\n\n"
|
|
||||||
f"Детали:\n"
|
|
||||||
f"• CN: {result['cn']}\n"
|
|
||||||
f"• Группы 2FA: {', '.join(result['matching_groups'])}\n\n"
|
|
||||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ticket_part = f"\n\n🎫 <b>Заявка #{ticket_id} создана!</b>" if ticket_id else ""
|
return (
|
||||||
body = (
|
f"🖥 <b>Инструкция по настройке TrueConf на мобильном устройстве</b>\n\n"
|
||||||
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
|
|
||||||
f"❌ <b>Доступ к Trueconf запрещён.</b>\n\n"
|
|
||||||
f"У вас нет группы 2FA в Active Directory.{ticket_part}\n\n"
|
|
||||||
f"Детали:\n"
|
|
||||||
f"• CN: {result['cn']}\n"
|
|
||||||
f"• Групп 2FA: 0\n\n"
|
|
||||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
)
|
)
|
||||||
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
def _instruct_email_available(emojis: dict, cn: str, mail: str) -> str:
|
||||||
def _instruct_email(emojis: dict) -> str:
|
"""Текст когда почта доступна."""
|
||||||
return (
|
return (
|
||||||
"📧 <b>Инструкция по Почте</b>\n\n"
|
f"📧 <b>Инструкция по настройке Почты на мобильном устройстве</b>\n\n"
|
||||||
"Данная функция находится в разработке.\n\n"
|
|
||||||
|
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _instruct_email_history_ask(emojis: dict, cn: str) -> str:
|
||||||
|
"""Вопрос о наличии ранее доступа к мобильной почте."""
|
||||||
|
return (
|
||||||
|
f"📧 <b>Был ли у Вас ранее доступ к мобильной почте?</b>\n\n"
|
||||||
|
f"Напишите нужную цифру в ответ:\n\n"
|
||||||
|
f"{emojis['1']} — да, был\n"
|
||||||
|
f"{emojis['2']} — нет, не было\n\n"
|
||||||
|
f"{emojis['0']} — В главное меню"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _instruct_email_unavailable(emojis: dict, cn: str) -> str:
|
||||||
|
"""Текст когда почта недоступна (старый вариант для обратной совместимости)."""
|
||||||
|
return (
|
||||||
|
f"⚠️ <b>У Вас отсутствует доступ к Почте</b>\n\n"
|
||||||
|
"Создайте, пожалуйста, заявку в Directum RX на модификацию прав пользователя.\n\n"
|
||||||
|
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _instruct_email_not_had(emojis: dict, cn: str) -> str:
|
||||||
|
"""Текст когда пользователь ответил 'не было доступа к мобильной почте'."""
|
||||||
|
return (
|
||||||
|
f"⚠️ <b>У Вас отсутствует доступ к мобильной почте.</b>\n\n"
|
||||||
|
"Создайте, пожалуйста, заявку в Directum RX на \"Удалённый доступ к ИС\".\n\n"
|
||||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -650,4 +665,15 @@ def _instruct_email(emojis: dict) -> str:
|
|||||||
INSTRUCT_MAIN_MENU_TEXT = _instruct_main_menu(EMOJI_DIGITS)
|
INSTRUCT_MAIN_MENU_TEXT = _instruct_main_menu(EMOJI_DIGITS)
|
||||||
INSTRUCT_TRUECONF_TEXT = _instruct_trueconf(EMOJI_DIGITS)
|
INSTRUCT_TRUECONF_TEXT = _instruct_trueconf(EMOJI_DIGITS)
|
||||||
INSTRUCT_TRUECONF_WITH_AD = _instruct_trueconf_with_ad
|
INSTRUCT_TRUECONF_WITH_AD = _instruct_trueconf_with_ad
|
||||||
INSTRUCT_EMAIL_TEXT = _instruct_email(EMOJI_DIGITS)
|
INSTRUCT_TRUECONF_PENDING_TEXT = _instruct_trueconf_pending
|
||||||
|
INSTRUCT_TRUECONF_NO_TICKET_TEXT = _instruct_trueconf_no_ticket
|
||||||
|
INSTRUCT_EMAIL_AVAILABLE = _instruct_email_available
|
||||||
|
INSTRUCT_EMAIL_HISTORY_ASK = _instruct_email_history_ask
|
||||||
|
INSTRUCT_EMAIL_NOT_HAD = _instruct_email_not_had
|
||||||
|
INSTRUCT_EMAIL_UNAVAILABLE = _instruct_email_unavailable
|
||||||
|
|
||||||
|
# Единая стандартная заглушка системной ошибки для модуля инструкций
|
||||||
|
INSTRUCT_ERROR_MSG = system_error_text(EMOJI_DIGITS, show_back=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user