Initial commit: TrueConf Chatbot КЛЕВЕР

This commit is contained in:
2026-07-15 12:00:27 +07:00
commit fc0288d13d
42 changed files with 7818 additions and 0 deletions
View File
+115
View File
@@ -0,0 +1,115 @@
# /opt/trueconf_bot/lk/documents_service.py
import smtplib
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from datetime import datetime
import config.config as config
logger = logging.getLogger(__name__)
def get_user_info_for_docs(cursor, card_id: str) -> dict:
"""Извлекает кадровые данные сотрудника по CARD_ID для оформления бланков справок"""
current_date = datetime.now().strftime('%Y-%m-%d')
query = """
select e.TAB_N, a.NAME, d.NAME, hr.FULL_NAME, hr.EMAIL, f.SNAME, pr.EMAIL as workBookEmail
from (select e.TAB_N, e.FIRM_ID, c.F_NAME, c.L_NAME, e.ID, c.M_NAME
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, c.F_NAME, c.L_NAME, c.M_NAME, e.TAB_N ) as e
join UOV_SELFSERVICE_PR_TRANS t on t.EMP_ID = e.ID
join UOV_SELFSERVICE_ST_APPOINT a on a.ID = t.APPOINT_ID
join UOV_SELFSERVICE_HR_DEPT d on d.ID = t.DEPT_ID
join UOV_SELFSERVICE_HR_S_USER_SL hr on hr.FIRM_ID = e.FIRM_ID
join UOV_SELFSERVICE_HR_FIRM f on e.FIRM_ID=f.ID
left join UOV_SELFSERVICE_HR_S_USER_PR pr on (pr.DEPT_ISOL_ID = d.DEPT_ISOL_ID or pr.FIRM_ID = f.ID)
where ((t.D_FROM <= %s and t.D_TO >= %s)
or (t.D_FROM <= %s and t.D_TO = '1999-12-31'))
and e.FIRM_ID = f.ID
"""
cursor.execute(query, (card_id, current_date, current_date, current_date))
row = cursor.fetchone()
if not row:
raise Exception("Не удалось найти кадровые данные сотрудника для заказа справки.")
return {
'tab_n': str(row[0]) if row[0] else "Не указан",
'job': str(row[1]) if row[1] else "",
'dept': str(row[2]) if row[2] else "",
'hr_fio': str(row[3]) if row[3] else "",
'hr_email': str(row[4]) if row[4] else "",
'firm': str(row[5]) if row[5] else "",
'wb_email': str(row[6]) if row[6] else ""
}
def generate_mail_message(doc_name: str, quantity: int, user_info: dict, period: str = "", attachment_path: str = "") -> bool:
"""Формирует и отправляет HTML-письмо заявки на справку в отдел кадров СибЦема"""
# Извлекаем оригинальные адреса кадровиков из структуры БД
orig_to = user_info.get('hr_email', '').strip()
# Копия уходит на ящик трудовых книжек только если заказывают пункт 7
orig_cc = user_info.get('wb_email', '').strip() if doc_name == "Копия трудовой книжки" else ""
to_email = orig_to
cc_list = [orig_cc] if orig_cc else []
# Если включен DOCUMENTS_TEST_MODE, жестко перехватываем поток писем
if getattr(config, 'DOCUMENTS_TEST_MODE', False):
test_email = getattr(config, 'DOCUMENTS_TARGET_EMAIL', 'mailonline@sibcem.ru')
to_email = test_email
cc_list = []
subject = f"Запрос документа {doc_name} из Личного кабинета"
period_str = f" {period}" if period else ""
messageBody = (
f"Необходимо оформить документ {doc_name} в количестве {quantity} шт. "
f"на сотрудника предприятия {user_info['firm']} с табельным номером - "
f"\"{user_info['tab_n']}\"{period_str}.<br/><br/>"
f"Данное письмо отправлено автоматически, отвечать на него не стоит."
)
message = MIMEMultipart()
message['From'] = getattr(config, 'DEFAULT_EMAIL_FROM', 'mailonline@sibcem.ru')
message['To'] = to_email
message['Subject'] = subject
if cc_list:
message['Cc'] = ", ".join(cc_list)
message.attach(MIMEText(messageBody, 'html', 'utf-8'))
if attachment_path:
try:
with open(attachment_path, 'rb') as attachment:
part = MIMEBase("application", "octet-stream")
part.set_payload(attachment.read())
encoders.encode_base64(part)
part.add_header("Content-Disposition", f"attachment; filename={attachment_path}")
message.attach(part)
except Exception as e:
logger.error(f"Не удалось прикрепить файл к письму: {e}")
recipients = [to_email] + cc_list
smtp_server = getattr(config, 'SMTP_SERVER', 'srvkem-mail.sibcem.ru')
smtp_port = int(getattr(config, 'SMTP_PORT', 25))
try:
server = smtplib.SMTP(smtp_server, smtp_port)
server.starttls()
# Если в конфигурации Passwork лежат логин/пароль для SMTP — авторизуемся
if getattr(config, 'SMTP_USER', None):
server.login(config.SMTP_USER, config.SMTP_PASSWORD)
server.sendmail(message['From'], recipients, message.as_string())
server.quit()
return True
except Exception as e:
logger.error(f"Ошибка SMTP при отправке письма: {e}")
return False
+682
View File
@@ -0,0 +1,682 @@
from utils.texts import *
# /opt/trueconf_bot/lk/handlers.py
import re
import os
import csv
import logging
import asyncio
import time
import smtplib
from datetime import datetime, timedelta
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from ldap3 import Server, Connection, ALL
from ldap3.utils.conv import escape_filter_chars
from trueconf import Router, Message
from trueconf.types import FSInputFile
import config.config as config
import lk.payslip_service as ps
import lk.documents_service as ds
from utils.states import get_state, set_state, clear_state
# 🔌 Импортируем централизованную функцию сбора статистики из main
from utils.stats_logger import log_menu_stats
# 🔌 Импортируем функции безопасности из изолированного утилитного модуля 2FA
from utils.otp_service import generate_otp_code, send_otp_via_api, get_user_phone_from_ad
# 🔌 Импортируем вынесенную бизнес-логику запросов по CARD_ID
from lk.info_service import (
get_vacation_records,
get_rewards_text,
get_sick_leaves_text,
get_tabel_report_text,
get_lk_profile_by_card_id,
get_missions_records,
get_transfers_records
)
logger = logging.getLogger(__name__)
# =========================================================
# ГЛОБАЛЬНЫЙ МАРШРУТИЗАТОР ЦЕЛЬНЫХ VK-ЭМОДЗИ И СЕССИЙ ПАГИНАЦИИ
# =========================================================
EMOJI_DIGITS = {
"0": "0⃣",
"1": "1⃣",
"2": "2⃣",
"3": "3⃣",
"4": "4⃣",
"5": "5⃣",
"6": "6⃣",
"7": "7⃣",
"8": "8⃣",
"9": "9⃣",
"10": "🔟"
}
# Список из 7 справок строго по структуру портала
DOC_TYPES = {
"1": "Справка о доходах физического лица (2-НДФЛ)",
"2": "Справка об удержаниях за ДМС",
"3": "Справка о средней заработной плате",
"4": "Справка о получении единовременного пособия по рождению ребенка",
"5": "Справка о получении ежемесячного пособия и назначении отпуска по уходу за ребенком",
"6": "Справка об удержаниях по исполнительным листам",
"7": "Копия трудовой книжки"
}
LK_DOCS_MENU_TEXT = (
"📄 <b>Выберите тип документа:</b>\n\n" +
"\n".join([f"{EMOJI_DIGITS[k]}{v}" for k, v in DOC_TYPES.items()]) +
f"\n\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
)
USER_COOLDOWN_SEC = 1.5
EMAIL_COOLDOWN_SEC = 10.0
user_last_request = {}
last_email_time = 0.0
user_docs_session = {}
user_page_sessions = {}
# 🔒 Репозиторий временных блокировок 2FA (хранит user_id: timestamp_разблокировки)
user_payslip_blocks = {}
router = Router()
# =========================================================
# СИСТЕМНЫЕ ФУНКЦИИ И ОШИБКИ
# =========================================================
def log_and_notify_error(user_id: str, action: str, error_details: str):
global last_email_time
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "lk", "logs")
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, "lk_errors.log")
current_time = time.time()
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] User: {user_id} | Action: {action} | Error: {error_details}\n"
with open(log_file, "a", encoding="utf-8") as f:
f.write(log_entry)
if current_time - last_email_time < EMAIL_COOLDOWN_SEC:
return
last_email_time = current_time
try:
msg = MIMEMultipart()
msg['From'] = str(getattr(config, 'DEFAULT_EMAIL_FROM', 'bot@noreply.com'))
to_emails = getattr(config, 'ALERTS_SUPPORT_EMAILS', 'admin@example.com')
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Пользователь: {user_id}\nДействие: {action}\nДетали:\n{error_details}"
msg.attach(MIMEText(body, 'plain', 'utf-8'))
import lk.info_service as info_service
with smtplib.SMTP(getattr(config, 'SMTP_SERVER', 'localhost'), int(getattr(config, 'SMTP_PORT', 25))) as server:
server.send_message(msg)
except Exception:
pass
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)
error_msg = lk_error_handling(EMOJI_DIGITS)
await msg.answer(error_msg, parse_mode="html")
# =========================================================
# ИНТЕГРАЦИЯ С ACTIVE DIRECTORY ЧЕРЕЗ extensionAttribute2
# =========================================================
def get_card_id_from_ad(user_id: str) -> str:
try:
server = Server(config.AD_SERVER, get_info=ALL)
conn = Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
safe_user_id = escape_filter_chars(user_id)
short_username = user_id.split('@')[0]
search_filter = f"(&(objectClass=user)(|(mail={safe_user_id})(userPrincipalName={safe_user_id})(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})(sAMAccountName={escape_filter_chars(short_username)})))"
conn.search(config.AD_BASE, search_filter, attributes=['extensionAttribute2'])
if conn.entries and 'extensionAttribute2' in conn.entries[0] and conn.entries[0].extensionAttribute2.value:
return str(conn.entries[0].extensionAttribute2.value).strip()
raise Exception("Поле extensionAttribute2 не заполнено в AD.")
except Exception as e: raise Exception(f"AD Card Query Error: {e}")
# =========================================================
# МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА
# =========================================================
async def send_paginated_page(msg: Message, user_id: str):
session = user_page_sessions.get(user_id)
if not session: return
current_page = session["current_page"]
per_page = session["per_page"]
items = session["items"]
total_items = len(items)
total_pages = (total_items + per_page - 1) // per_page if total_items > 0 else 1
start_idx = current_page * per_page
end_idx = start_idx + per_page
page_items = items[start_idx:end_idx]
if session["type"] == "vacation":
body_text = session["static_header"] + "\n📜 <b>История отпусков (полный архив):</b>\n"
body_text += "\n".join(page_items) + "\n" if page_items else "• <i>История отпусков пуста</i>\n"
elif session["type"] == "missions":
body_text = "🧳 <b>Информация о командировках (архив):</b>\n\n"
body_text += "\n\n".join(page_items) + "\n" if page_items else "• <i>Записи о командировках отсутствуют</i>\n"
elif session["type"] == "transfers":
body_text = "🔄 <b>История кадровых перемещений:</b>\n\n"
body_text += "\n\n".join(page_items) + "\n" if page_items else "• <i>Записи о перемещениях отсутствуют</i>\n"
body_text += f"\n📄 <b>Страница {current_page + 1} из {total_pages}</b>\n\n"
nav_lines = []
if current_page > 0: nav_lines.append(f"{EMOJI_DIGITS['7']} — Предыдущая страница")
if current_page < total_pages - 1: nav_lines.append(f"{EMOJI_DIGITS['8']} — Следующая страница")
nav_lines.append(f"{EMOJI_DIGITS['9']} — Назад")
nav_lines.append(f"{EMOJI_DIGITS['0']}В главное меню")
body_text += "\n".join(nav_lines)
await msg.answer(body_text, parse_mode="html")
# =========================================================
# ДИНАМИЧЕСКАЯ СБОРКА ГЛАВНОГО МЕНЮ ЛК
# =========================================================
async def send_dynamic_lk_main_menu(msg: Message, user_id: str):
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
profile = await asyncio.to_thread(get_lk_profile_by_card_id, card_id)
header = (
f"👤 <b>Личный кабинет</b>\n\n"
f"🏢 <b>Компания:</b> {profile['company']}\n"
f"📂 <b>Отдел:</b> {profile['dept']}\n"
f"💼 <b>Должность:</b> {profile['job']}\n"
f"⏳ <b>Страховой стаж:</b> {profile['stag_o']}\n"
f"📈 <b>Стаж на предприятии:</b> {profile['stag_p']}\n\n"
)
except Exception as e:
logger.error(f"Не удалось собрать динамический заголовок ЛК: {e}")
header = "👤 <b>Личный кабинет</b>\n\n"
menu_text = lk_main_menu_text(header, EMOJI_DIGITS)
await msg.answer(menu_text, parse_mode="html")
async def send_tabel_months_menu(msg: Message, user_id: str):
now = datetime(2026, 6, 3)
prev_1 = now.replace(day=1) - timedelta(days=1)
prev_2 = prev_1.replace(day=1) - timedelta(days=1)
months_ru = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
menu_text = lk_tabel_months_menu_text(prev_1, prev_2, months_ru, EMOJI_DIGITS)
set_state(user_id, "LK_TABEL_MONTHS")
await msg.answer(menu_text, parse_mode="html")
def wrapper_get_months(card_id: str) -> list:
import lk.info_service as info_service
conn = info_service.get_db_connection()
cursor = conn.cursor()
try: return ps.get_latest_payslip_months(cursor, card_id)
finally: conn.close()
def wrapper_get_payslip(card_id: str, target_cmonth: int):
import lk.info_service as info_service
conn = info_service.get_db_connection()
cursor = conn.cursor()
try: return ps.build_payslip_data(cursor, card_id, target_cmonth)
except Exception as e: raise Exception(f"Payslip Build Error: {e}")
finally: conn.close()
async def send_payslip_months_menu(msg: Message, user_id: str, card_id: str):
try:
months_data = await asyncio.to_thread(wrapper_get_months, card_id)
months_ru = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
menu_text = lk_payslip_months_menu_text(months_data, months_ru, EMOJI_DIGITS)
set_state(user_id, "LK_PAYSLIP_MONTHS")
await msg.answer(menu_text, parse_mode="html")
except Exception as e: raise Exception(str(e))
async def build_confirmation_text(user_id: str, doc: dict) -> str:
confirm_txt = f"📝 <b>Подтверждение заказа:</b>\n\nДокумент: {doc['doc_name']}\nКоличество: {doc['quantity']} шт."
if doc.get('period'): confirm_txt += f"\nУточнение: {doc['period']}"
if getattr(config, 'DOCUMENTS_TEST_MODE', False):
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
import lk.info_service as info_service
with info_service.get_db_connection() as conn:
cursor = conn.cursor()
user_info = await asyncio.to_thread(ds.get_user_info_for_docs, cursor, card_id)
orig_to = user_info.get('hr_email', '').strip()
orig_cc = user_info.get('wb_email', '').strip() if doc['doc_id'] == "7" else ""
requested_recipients = orig_to
if orig_cc: requested_recipients += f" (Копия: {orig_cc})"
test_email = getattr(config, 'DOCUMENTS_TARGET_EMAIL', 'mailonline@sibcem.ru')
confirm_txt += f"\n\n⚠️ <b>Тест-режим.</b>\nКому: <code>{requested_recipients}</code>\nЗамена: <code>{test_email}</code>"
except Exception as e: confirm_txt += f"\n\n⚠️ <b>Тест-режим.</b>\n(Ошибка БД: {e})"
confirm_txt += f"\n\n<i>{EMOJI_DIGITS['1']} — Да, отправить\n{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
return confirm_txt
# =========================================================
# ОСНОВНОЙ ОБРАБОТЧИК МАРШРУТОВ ЛК
# =========================================================
@router.message()
async def lk_router_handler(msg: Message):
user_id = msg.from_user.id
current_time = time.time()
if current_time - user_last_request.get(user_id, 0) < USER_COOLDOWN_SEC: return
user_last_request[user_id] = current_time
current_state = get_state(user_id)
nav_text = lk_nav_text(EMOJI_DIGITS)
if not msg.text: return
cmd = msg.text.strip().lower()
for raw_num, emoji_num in EMOJI_DIGITS.items():
if cmd == emoji_num:
cmd = raw_num
break
if current_state is None:
from utils.menu import MENU_MAP
if MENU_MAP.get(cmd) == "LK":
msg.handled = True
set_state(user_id, "LK_MODE")
await send_dynamic_lk_main_menu(msg, user_id)
return
return
if current_state == "LK_MODE":
msg.handled = True
if cmd == "0":
log_menu_stats(user_id, "Личный кабинет", "Выход в главное меню")
clear_state(user_id)
from utils.menu import MENU_TEXT
await msg.answer(MENU_TEXT, parse_mode="html")
elif cmd == "1":
block_expiry = user_payslip_blocks.get(user_id, 0)
if time.time() < block_expiry:
rem_minutes = int((block_expiry - time.time()) // 60) + 1
await msg.answer(
f"❌ <b>Доступ временно заблокирован.</b>\n"
f"Вы трижды ввели неверный код подтверждения.\n"
f"Доступ к расчетным листам ограничен на {int(getattr(config, 'OTP_BLOCK_DURATION_SEC', 1800) // 60)} минут.\n\n"
f"⏱ Осталось подождать: <b>{rem_minutes} мин.</b>{nav_text}",
parse_mode="html"
)
return
log_menu_stats(user_id, "Личный кабинет", "Расчётный лист")
await msg.answer(lk_payslip_loading(EMOJI_DIGITS), parse_mode="html")
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
await send_payslip_months_menu(msg, user_id, card_id)
except Exception as e: await handle_lk_error(msg, user_id, "Меню расчетных листков", str(e))
elif cmd == "2":
log_menu_stats(user_id, "Личный кабинет", "Информация о днях отпуска")
await msg.answer(lk_vacation_loading(EMOJI_DIGITS), parse_mode="html")
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
static_header, history_lines = await asyncio.to_thread(get_vacation_records, card_id)
user_page_sessions[user_id] = {"type": "vacation", "current_page": 0, "per_page": 5, "static_header": static_header, "items": history_lines}
set_state(user_id, "LK_VACATION_VIEW")
await send_paginated_page(msg, user_id)
except Exception as e: await handle_lk_error(msg, user_id, "Справка по отпускам", str(e))
elif cmd == "3":
log_menu_stats(user_id, "Личный кабинет", "Больничные")
await msg.answer(lk_sick_loading(EMOJI_DIGITS), parse_mode="html")
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
sick_info = await asyncio.to_thread(get_sick_leaves_text, card_id)
set_state(user_id, "LK_SICK_VIEW")
await msg.answer(sick_info + nav_text, parse_mode="html")
except Exception as e: await handle_lk_error(msg, user_id, "Просмотр больничных", str(e))
elif cmd == "4":
log_menu_stats(user_id, "Личный кабинет", "Командировки")
await msg.answer(lk_missions_loading(EMOJI_DIGITS), parse_mode="html")
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
mission_lines = await asyncio.to_thread(get_missions_records, card_id)
user_page_sessions[user_id] = {"type": "missions", "current_page": 0, "per_page": 4, "items": mission_lines}
set_state(user_id, "LK_MISSIONS_VIEW")
await send_paginated_page(msg, user_id)
except Exception as e: await handle_lk_error(msg, user_id, "Просмотр командировок", str(e))
elif cmd == "5":
log_menu_stats(user_id, "Личный кабинет", "Награды")
await msg.answer(lk_rewards_loading(EMOJI_DIGITS), parse_mode="html")
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
rewards_info = await asyncio.to_thread(get_rewards_text, card_id)
set_state(user_id, "LK_REWARDS_VIEW")
await msg.answer(rewards_info + nav_text, parse_mode="html")
except Exception as e: await handle_lk_error(msg, user_id, "Запрос наград", str(e))
elif cmd == "6":
log_menu_stats(user_id, "Личный кабинет", "Переводы")
await msg.answer(lk_transfers_loading(EMOJI_DIGITS), parse_mode="html")
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
transfer_lines = await asyncio.to_thread(get_transfers_records, card_id)
user_page_sessions[user_id] = {"type": "transfers", "current_page": 0, "per_page": 4, "items": transfer_lines}
set_state(user_id, "LK_TRANSFERS_VIEW")
await send_paginated_page(msg, user_id)
except Exception as e: await handle_lk_error(msg, user_id, "Просмотр переводов", str(e))
elif cmd == "7":
log_menu_stats(user_id, "Личный кабинет", "Табель учёта рабочего времени")
await send_tabel_months_menu(msg, user_id)
elif cmd == "8":
log_menu_stats(user_id, "Личный кабинет", "Справки")
set_state(user_id, "LK_DOCS_MENU")
await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
else:
# 🎯 ИСПРАВЛЕНО: Вместо спама копии меню ЛК выводим строгое предупреждение
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state == "LK_TABEL_MONTHS":
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, "LK_MODE")
await send_dynamic_lk_main_menu(msg, user_id)
elif cmd in ["1", "2"]:
log_menu_stats(user_id, "Личный кабинет", "Просмотр табеля за месяц")
await msg.answer(lk_tabel_loading(EMOJI_DIGITS), parse_mode="html")
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
now = datetime(2026, 6, 3)
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)
tabel_report = await asyncio.to_thread(get_tabel_report_text, card_id, target_date.year, target_date.month)
set_state(user_id, "LK_TABEL_VIEW")
await msg.answer(tabel_report + nav_text, parse_mode="html")
except Exception as e: await handle_lk_error(msg, user_id, "Формирование табеля", str(e))
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state in ["LK_VACATION_VIEW", "LK_MISSIONS_VIEW", "LK_TRANSFERS_VIEW"]:
msg.handled = True
session = user_page_sessions.get(user_id)
if cmd == "0":
user_page_sessions.pop(user_id, None)
clear_state(user_id)
from utils.menu import MENU_TEXT
await msg.answer(MENU_TEXT, parse_mode="html")
elif cmd == "9":
user_page_sessions.pop(user_id, None)
set_state(user_id, "LK_MODE")
await send_dynamic_lk_main_menu(msg, user_id)
elif cmd == "7" and session and session["current_page"] > 0:
session["current_page"] -= 1
await send_paginated_page(msg, user_id)
elif cmd == "8" and session:
if session["current_page"] < (len(session["items"]) + session["per_page"] - 1) // session["per_page"] - 1:
session["current_page"] += 1
await send_paginated_page(msg, user_id)
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state == "LK_PAYSLIP_AUTH":
msg.handled = True
session = user_page_sessions.get(user_id)
if cmd == "0":
user_page_sessions.pop(user_id, None)
clear_state(user_id)
from utils.menu import MENU_TEXT
await msg.answer(MENU_TEXT, parse_mode="html")
return
elif cmd == "9":
user_page_sessions.pop(user_id, None)
set_state(user_id, "LK_PAYSLIP_MONTHS")
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
await send_payslip_months_menu(msg, user_id, card_id)
except Exception as e: await handle_lk_error(msg, user_id, "Возврат к меню месяцев", str(e))
return
if not session:
await msg.answer("⚠️ <i>Сессия авторизации не найдена. Начните сначала.</i>", parse_mode="html")
set_state(user_id, "LK_MODE")
await send_dynamic_lk_main_menu(msg, user_id)
return
if time.time() > session.get("expires_at", 0):
log_menu_stats(user_id, "Личный кабинет", "Ошибка 2FA: Истекло время жизни кода")
user_page_sessions.pop(user_id, None)
set_state(user_id, "LK_MODE")
await msg.answer(
f"⏱ <b>Время действия кода подтверждения изменилось или истекло.</b>\n"
f"Пожалуйста, запросите расчетный лист заново.{nav_text}",
parse_mode="html"
)
await send_dynamic_lk_main_menu(msg, user_id)
return
if cmd == session.get("correct_code"):
log_menu_stats(user_id, "Личный кабинет", "Успешная проверка 2FA расчетного листа")
await msg.answer(lk_auth_success(EMOJI_DIGITS), parse_mode="html")
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
target_cmonth = session["target_cmonth"]
m_name = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
tmonth = (target_cmonth - 1) % 12 + 1
tyear = (target_cmonth - 1) // 12
payslip_info, pdf_path = await asyncio.to_thread(wrapper_get_payslip, card_id, target_cmonth)
set_state(user_id, "LK_PAYSLIP_VIEW")
await msg.answer_document(
FSInputFile(pdf_path, filename=f"Расчетный_лист_{m_name.get(tmonth, '00')}_{tyear}.pdf"),
caption=payslip_info + f"\n\n<i>{EMOJI_DIGITS['9']} — Назад к выбору месяца\n{EMOJI_DIGITS['0']} — В главное меню</i>",
parse_mode="html"
)
if os.path.exists(pdf_path): os.remove(pdf_path)
except Exception as e:
await handle_lk_error(msg, user_id, "Просмотр детализации после 2FA", str(e))
finally:
user_page_sessions.pop(user_id, None)
else:
session["attempts_left"] -= 1
if session["attempts_left"] <= 0:
log_menu_stats(user_id, "Личный кабинет", "Ошибка 2FA: Превышен лимит попыток ввода, бан")
block_duration = int(getattr(config, 'OTP_BLOCK_DURATION_SEC', 1800))
user_payslip_blocks[user_id] = time.time() + block_duration
user_page_sessions.pop(user_id, None)
set_state(user_id, "LK_MODE")
await msg.answer(
f"❌ <b>Превышено максимальное количество попыток ввода кода.</b>\n"
f"Доступ к финансовым документам заблокирован на <b>{block_duration // 60} минут</b>.{nav_text}",
parse_mode="html"
)
await send_dynamic_lk_main_menu(msg, user_id)
else:
await msg.answer(
f"⚠️ <b>Неверный код подтверждения.</b>\n"
f"Осталось попыток: {session['attempts_left']}. Попробуйте еще раз или вернитесь назад.{nav_text}",
parse_mode="html"
)
elif current_state in ["LK_REWARDS_VIEW", "LK_PAYSLIP_MONTHS", "LK_SICK_VIEW", "LK_TABEL_VIEW", "LK_STUB_VIEW"]:
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":
if current_state == "LK_TABEL_VIEW": await send_tabel_months_menu(msg, user_id)
else: set_state(user_id, "LK_MODE"); await send_dynamic_lk_main_menu(msg, user_id)
elif current_state == "LK_PAYSLIP_MONTHS" and cmd.isdigit():
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
months_data = await asyncio.to_thread(wrapper_get_months, card_id)
idx = int(cmd) - 1
if 0 <= idx < len(months_data):
target_cmonth = months_data[idx]
await msg.answer(lk_auth_processing(EMOJI_DIGITS), parse_mode="html")
user_phone = await asyncio.to_thread(get_user_phone_from_ad, user_id)
otp_code = generate_otp_code(4)
sms_success, channel_info = await send_otp_via_api(user_phone, otp_code)
if sms_success:
log_menu_stats(user_id, "Личный кабинет", f"Запрос кода 2FA для расчетного листа ({channel_info})")
masked_phone = f"{user_phone[:5]}***{user_phone[-4:]}" if len(user_phone) > 8 else user_phone
otp_ttl = int(getattr(config, 'OTP_TTL_SEC', 180))
max_attempts = int(getattr(config, 'OTP_MAX_ATTEMPTS', 3))
user_page_sessions[user_id] = {
"correct_code": otp_code,
"target_cmonth": target_cmonth,
"expires_at": time.time() + otp_ttl,
"attempts_left": max_attempts
}
set_state(user_id, "LK_PAYSLIP_AUTH")
await msg.answer(
f"🔐 <b>Двухфакторная авторизация</b>\n\n"
f"Код подтверждения отправлен <b>{channel_info}</b> на номер <code>{masked_phone}</code>.\n"
f"Пожалуйста, введите 4-значный код из сообщения в ответ:\n\n"
f"<i>Код действителен в течение {otp_ttl // 60} минут. У вас есть {max_attempts} попытки.</i>{nav_text}",
parse_mode="html"
)
else:
raise Exception(f"Внутренний каскадный шлюз недоступен: {channel_info}")
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
except Exception as e: await handle_lk_error(msg, user_id, "Инициализация 2FA расчетного листа", str(e))
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state == "LK_PAYSLIP_VIEW":
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":
try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id); await send_payslip_months_menu(msg, user_id, card_id)
except Exception as e: await handle_lk_error(msg, user_id, "Возврат в меню месяцев", str(e))
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state == "LK_DOCS_MENU":
msg.handled = True
if cmd == "0": user_docs_session.pop(user_id, None); clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html")
elif cmd == "9": user_docs_session.pop(user_id, None); set_state(user_id, "LK_MODE"); await send_dynamic_lk_main_menu(msg, user_id)
elif cmd in DOC_TYPES:
user_docs_session[user_id] = {'doc_id': cmd, 'doc_name': DOC_TYPES[cmd]}
log_menu_stats(user_id, "Личный кабинет", f"Выбор справки: {DOC_TYPES[cmd]}")
set_state(user_id, "LK_DOCS_QUANTITY")
await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html")
else:
# 🎯 ИСПРАВЛЕНО: Заменен спам списка документов на чистую ошибку ввода
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state == "LK_DOCS_QUANTITY":
msg.handled = True
if cmd == "0": user_docs_session.pop(user_id, None); 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.isdigit() and 0 < int(cmd) <= 5:
user_docs_session[user_id]['quantity'] = int(cmd)
doc_id = user_docs_session[user_id]['doc_id']
if doc_id in ["1", "2", "6"]:
set_state(user_id, "LK_DOCS_PERIOD")
current_year = datetime.now().year
years_text = lk_years_menu(current_year, EMOJI_DIGITS)
await msg.answer(years_text, parse_mode="html")
elif doc_id == "3":
set_state(user_id, "LK_DOCS_PERIOD")
await msg.answer(lk_docs_avg_salary_prompt(EMOJI_DIGITS), parse_mode="html")
elif doc_id == "5":
set_state(user_id, "LK_DOCS_PERIOD")
await msg.answer(lk_docs_child_age_prompt(EMOJI_DIGITS), parse_mode="html")
else:
user_docs_session[user_id]['period'] = ""
set_state(user_id, "LK_DOCS_CONFIRM")
confirm_txt = await build_confirmation_text(user_id, user_docs_session[user_id])
await msg.answer(confirm_txt, parse_mode="html")
else:
# 🎯 ИСПРАВЛЕНО: Вместо кастомной подсказки выводим единый стандарт
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state == "LK_DOCS_PERIOD":
msg.handled = True
if cmd == "0": user_docs_session.pop(user_id, None); 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_QUANTITY"); await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html")
else:
doc_id = user_docs_session[user_id]['doc_id']
if doc_id in ["1", "2", "6"]:
if cmd in ["1", "2", "3", "4", "5", "6", "7", "8"]:
user_docs_session[user_id]['period'] = f"за {datetime.now().year - (int(cmd) - 1)} год"
else:
# 🎯 ИСПРАВЛЕНО: Убран спам меню годов при абракадабре
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return
elif doc_id == "5":
if cmd == "1": user_docs_session[user_id]['period'] = "до 1.5 лет"
elif cmd == "2": user_docs_session[user_id]['period'] = "до 3 лет"
else:
# 🎯 ИСПРАВЛЕНО: Убран спам меню возраста при некорректном вводе
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return
elif doc_id == "3":
user_docs_session[user_id]['period'] = f"за {cmd} мес."
set_state(user_id, "LK_DOCS_CONFIRM")
confirm_txt = await build_confirmation_text(user_id, user_docs_session[user_id])
await msg.answer(confirm_txt, parse_mode="html")
elif current_state == "LK_DOCS_CONFIRM":
msg.handled = True
if cmd == "0": user_docs_session.pop(user_id, None); clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html")
elif cmd == "9":
doc_id = user_docs_session.get(user_id, {}).get('doc_id')
if doc_id in ["1", "2", "3", "5", "6"]:
set_state(user_id, "LK_DOCS_PERIOD")
if doc_id in ["1", "2", "6"]:
current_year = datetime.now().year
years_text = lk_years_menu(current_year, EMOJI_DIGITS)
await msg.answer(years_text, parse_mode="html")
elif doc_id == "3": await msg.answer(lk_docs_avg_salary_prompt(EMOJI_DIGITS), parse_mode="html")
elif doc_id == "5": await msg.answer(lk_docs_child_age_prompt(EMOJI_DIGITS), parse_mode="html")
else: set_state(user_id, "LK_DOCS_QUANTITY"); await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html")
elif cmd == "1":
await msg.answer("⏳ <i>Отправка заявки...</i>", parse_mode="html")
try:
doc = user_docs_session[user_id]
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
import lk.info_service as info_service
with info_service.get_db_connection() as conn:
cursor = conn.cursor()
user_info = await asyncio.to_thread(ds.get_user_info_for_docs, cursor, card_id)
success = await asyncio.to_thread(ds.generate_mail_message, doc['doc_name'], doc['quantity'], user_info, doc.get('period', ''))
if success:
log_menu_stats(user_id, "Личный кабинет", f"Заказ справки: {doc['doc_name']}")
await msg.answer(f"✅ <b>Ваша заявка успешно отправлена!</b>\nСпециалисты свяжутся с вами по готовности документа.\n\n<i>{EMOJI_DIGITS['9']} — Назад в список справок\n{EMOJI_DIGITS['0']} — В главное меню</i>", parse_mode="html")
set_state(user_id, "LK_DOCS_SUCCESS")
else: raise Exception("Внутренняя ошибка SMTP при отправке письма")
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)
else:
# 🎯 ИСПРАВЛЕНО: Удален спам-текст подтверждения
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state == "LK_DOCS_SUCCESS":
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, "LK_DOCS_MENU"); await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
else:
# 🎯 ИСПРАВЛЕНО: Унифицировано под общий шаблон ошибки
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
+378
View File
@@ -0,0 +1,378 @@
# /opt/trueconf_bot/lk/info_service.py
import re
import logging
import calendar
import pymssql
from datetime import datetime
import config.config as config
logger = logging.getLogger(__name__)
def get_db_connection():
if not config.DB_USER or not config.DB_PASSWORD:
raise Exception("Пароли БД не загружены из Passwork.")
return pymssql.connect(
server=config.SQL_SERVER,
user=config.DB_USER,
password=config.DB_PASSWORD,
database=config.SQL_DB_NAME,
charset='cp1251'
)
def get_lk_profile_by_card_id(card_id: str) -> dict:
"""Запрос 1: Получение информации о подразделении, должности и стаже по CARD_ID"""
try:
current_date = datetime.now().strftime('%Y-%m-%d')
conn = get_db_connection()
cursor = conn.cursor()
query = """
select f.SNAME [Компания], d.NAME [Отдел], appoint.NAME [Должность], rtrim(ltrim(e.STAGO)) [Страховой стаж], rtrim(ltrim(e.STAGP)) [Стаж на предприятии]
from (select e.ID, e.FIRM_ID, st.STAGO, st.STAGP
from UOV_SELFSERVICE_PR_EMP e
join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID
join UOV_SELFSERVICE_PR_STAG_CUR st on st.CARD_ID=c.ID
where c.ID=%s
group by e.ID , e.FIRM_ID, st.STAGO, st.STAGP) 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
join UOV_SELFSERVICE_HR_DEPT d on d.ID = t.DEPT_ID
join UOV_SELFSERVICE_ST_APPOINT appoint on appoint.ID = t.APPOINT_ID
where (t.D_FROM <= cast(%s as date)
and (t.D_TO >= cast(%s as date)
or t.D_TO = cast('1999-12-31' as date)))
group by f.SNAME, d.NAME, appoint.NAME, rtrim(ltrim(e.STAGO)), rtrim(ltrim(e.STAGP))
"""
cursor.execute(query, (card_id, current_date, current_date))
row = cursor.fetchone()
conn.close()
if not row: raise Exception(f"Запись сотрудника по CARD_ID {card_id} не найдена в БД.")
return {
'company': str(row[0]).strip() if row[0] else "",
'dept': str(row[1]).strip() if row[1] else "",
'job': str(row[2]).strip() if row[2] else "",
'stag_o': str(row[3]).strip() if row[3] else "0",
'stag_p': str(row[4]).strip() if row[4] else "0"
}
except Exception as e: raise Exception(f"SQL Profile Error: {e}")
def get_vacation_records(card_id: str) -> tuple:
"""Возвращает статический заголовок (баланс + планы) и список строк истории отпусков"""
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT TOP 1 e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s", (card_id,))
row = cursor.fetchone()
if not row:
conn.close()
return "🌴 <b>Информация об отпусках</b>\n\nСотрудник не найден в базе данных.", []
emp_id = row[0]
# Балансы
q_balance = """
SELECT comp.LEAVE_NAME, comp.DAYS FROM UO_SELFSERVICE_LEAVE_COMP as comp
INNER JOIN UOV_SELFSERVICE_PR_TRANS as trans ON trans.EMP_ID = comp.EMP_ID
WHERE GETDATE() BETWEEN trans.D_FROM and trans.D_TO AND comp.EMP_ID = %s
"""
cursor.execute(q_balance, (emp_id,))
balances = cursor.fetchall()
# Планы
q_plans = """
SELECT e.D_FROM, e.DAYS
FROM UOV_SELFSERVICE_PR_LEAVE_PLAN e
WHERE e.EMP_ID = %s AND e.D_FROM >= CAST(GETDATE() AS DATE)
ORDER BY e.D_FROM ASC
"""
cursor.execute(q_plans, (emp_id,))
plans = cursor.fetchall()
# Архив всей истории
q_history = """
SELECT e.D_FROM, e.D_TO, e.DAYS, CAST(e.NAME AS VARCHAR(250)), e.ADDITIONAL
FROM UOV_SELFSERVICE_PR_LEAVE_ALL e
WHERE e.EMP_ID = %s AND e.D_FROM < CAST(GETDATE() AS DATE)
ORDER BY e.D_FROM DESC
"""
cursor.execute(q_history, (emp_id,))
history = cursor.fetchall()
conn.close()
# Формируем неизменяемую верхнюю часть (шапку) справки
static_header = "🌴 <b>Информация об отпусках</b>\n\n🟢 <b>Доступный остаток:</b>\n"
if balances:
for b in balances:
b_name = str(b[0]).strip() if b[0] else "Ежегодный оплачиваемый отпуск"
static_header += f"• <b>{int(b[1]) if b[1] else 0} дн.</b> <i>({b_name})</i>\n"
else:
static_header += "• <b>0 дн.</b>\n"
static_header += "\n⏳ <b>Предстоящие отпуска:</b>\n"
if plans:
for p in plans:
d_from = p[0].strftime('%d.%m.%Y') if hasattr(p[0], 'strftime') else str(p[0])[:10]
static_header += f"• <b>{d_from}</b> — <b>{int(p[1]) if p[1] else 0} дн.</b>\n"
else:
static_header += "• <i>Нет запланированных отпусков</i>\n"
# Нарезаем строки истории в плоский массив для пагинатора
history_lines = []
if history:
for h in history:
h_from = h[0].strftime('%d.%m.%Y') if hasattr(h[0], 'strftime') else str(h[0])[:10]
h_to = h[1].strftime('%d.%m.%Y') if hasattr(h[1], 'strftime') else str(h[1])[:10]
h_days = int(h[2]) if h[2] else 0
if h[3] and str(h[3]).strip():
type_str = str(h[3]).strip()
else:
type_str = "Дополнительный" if h[4] == 1 else "Основной"
history_lines.append(f"{h_from}{h_to} : 🔹 <b>{h_days} дн.</b> <i>({type_str})</i>")
return static_header, history_lines
except Exception as e:
raise Exception(f"SQL Vacation Records Error: {e}")
def get_sick_leaves_text(card_id: str) -> str:
"""Запрашивает историю больничных листов строго по CARD_ID"""
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s", (card_id,))
row = cursor.fetchone()
if not row:
conn.close()
return "📑 <b>Ваши больничные листы:</b>\n\nСотрудник не найден в базе данных."
emp_id = row[0]
query = """
SELECT e.D_FROM, e.D_TO
FROM UOV_SELFSERVICE_PR_ILL e
WHERE e.EMP_ID = %s
ORDER BY e.D_FROM DESC
"""
cursor.execute(query, (emp_id,))
results = cursor.fetchall()
conn.close()
if not results:
return "📑 <b>Ваши больничные листы:</b>\n\nНичего не найдено."
response = "📑 <b>Ваши больничные листы:</b>\n\n"
for r in results:
d_from = r[0].strftime('%d.%m.%Y') if hasattr(r[0], 'strftime') else str(r[0])[:10]
d_to = r[1].strftime('%d.%m.%Y') if hasattr(r[1], 'strftime') else str(r[1])[:10]
days_count = ""
if hasattr(r[0], 'strftime') and hasattr(r[1], 'strftime'):
delta_days = (r[1] - r[0]).days + 1
days_count = f" — <b>{delta_days} дн.</b>"
response += f"🩺 <code>{d_from}</code> по <code>{d_to}</code>{days_count}\n"
return response
except Exception as e:
raise Exception(f"SQL Sick Leaves Error: {e}")
def get_missions_records(card_id: str) -> list:
"""Возвращает структурированный список pre-formatted текстовых блоков командировок"""
try:
conn = get_db_connection()
cursor = conn.cursor()
query = """
SELECT mp.D_FROM, mp.D_TO, CAST(mp.NAME AS VARCHAR(250)), CAST(mp.CITY AS VARCHAR(250))
FROM UOV_SELFSERVICE_PR_EMP e
JOIN UOV_SELFSERVICE_PR_MISSION m ON e.ID = m.EMP_ID
JOIN UOV_SELFSERVICE_PR_MISSION_P mp ON mp.MISSION_ID = m.ID
WHERE e.CARD_ID = %s
ORDER BY mp.D_FROM DESC
"""
cursor.execute(query, (card_id,))
results = cursor.fetchall()
conn.close()
mission_blocks = []
for r in results:
d_from = r[0].strftime('%d.%m.%Y') if hasattr(r[0], 'strftime') else str(r[0])[:10]
d_to = r[1].strftime('%d.%m.%Y') if hasattr(r[1], 'strftime') else str(r[1])[:10]
firm_name = str(r[2]).strip() if r[2] else "Организация не указана"
city = str(r[3]).strip() if r[3] else "Город не указан"
block = f"• 📅 <code>{d_from}</code> — <code>{d_to}</code>\n 📍 г. {city}, {firm_name}"
mission_blocks.append(block)
return mission_blocks
except Exception as e:
raise Exception(f"SQL Missions Records Error: {e}")
def get_transfers_records(card_id: str) -> list:
"""Возвращает структурированный список кадровых переходов сотрудника по его CARD_ID"""
try:
conn = get_db_connection()
cursor = conn.cursor()
query = """
SELECT
tr.D_FROM,
CASE
WHEN tr.D_TO = CAST('1999-12-31' AS DATE) THEN CAST('2099-12-31' AS DATE)
ELSE tr.D_TO
END as D_TO,
CAST(d.NAME AS VARCHAR(250)) as DEPT,
CAST(a.NAME AS VARCHAR(250)) as APPOINT
FROM UOV_SELFSERVICE_PR_EMP e
JOIN UOV_SELFSERVICE_PR_TRANS tr ON tr.EMP_ID = e.ID
JOIN UOV_SELFSERVICE_HR_DEPT d ON d.ID = tr.DEPT_ID
JOIN UOV_SELFSERVICE_ST_APPOINT a ON a.ID = tr.APPOINT_ID
WHERE e.CARD_ID = %s
ORDER BY tr.D_FROM DESC
"""
cursor.execute(query, (card_id,))
results = cursor.fetchall()
conn.close()
transfer_blocks = []
for r in results:
d_from = r[0].strftime('%d.%m.%Y') if hasattr(r[0], 'strftime') else str(r[0])[:10]
if r[1] and (r[1].year == 2099 or r[1].year == 1999):
d_to = "наст. время"
else:
d_to = r[1].strftime('%d.%m.%Y') if hasattr(r[1], 'strftime') else str(r[1])[:10]
dept = str(r[2]).strip() if r[2] else "Не указан"
appoint = str(r[3]).strip() if r[3] else "Не указана"
block = f"• ⏳ <b>{d_from}{d_to}</b>\n 💼 <b>Должность:</b> {appoint}\n 📂 <b>Отдел:</b> {dept}"
transfer_blocks.append(block)
return transfer_blocks
except Exception as e:
raise Exception(f"SQL Transfers Records Error: {e}")
def get_rewards_text(card_id: str) -> str:
"""Запрашивает историю наград по CARD_ID"""
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT e.D, CAST(e.NAME AS VARCHAR(1000))
FROM UOV_SELFSERVICE_PR_REW_ENC e
WHERE e.CARD_ID = %s
ORDER BY e.D
""", (card_id,))
results = cursor.fetchall()
conn.close()
if not results:
return "🏆 <b>Ваши награды и поощрения:</b>\n\nНичего не найдено."
response = "🏆 <b>Ваши награды и поощрения:</b>\n\n"
for r in results:
date_str = r[0].strftime('%d.%m.%Y') if hasattr(r[0], 'strftime') else str(r[0])[:10]
response += f"🏅 <b>{date_str}</b> — {str(r[1]).strip()}\n"
return response
except Exception as e:
raise Exception(f"SQL Rewards Error: {e}")
def get_tabel_report_text(card_id: str, year: int, month: int) -> str:
"""Формирует отчет по табелю из UOV_SELFSERVICE_TB_TABEL на основе CARD_ID"""
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s", (card_id,))
row = cursor.fetchone()
if not row:
conn.close()
return "📋 <b>Рабочий табель:</b>\n\nСотрудник не найден в базе данных."
emp_id = row[0]
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
"""
cursor.execute(query, (emp_id, year, month))
records = cursor.fetchall()
conn.close()
if not records:
return f"📋 <b>Рабочий табель за {month:02d}.{year} г.</b>\n\nДанные отсутствуют."
tabel_map = {r[0].day if hasattr(r[0], 'day') else int(str(r[0])[-2:]): (str(r[1]).strip(), float(r[2]) if r[2] is not None else 0.0) for r in records}
total_days = 0
total_hours = 0.0
for code, hours in tabel_map.values():
if code == 'Я':
total_days += 1
total_hours += hours
months_names = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
report = (
f"📅 <b>Рабочий табель за {months_names.get(month, 'Указанный месяц')} {year} г.</b>\n\n"
f"📊 <b>Итого за месяц:</b>\n"
f"• Отработано дней: <b>{total_days}</b>\n"
f"• Всего часов: <b>{total_hours:.1f} ч.</b>\n"
f"• Переработки / Недоработки: <b>0 ч.</b>\n"
)
days_in_month = calendar.monthrange(year, month)[1]
weekday_ru = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
current_week_days = []
week_counter = 1
for day in range(1, days_in_month + 1):
cur_date = datetime(year, month, day)
wday_idx = cur_date.weekday()
code, hours = tabel_map.get(day, ('В', 0.0))
current_week_days.append((day, weekday_ru[wday_idx], code, hours))
if wday_idx == 6 or day == days_in_month:
w_start = f"{current_week_days[0][0]:02d}.{month:02d}"
w_end = f"{current_week_days[-1][0]:02d}.{month:02d}"
report += f"\n🔹 <b>Неделя {week_counter} ({w_start}{w_end}):</b>\n"
idx = 0
while idx < len(current_week_days):
d_num, d_name, c_code, c_hours = current_week_days[idx]
if c_code == 'Я' and c_hours == 8.2:
run_start = d_name
run_end = d_name
count = 1
while idx + 1 < len(current_week_days) and current_week_days[idx+1][2] == 'Я' and current_week_days[idx+1][3] == 8.2:
idx += 1
run_end = current_week_days[idx][1]
count += 1
range_str = f"{run_start} - {run_end}" if count > 1 else f"{run_start}"
report += f"{range_str}: 🛠 <b>Явка</b> ({count} дн. по 8.2 ч.)\n"
elif c_code == 'В' and c_hours == 0.0:
run_start = d_name
run_end = d_name
count = 1
while idx + 1 < len(current_week_days) and current_week_days[idx+1][2] == 'В' and current_week_days[idx+1][3] == 0.0:
idx += 1
run_end = current_week_days[idx][1]
count += 1
range_str = f"{run_start} - {run_end}" if count > 1 else f"{run_start}"
report += f"{range_str}: 🏖 <b>Выходные</b> (0 ч.)\n"
else:
emoji = "🛠" if c_code == 'Я' else "" if c_code in ['Б', 'БЛ'] else "🏖"
label = "Явка" if c_code == 'Я' else "Больничный" if c_code in ['Б', 'БЛ'] else "Отпуск" if c_code == 'ОТ' else "Выходной"
report += f"{d_name}: {emoji} <b>{label}</b> ({c_hours} ч.)\n"
idx += 1
current_week_days = []
week_counter += 1
return report
except Exception as e:
raise Exception(f"SQL Tabel Parser Error: {e}")
+1
View File
@@ -0,0 +1 @@
[2026-07-08 02:23:21] User: ds.krivochenko@tcs.sibcem.ru | Action: Инициализация 2FA расчетного листа | Error: Не удалось извлечь номер телефона: Не удалось извлечь номер телефона из БД BossCopy: (208, b"Invalid object name 'PR_CARD'.DB-Lib error message 20018, severity 16:\nGeneral SQL Server error: Check messages from the SQL Server\n")
+322
View File
@@ -0,0 +1,322 @@
# /opt/trueconf_bot/lk/payslip_service.py
import calendar
import tempfile
from itertools import zip_longest
from weasyprint import HTML
import config.config as config
# Перевод английских системных месяцев в русские наименования для бланка PDF
ENG_TO_RU_MONTHS = {
"January": "Январь", "February": "Февраль", "March": "Март",
"April": "Апрель", "May": "Май", "June": "Июнь",
"July": "Июль", "August": "Август", "September": "Сентябрь",
"October": "Октябрь", "November": "Ноябрь", "December": "Декабрь"
}
def format_currency(summa):
try:
if summa is None:
return "0,00"
f_summa = float(summa)
return f"{f_summa:,.2f}".replace(",", " ").replace(".", ",")
except:
return str(summa)
def translate_period(period_str):
if not period_str:
return ""
for eng, ru in ENG_TO_RU_MONTHS.items():
if eng in period_str:
return period_str.replace(eng, ru)
return period_str
def get_latest_payslip_months(cursor, card_id: str) -> list:
query = """
SELECT DISTINCT TOP 6 (t.TYEAR * 12 + t.TMONTH) as CMONTH
FROM UOV_SELFSERVICE_PR_EMP e
JOIN UOV_SELFSERVICE_PR_CARD c on c.ID = e.CARD_ID
JOIN UOV_SELFSERVICE_SL_YTAX t on t.CARD_ID = e.CARD_ID
JOIN UOV_SELFSERVICE_PR_TRANS tr on tr.EMP_ID = e.ID
JOIN UOV_SELFSERVICE_ST_APPOINT a on a.ID = tr.APPOINT_ID
JOIN UOV_SELFSERVICE_HR_FIRM f on f.ID = e.FIRM_ID
WHERE c.ID = %s
ORDER BY CMONTH DESC
"""
cursor.execute(query, (card_id,))
months = cursor.fetchall()
if not months:
raise Exception("Отсутствуют расчетные периоды.")
return [m[0] for m in months]
def create_payslip_pdf(month_name, target_tyear,
header_sys, header_wage, header_deduct, header_base,
earnings_rows, deductions_rows, noncash_rows, payout_rows,
total_earned, total_withheld, total_noncash, total_payouts,
debt_comp_start, debt_emp_start, debt_comp_end, debt_emp_end, net_pay):
tbody_html = ""
tbody_html += f"""
<tr>
<td colspan="6" class="section-title">1. Начислено</td>
<td colspan="4" class="section-title">2. Удержано</td>
</tr>
"""
for earn, ded in zip_longest(earnings_rows, deductions_rows):
if earn:
earn_name = str(earn[0]).strip()
earn_days = f"{earn[1]:g}".replace('.', ',') if earn[1] else ""
earn_hours = f"{earn[2]:g}".replace('.', ',') if earn[2] else ""
earn_prc = f"{earn[3]:g}" if earn[3] else ""
earn_period = translate_period(earn[4] or "")
earn_summa = format_currency(earn[5])
earn_html = f'<td class="left-align">{earn_name}</td><td class="center-align">{earn_days}</td><td class="center-align">{earn_hours}</td><td class="center-align">{earn_prc}</td><td class="center-align">{earn_period}</td><td>{earn_summa}</td>'
else:
earn_html = '<td style="border-bottom: none; border-left: none;"></td><td style="border-bottom: none;"></td><td style="border-bottom: none;"></td><td style="border-bottom: none;"></td><td style="border-bottom: none;"></td><td style="border-bottom: none; border-right: none;"></td>'
if ded:
ded_name = str(ded[0]).strip()
ded_prc = f"{ded[1]:.0f}" if ded[1] else ""
ded_period = translate_period(ded[2] or "")
ded_summa = format_currency(ded[3])
ded_html = f'<td class="left-align">{ded_name}</td><td class="center-align">{ded_prc}</td><td class="center-align">{ded_period}</td><td>{ded_summa}</td>'
else:
ded_html = '<td style="border-bottom: none; border-left: none; border-right: none;" colspan="4"></td>'
tbody_html += f"<tr>{earn_html}{ded_html}</tr>"
tbody_html += f"""
<tr class="totals">
<td class="left-align" colspan="5">Всего начислено</td><td>{format_currency(total_earned)}</td>
<td class="left-align" colspan="3">Всего удержано</td><td>{format_currency(total_withheld)}</td>
</tr>
<tr>
<td colspan="6" class="section-title">3. Доходы в неденежной форме</td>
<td colspan="4" class="section-title">4. Выплачено</td>
</tr>
"""
for nc, pay in zip_longest(noncash_rows, payout_rows):
if nc:
nc_name = str(nc[0]).strip()
nc_period = translate_period(nc[1] or "")
nc_summa = format_currency(nc[2])
nc_html = f'<td class="left-align" colspan="4">{nc_name}</td><td class="center-align">{nc_period}</td><td>{nc_summa}</td>'
else:
nc_html = '<td style="border-top: none; border-bottom: none; border-left: none;"></td><td style="border-top: none; border-bottom: none;"></td><td style="border-top: none; border-bottom: none;"></td><td style="border-top: none; border-bottom: none;"></td><td style="border-top: none; border-bottom: none;"></td><td style="border-top: none; border-bottom: none; border-right: none;"></td>'
if pay:
pay_name = str(pay[0]).strip()
pay_period = translate_period(pay[1] or "")
pay_summa = format_currency(pay[2])
pay_html = f'<td class="left-align" colspan="2">{pay_name}</td><td class="center-align">{pay_period}</td><td>{pay_summa}</td>'
else:
pay_html = '<td style="border-top: none; border-bottom: none; border-left: none; border-right: none;" colspan="4"></td>'
tbody_html += f"<tr>{nc_html}{pay_html}</tr>"
tbody_html += f"""
<tr class="totals">
<td class="left-align" colspan="5">Всего доходов в неденежной форме</td><td>{format_currency(total_noncash)}</td>
<td class="left-align" colspan="3">Всего выплачено</td><td>{format_currency(total_payouts)}</td>
</tr>
<tr>
<td class="left-align" colspan="5">Долг за предприятием на начало месяца</td><td>{format_currency(debt_comp_start)}</td>
<td class="left-align" colspan="3">Долг за предприятием на конец месяца</td><td>{format_currency(debt_comp_end)}</td>
</tr>
<tr>
<td class="left-align" colspan="5">Долг за работником на начало месяца</td><td>{format_currency(debt_emp_start)}</td>
<td class="left-align" colspan="3">Долг за работником на конец месяца</td><td>{format_currency(debt_emp_end)}</td>
</tr>
<tr class="totals">
<td colspan="6" class="empty-cell"></td>
<td class="left-align section-title" colspan="3"><b>5. Выплачено денежными средствами</b></td><td class="section-title"><b>{format_currency(net_pay)}</b></td>
</tr>
"""
html_template = f"""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<style>
@page {{ size: A4 landscape; margin: 15mm; background-color: #ffffff; }}
body {{ font-family: Arial, sans-serif; font-size: 11px; color: #333; margin: 0; padding: 0; }}
h2 {{ text-align: center; margin-bottom: 15px; font-size: 16px; font-weight: normal; }}
table {{ width: 100%; border-collapse: collapse; margin-bottom: 10px; }}
th, td {{ border: 1px solid #a0a0a0; padding: 5px 8px; text-align: right; vertical-align: middle; }}
th {{ background-color: #f2f2f2; text-align: center; font-weight: normal; color: #555; }}
.header-table td {{ border: 1px solid #a0a0a0; text-align: left; background-color: #fcfcfc; }}
.left-align {{ text-align: left; }}
.center-align {{ text-align: center; }}
.section-title {{ background-color: #fcfcfc; font-weight: bold; text-align: left; padding-top: 10px; padding-bottom: 10px; }}
.totals {{ background-color: #fcfcfc; font-weight: bold; }}
.empty-cell {{ border: none; }}
</style>
</head>
<body>
<h2><b>Расчетный лист за {month_name} {target_tyear} г.</b></h2>
<table class="header-table" style="margin-bottom: 15px;">
<tr><td style="width: 60%;">Система оплаты: {header_sys}</td><td style="width: 40%;">Оклад: {format_currency(header_wage)}</td></tr>
<tr><td colspan="2">Стандартные вычеты за месяц: {format_currency(header_deduct)}</td></tr>
<tr><td colspan="2">Облагаемая база по ставке 13%: {format_currency(header_base)}</td></tr>
</table>
<table>
<thead>
<tr>
<th style="width: 20%;">Вид</th><th>Дни</th><th>Часы</th><th>%</th><th>Период</th><th>Сумма</th>
<th style="width: 20%;">Вид</th><th>%</th><th>Период</th><th>Сумма</th>
</tr>
</thead>
<tbody>
{tbody_html}
</tbody>
</table>
</body>
</html>
"""
temp_pdf = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
HTML(string=html_template).write_pdf(temp_pdf.name)
return temp_pdf.name
def build_payslip_data(cursor, card_id: str, target_cmonth: int) -> tuple:
tyear = (target_cmonth - 1) // 12
tmonth = (target_cmonth - 1) % 12 + 1
_, 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)
months_ru = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь",
7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
month_name = months_ru.get(tmonth, f"{tmonth:02d}")
# FIRM_ID по c.ID
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 not firm_row:
raise Exception("Не удалось определить FIRM_ID для сотрудника в указанном периоде.")
firm_id = firm_row[0]
# ШАПКА по c.ID
q_header = """
select s.NAME as [NAME], ROUND(t.WAGE, 2), isnull(sum(yt.u21 + yt.u30), 0), ROUND(isnull(sum(yt.n200 + yt.n5100 - yt.u21 - yt.u30 - yt.u42 - yt.u99), 0), 2)
from (select e.ID, e.FIRM_ID, e.CARD_ID from UOV_SELFSERVICE_PR_EMP e join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID
where c.ID = %s and e.FIRM_ID=%s group by e.ID, e.FIRM_ID, e.CARD_ID ) as e
join UOV_SELFSERVICE_pr_trans t on t.EMP_ID = e.ID join UOV_SELFSERVICE_hr_syspay s on s.ID = t.SYSPAY_ID
join UOV_SELFSERVICE_sl_ytax yt on yt.CARD_ID = e.CARD_ID and yt.FIRM_ID = e.FIRM_ID
where e.FIRM_ID = %s and yt.tmonth = %s and yt.tyear = %s
and ((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'))
and t.tp_abbr='CUR' group by s.NAME, t.WAGE, t.D_FROM order by t.D_FROM desc
"""
cursor.execute(q_header, (card_id, firm_id, firm_id, tmonth, tyear, *date_params))
h_row = cursor.fetchone()
header_sys = str(h_row[0]) if h_row else "Неизвестная система"
header_wage = float(h_row[1]) if h_row and h_row[1] else 0.0
header_deduct = float(h_row[2]) if h_row and h_row[2] else 0.0
header_base = float(h_row[3]) if h_row and h_row[3] else 0.0
# НАЧИСЛЕНО по c.ID
q_earn = """
select p.NAME AS [NAME], ROUND(l.days, 1), ROUND(l.hours, 1), CAST(l.prc as INT), convert(varchar, i.D_FROM,104)+'-'+convert(varchar, i.D_TO,104), ROUND(l.summa, 2)
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 and e.FIRM_ID=%s group by e.ID, FIRM_ID ) as e
join UOV_SELFSERVICE_sl_lic l on l.EMP_ID = e.ID join UOV_SELFSERVICE_sl_pay p on p.ID = l.PAY_ID join UOV_SELFSERVICE_pr_ill i on i.ID=l.ID_MAIN
join (select MAX(id) as id, EMP_ID from UOV_SELFSERVICE_pr_trans t 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 EMP_ID) tr on tr.EMP_ID = e.ID
where l.t_pay = 1 and l.pay_id not in (264,301,262) and l.n_dirm = 6 and l.cmonth = %s
UNION ALL
select max(p.NAME) AS [NAME], sum(l.days), ROUND(sum(l.hours), 1), CAST(l.prc as INT), (DateName(month, DateAdd(month, l.TMONTH, 0) - 1) +' '+ cast(l.TYEAR as varchar)), ROUND(isnull(sum(l.summa),0), 2)
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 and e.FIRM_ID=%s group by e.ID, FIRM_ID ) as e
join (select MAX(id) as id, EMP_ID from UOV_SELFSERVICE_pr_trans t 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 EMP_ID) tr on tr.EMP_ID = e.ID
join UOV_SELFSERVICE_sl_lic l on l.EMP_ID = e.ID join UOV_SELFSERVICE_sl_pay p on p.ID = l.PAY_ID
where l.t_pay = 1 and l.pay_id not in (264,301,262) and l.n_dirm <> 6 and l.cmonth = %s
group by l.emp_id,l.pay_id,l.tmonth,l.prc, case l.PAY_ID when 17 then l.id else 0 end, l.TYEAR order by 6 DESC
"""
cursor.execute(q_earn, (card_id, firm_id, *date_params, target_cmonth, card_id, firm_id, *date_params, target_cmonth))
earnings_rows = cursor.fetchall()
# УДЕРЖАНО по c.ID
q_deduct = """
select max(p.NAME) AS [NAME], CAST(l.prc as INT), ISNULL(DATENAME(month, DATEADD(month, l.tmonth, 0) - 1) + ' ' + CAST(NULLIF(l.TYEAR, 0) AS varchar), ''), ROUND(isnull(sum(l.summa),0), 2)
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 and e.FIRM_ID=%s group by e.ID, FIRM_ID ) as e
JOIN UOV_SELFSERVICE_sl_lic l ON l.EMP_ID = e.ID JOIN UOV_SELFSERVICE_sl_pay p ON p.ID = l.PAY_ID
join (select MAX(id) as id, EMP_ID from UOV_SELFSERVICE_pr_trans t 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 EMP_ID) tr on tr.EMP_ID = e.ID
where l.t_pay=2 and l.pay_id not in (301,490) and l.pay_id not in (305, 308, 316, 328, 360, 364) and l.cmonth = %s
group by l.emp_id,l.pay_id,l.tmonth,l.prc, l.id, l.TYEAR order by 4 DESC
"""
cursor.execute(q_deduct, (card_id, firm_id, *date_params, target_cmonth))
deductions_rows = cursor.fetchall()
# ДОЛГИ по c.ID
q_debts = """
select l.pay_id, ROUND(isnull(sum(l.summa),0), 2) 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 and e.FIRM_ID=%s group by e.ID, FIRM_ID ) as e
join UOV_SELFSERVICE_sl_lic l on l.EMP_ID = e.id join (select MAX(id) as id, EMP_ID from UOV_SELFSERVICE_pr_trans t 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 EMP_ID) tr on tr.EMP_ID = e.ID
where l.cmonth = %s and l.pay_id in (264, 301, 490) group by l.pay_id
"""
cursor.execute(q_debts, (card_id, firm_id, *date_params, target_cmonth))
debt_rows = cursor.fetchall()
debt_comp_start, debt_emp_start, debt_emp_end = 0.0, 0.0, 0.0
for r in debt_rows:
if r[0] == 264: debt_comp_start = float(r[1])
elif r[0] == 301: debt_emp_start = float(r[1])
elif r[0] == 490: debt_emp_end = float(r[1])
# НЕ ДЕНЕЖНЫЕ по c.ID
q_noncash = """
select t.NAME AS [NAME], (DateName(month, CAST(%s as date)) +' '+ DateName(YEAR, CAST(%s as date))), ROUND(isnull(p.summa, 0), 2)
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 and e.FIRM_ID=%s group by e.ID, FIRM_ID ) as e
join UOV_SELFSERVICE_sl_profit p on p.EMP_ID = e.ID join UOV_SELFSERVICE_sl_tprofit t on t.ID = p.TPROFIT_ID
join (select MAX(id) as id, EMP_ID from UOV_SELFSERVICE_pr_trans tr where ((tr.D_FROM <= %s and tr.D_TO >= %s) or (tr.D_FROM <= %s and tr.D_TO >= %s) or (tr.D_FROM <= %s and tr.D_TO = '1999-12-31')) group by EMP_ID) tr2 on tr2.EMP_ID = e.ID
where p.cmonth = %s and t.id != 9999
"""
cursor.execute(q_noncash, (date_start, date_start, card_id, firm_id, *date_params, target_cmonth))
noncash_rows = cursor.fetchall()
# ВЫПЛАЧЕНО по c.ID
q_payout = """
select p.NAME AS [NAME], (DateName(month, DateAdd(month, l.TMONTH, 0) - 1) +' '+ cast(l.TYEAR as varchar)), ROUND(sum(isnull(l.summa, 0)), 2)
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 and e.FIRM_ID=%s group by e.ID, FIRM_ID ) as e
join UOV_SELFSERVICE_sl_lic l on l.EMP_ID = e.ID join UOV_SELFSERVICE_sl_pay p on p.ID =l.PAY_ID
join (select MAX(id) as id, EMP_ID from UOV_SELFSERVICE_pr_trans tr where ((tr.D_FROM <= %s and tr.D_TO >= %s) or (tr.D_FROM <= %s and tr.D_TO >= %s) or (tr.D_FROM <= %s and tr.D_TO = '1999-12-31')) group by EMP_ID) tr2 on tr2.EMP_ID = e.ID
where l.t_pay = 2 and l.cmonth = %s and l.pay_id in (305, 308, 316, 328, 360, 364) group by p.NAME, l.TMONTH, l.TYEAR
"""
cursor.execute(q_payout, (card_id, firm_id, *date_params, target_cmonth))
payout_rows = cursor.fetchall()
total_earned = sum(float(r[5]) for r in earnings_rows if r[5])
total_withheld = sum(float(r[3]) for r in deductions_rows if r[3])
total_noncash = sum(float(r[2]) for r in noncash_rows if r[2])
total_payouts = sum(float(r[2]) for r in payout_rows if r[2])
# Расчет долга по формуле СибЦема
debt_comp_end = total_earned - total_withheld - total_payouts
net_pay = total_earned - total_withheld
response = f"💰 <b>Расчетный листок за {month_name} {tyear} г.</b>\n\n<b>1. Начислено</b>\n"
for row in earnings_rows:
time_spent = f" ({float(row[1]):g} дн. / {float(row[2]):g} ч.)" if (row[1] or row[2]) else ""
response += f"🔹 {str(row[0]).strip()}{time_spent}: <b>{format_currency(row[5])} руб.</b>\n"
response += f"<b>Всего начислено: {format_currency(total_earned)} руб.</b>\n\n<b>2. Удержано</b>\n"
for row in deductions_rows:
prc_str = f" ({int(row[1])}%)" if row[1] else ""
response += f"🔹 {str(row[0]).strip()}{prc_str}: <b>{format_currency(row[3])} руб.</b>\n"
response += f"<b>Всего удержано: {format_currency(total_withheld)} руб.</b>\n\n<b>3. Доходы в неденежной форме:</b> {format_currency(total_noncash)} руб.\n\n<b>4. Выплачено</b>\n"
for row in payout_rows:
response += f"🔹 {str(row[0]).strip()}: <b>{format_currency(row[2])} руб.</b>\n"
response += f"<b>Всего выплачено: {format_currency(total_payouts)} руб.</b>\n\n<i>Долг за предприятием на конец месяца: {format_currency(debt_comp_end)} руб.</i>\n<i>Долг за работником на конец месяца: {format_currency(debt_emp_end)} руб.</i>\n\n<b>5. Выплачено денежными средствами: {format_currency(net_pay)} руб.</b>"
pdf_path = create_payslip_pdf(
month_name, tyear, header_sys, header_wage, header_deduct, header_base,
earnings_rows, deductions_rows, noncash_rows, payout_rows,
total_earned, total_withheld, total_noncash, total_payouts,
debt_comp_start, debt_emp_start, debt_comp_end, debt_emp_end, net_pay
)
return response, pdf_path
+213
View File
@@ -0,0 +1,213 @@
import re
from datetime import datetime
import pymssql
from ldap3 import Server, Connection, ALL
from ldap3.utils.conv import escape_filter_chars
# =========================================================
# НАСТРОЙКИ
# =========================================================
TEST_USER = "man.bogov@tcs.sibcem.ru"
AD_SERVER = "ldap://172.16.20.20"
AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru"
AD_USER = "sdesk-mail"
AD_PASSWORD = "fne?e!q.m8phcrGVAcqr"
SQL_SERVER = "SRVKEM-MOBILEIN.sibcem.ru"
SQL_DB_NAME = "BossCopy"
DB_USER = "SIBCEM\bot_tcs"
DB_PASSWORD = "Bav:fX#UwH8%atv4"
# =========================================================
# AD
# =========================================================
def get_phone_from_ad(user_id: str) -> str:
server = Server(AD_SERVER, get_info=ALL)
conn = Connection(
server,
user=AD_USER,
password=AD_PASSWORD,
auto_bind=True
)
safe_user_id = escape_filter_chars(user_id)
short_username = user_id.split("@")[0]
search_filter = (
f"(&(objectClass=user)"
f"(|(mail={safe_user_id})"
f"(userPrincipalName={safe_user_id})"
f"(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})"
f"(sAMAccountName={escape_filter_chars(short_username)})))"
)
conn.search(
AD_BASE,
search_filter,
attributes=['mobile', 'telephoneNumber']
)
if not conn.entries:
raise Exception("Пользователь не найден в AD")
entry = conn.entries[0]
raw_phone = (
str(entry.mobile.value)
if 'mobile' in entry and entry.mobile.value
else str(entry.telephoneNumber.value)
if 'telephoneNumber' in entry and entry.telephoneNumber.value
else None
)
if not raw_phone:
raise Exception("Телефон не найден")
clean_phone = re.sub(r'\D', '', raw_phone)
if clean_phone.startswith("8") and len(clean_phone) == 11:
return "7" + clean_phone[1:]
if len(clean_phone) == 10:
return "7" + clean_phone
return clean_phone
# =========================================================
# SQL
# =========================================================
def get_db_connection():
return pymssql.connect(
server=SQL_SERVER,
user=DB_USER,
password=DB_PASSWORD,
database=SQL_DB_NAME,
charset='cp1251'
)
# =========================================================
# MAIN
# =========================================================
def main():
print("=" * 80)
print("ТЕСТ РАСЧЕТНЫХ ЛИСТКОВ")
print("=" * 80)
date = datetime.now().date()
cmonth = date.year * 12 + date.month
print(f"Дата: {date}")
print(f"CMONTH: {cmonth}")
# -----------------------------------------------------
print("\n[1] Получение телефона из AD")
phone_full = get_phone_from_ad(TEST_USER)
print(f"Телефон из AD: {phone_full}")
phone = phone_full[1:].strip()
print(f"Телефон для SQL: {phone}")
# -----------------------------------------------------
print("\n[2] Подключение к SQL")
conn = get_db_connection()
cursor = conn.cursor()
# -----------------------------------------------------
print("\n[3] Поиск PR_CARD")
query = """
SELECT
ID,
PHONE
FROM UOV_SELFSERVICE_PR_CARD
WHERE PHONE = %s
"""
print(query)
cursor.execute(query, (phone,))
rows = cursor.fetchall()
print(f"\nНайдено записей: {len(rows)}")
if rows:
for row in rows:
print("-" * 60)
print(f"ID: {row[0]}")
print(f"PHONE: {row[1]}")
else:
print("\n❌ Запись не найдена")
print("\n[4] Поиск похожих телефонов")
query2 = """
SELECT TOP 20
ID,
PHONE
FROM UOV_SELFSERVICE_PR_CARD
WHERE
PHONE LIKE %s
OR RIGHT(RTRIM(PHONE), 10) = %s
"""
phone_tail = phone[-10:]
cursor.execute(
query2,
(
f"%{phone_tail}%",
phone_tail
)
)
rows2 = cursor.fetchall()
print(f"\nПохожих записей: {len(rows2)}")
for row in rows2:
print("-" * 60)
print(f"ID: {row[0]}")
print(f"PHONE: {row[1]}")
conn.close()
print("\n")
print("=" * 80)
print("ГОТОВО")
print("=" * 80)
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
# /opt/trueconf_bot/test_secure_connect.py
import sys
import os
import pymssql
# Добавляем путь к проекту для корректных импортов конфигурации
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
print("🔄 Загружаем конфигурацию и выгружаем секреты из Passwork...")
import config.config as config
except ImportError as e:
print(f"❌ Ошибка импорта config.py: {e}")
sys.exit(1)
def main():
# Технические параметры целевого сервера
SERVER = '172.16.20.203'
DATABASE = 'iskmdax_model'
# Извлекаем данные из автоматически загруженного пула
USER = getattr(config, 'DB_USER', None)
PASSWORD = getattr(config, 'DB_PASSWORD', None)
if not USER or not PASSWORD:
print("❌ Ошибка: Учетные данные для подключения к БД не найдены в Passwork!")
sys.exit(1)
print(f"🔌 Устанавливаем ЗАЩИЩЕННОЕ соединение (encryption='require')...")
print(f" Сервер: {SERVER}")
print(f" База: {DATABASE}")
print(f" Логин: [ДАННЫЕ СКРЫТЫ]")
print("-" * 60)
try:
conn = pymssql.connect(
server=SERVER,
user=USER,
password=PASSWORD,
database=DATABASE,
charset='cp1251',
encryption='require' # Строгое требование SSL/TLS
)
cursor = conn.cursor()
print("✅ Защищенное TLS/SSL соединение с сервером успешно установлено!")
# Проверочный SQL-запрос
query = "SELECT TOP(5) * FROM dbo.Layer;"
print(f"🏃 Выполняем тестовый запрос: {query}\n")
cursor.execute(query)
rows = cursor.fetchall()
if rows:
print(f"🎯 Успешно получено строк: {len(rows)}")
for idx, row in enumerate(rows, 1):
# Печатаем содержимое строки как есть
print(f" 🔹 Строка #{idx}: {row}")
else:
print("📭 Запрос выполнен успешно, но целевая таблица оказалась пустой.")
conn.close()
print("\n🏁 Тест защищенного соединения завершен успешно!")
except Exception as e:
print(f"\n❌ Ошибка подключения или выполнения запроса!")
error_msg = str(e)
if "Login failed" in error_msg or "18456" in error_msg:
print("Детали: Ошибка аутентификации (проверьте корректность учетных данных в Passwork).")
else:
print(f"Детали: {error_msg}")
sys.exit(1)
if __name__ == "__main__":
main()