""" Скрипт экспорта данных СКУД (Orion) в Excel. Оптимизированная супер-быстрая версия (использует ROW_NUMBER() вместо подзапросов). Запуск: python scud_export.py python scud_export.py -d python scud_export.py --date 28.07.2026 -d """ import argparse import logging import os import sys import warnings from datetime import datetime, timedelta import pandas as pd import pyodbc from openpyxl.utils import get_column_letter warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable") # ============================================================================== # 1. Настройки путей и логов # ============================================================================== SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) LOG_DIR = os.path.join(SCRIPT_DIR, "logs") os.makedirs(LOG_DIR, exist_ok=True) TODAY_DATE_STR = datetime.now().strftime("%d.%m.%Y") LOG_FILE = os.path.join(LOG_DIR, f"export_{TODAY_DATE_STR}.log") logger = logging.getLogger("scud_export") logger.setLevel(logging.INFO) _file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") _console_handler = logging.StreamHandler(sys.stdout) _formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S") _file_handler.setFormatter(_formatter) _console_handler.setFormatter(_formatter) logger.addHandler(_file_handler) logger.addHandler(_console_handler) def log(message: str, level: str = "INFO"): level_map = { "INFO": logging.INFO, "ERROR": logging.ERROR, "WARNING": logging.WARNING, "SUCCESS": logging.INFO, } if level == "SUCCESS": logger.info(f"[SUCCESS] {message}") else: logger.log(level_map.get(level, logging.INFO), message) # ============================================================================== # 2. Настройки базы данных и папки # ============================================================================== SERVER_NAME = r"192.168.1.253\SQL" DATABASE_NAME = "Orion-14.01.21-1" SQL_USER = "sa" SQL_PASSWORD = "123456" FOLDER_PATH = r"C:\scud_exchsnge" ODBC_DRIVER = "SQL Server Native Client 10.0" # ============================================================================== # 3. СУПЕР-ОПТИМИЗИРОВАННЫЙ SQL-ЗАПРОС (Мгновенное выполнение) # ============================================================================== SQL_QUERY_TEMPLATE = r""" DECLARE @InputDate DATE = '{target_date}'; DECLARE @TargetDate DATE = @InputDate; DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME); DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate)); -- 1. Фильтруем логи только за нужные 24 часа за один проход WITH DailyLogs AS ( SELECT log.HozOrgan AS EmployeeID, log.TimeVal, log.Event, log.Mode, CASE WHEN log.Event IN (28, 26, 32) OR log.Mode = 1 THEN 'IN' WHEN log.Event IN (29, 27, 33) OR log.Mode = 2 THEN 'OUT' ELSE 'OTHER' END AS Direction, ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc FROM pLogData log WITH (NOLOCK) WHERE log.TimeVal BETWEEN @StartDate AND @EndDate AND log.HozOrgan IS NOT NULL AND log.HozOrgan > 0 AND (log.Event IN (28, 29, 26, 27, 32, 33) OR log.Mode IN (1, 2)) ), -- 2. Агрегируем времена входа/выхода и определяем последнее событие Passages AS ( SELECT EmployeeID, MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn, MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut, MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType FROM DailyLogs GROUP BY EmployeeID ) SELECT N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма], ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение], LTRIM(RTRIM( ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') + CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> '' THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END + CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> '' THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END )) AS [Сотрудник], ISNULL(CAST(post.Name AS NVARCHAR(255)), N'—') AS [Должность], ISNULL(CAST(p.TabNumber AS NVARCHAR(50)), N'—') AS [Таб_№], CONVERT(VARCHAR(10), @TargetDate, 104) AS [Дата], ISNULL(CAST(CONVERT(VARCHAR(8), pass.FirstIn, 108) AS NVARCHAR(20)), N'Нет входа') AS [Начало_дня], CASE WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN N'Нет выхода' WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20)) ELSE N'Нет выхода' END AS [Конец_дня], CASE WHEN pass.EmployeeID IS NOT NULL AND pass.FirstIn IS NOT NULL THEN RIGHT('0' + CAST(DATEDIFF(MINUTE, pass.FirstIn, CASE WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() ELSE ISNULL(pass.LastOut, pass.FirstIn) END) / 60 AS VARCHAR), 2) + ':' + RIGHT('0' + CAST(DATEDIFF(MINUTE, pass.FirstIn, CASE WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() ELSE ISNULL(pass.LastOut, pass.FirstIn) END) % 60 AS VARCHAR), 2) ELSE N'00:00' END AS [Находился_в_здании], CASE WHEN pass.EmployeeID IS NOT NULL THEN N'Присутствовал' ELSE N'Отсутствовал (Нет событий)' END AS [Статус] FROM pList p WITH (NOLOCK) LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID LEFT JOIN Passages pass ON p.ID = pass.EmployeeID WHERE ISNULL(p.StatusRecord, 0) = 0 AND p.DateTimeInArchive IS NULL AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%' AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'') AND p.Name NOT LIKE N'бр.%' AND p.Name NOT LIKE N'Гость%' AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР') AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%' AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%' AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%' AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%' AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%' ORDER BY p.Name ASC; """ # ============================================================================== # 4. Вспомогательные функции # ============================================================================== def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0): from openpyxl import load_workbook wb = load_workbook(file_path) ws = wb[sheet_name] for col_cells in ws.columns: max_len = 0 col_letter = get_column_letter(col_cells[0].column) for cell in col_cells: if cell.value is not None: cell_len = len(str(cell.value)) if cell_len > max_len: max_len = cell_len width = max(min_width, min(max_len + padding, max_width)) ws.column_dimensions[col_letter].width = width wb.save(file_path) def get_targets(input_date: str | None): targets = [] if input_date: try: parsed = datetime.strptime(input_date, "%d.%m.%Y").date() targets.append({"name": "Указанная дата", "date": parsed}) log(f"Запущен ручной режим для даты: {input_date}") except ValueError: log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR") sys.exit(1) else: now = datetime.now() yesterday = (now - timedelta(days=3 if now.weekday() == 0 else 1)).date() today = now.date() targets.append({"name": "Вчера", "date": yesterday}) targets.append({"name": "Сегодня", "date": today}) return targets # ============================================================================== # 5. Основная функция выгрузки # ============================================================================== def run_export(input_date: str | None, debug: bool = False): if debug: logger.setLevel(logging.DEBUG) log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING") log("=== Запуск скрипта экспорта СКУД ===") os.makedirs(FOLDER_PATH, exist_ok=True) targets = get_targets(input_date) conn_str = ( f"DRIVER={{{ODBC_DRIVER}}};" f"SERVER={SERVER_NAME};" f"DATABASE={DATABASE_NAME};" f"UID={SQL_USER};" f"PWD={SQL_PASSWORD};" f"TrustServerCertificate=yes;" f"Encrypt=no;" ) for target in targets: processing_date = target["date"] processing_date_str = processing_date.strftime("%d.%m.%Y") period_label = target["name"] log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---") file_name = f"Сотрудники_{processing_date_str}.xlsx" file_path = os.path.join(FOLDER_PATH, file_name) if os.path.exists(file_path): log(f"Файл {file_name} существует. Перезаписываем...") try: os.remove(file_path) except OSError as e: log(f"ОШИБКА при удалении файла {file_name}: {e}", "ERROR") continue sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d")) connection = None try: connection = pyodbc.connect(conn_str, timeout=120) df = pd.read_sql(sql_query, connection) log(f"База вернула {len(df)} строк за {processing_date_str}") if debug: present_df = df[df["Начало_дня"] != "Нет входа"] still_inside_df = df[(df["Начало_дня"] != "Нет входа") & (df["Конец_дня"] == "Нет выхода")] left_df = df[(df["Начало_дня"] != "Нет входа") & (df["Конец_дня"] != "Нет выхода")] print("\n" + "=" * 50) print(f"[DEBUG STATS] Дата: {processing_date_str}") print(f" Всего активных сотрудников в БД: {len(df)}") print(f" Присутствуют / были сегодня: {len(present_df)}") print(f" Физически находятся в здании: {len(still_inside_df)} (Конец_дня = 'Нет выхода')") print(f" Завершили рабочий день (Ушли): {len(left_df)}") if not still_inside_df.empty: print("\n Первые 5 сотрудников, находящихся сейчас в здании:") for _, r in still_inside_df.head(5).iterrows(): print(f" • {r['Сотрудник']} | Вход: {r['Начало_дня']} | В здании: {r['Находился_в_здании']}") print("=" * 50 + "\n") if len(df) > 0: df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl") auto_fit_columns(file_path, sheet_name="Отчет") log(f"Успешно создан файл {file_path}", "SUCCESS") except Exception as e: log(f"ОШИБКА при обработке даты {processing_date_str}: {e}", "ERROR") finally: if connection is not None: connection.close() log("=== Завершение работы скрипта ===") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Экспорт данных СКУД в Excel") parser.add_argument( "--date", dest="input_date", default=None, help='Дата в формате ДД.ММ.ГГГГ (например, "28.07.2026")', ) parser.add_argument( "-d", "--debug", action="store_true", help="Запуск в режиме отладки (вывод детальной статистики)", ) args = parser.parse_args() run_export(args.input_date, debug=args.debug)