diff --git a/config.py b/config.py index c2459d3..ee4ae3a 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,8 @@ SCUD_DIR = os.path.join(DATA_DIR, "scud") ZUP_1C_DIR = os.path.join(DATA_DIR, "1c") OUTPUT_DIR = os.path.join(BASE_DIR, "output") -SHARE_1C_DIR = r"\\storage\SCUD\Обмен\Штат" +# Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат) +SHARE_1C_DIR = "/mnt/scud_share" for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]: os.makedirs(folder, exist_ok=True) diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index e225f1b..f0d7385 100644 Binary files a/data/scud_orion_ai.db and b/data/scud_orion_ai.db differ diff --git a/main.py b/main.py index e6d25b9..e03dac9 100644 --- a/main.py +++ b/main.py @@ -106,7 +106,6 @@ def main(): args = parser.parse_args() DEBUG = args.debug - SAVE_XLSX = args.with_xlsx print("=" * 60) print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}") @@ -117,16 +116,16 @@ def main(): kb_data = load_knowledge_base() kb_rules = kb_data.get("rules", []) - # 0.1 Выгрузка из БД СКУД напрямую в SQLite + # 0.1 Выгрузка из БД СКУД напрямую в SQLite и Excel (save_xlsx=True требуется для data_loader) if not args.skip_export: try: - run_export(save_xlsx=SAVE_XLSX, debug=DEBUG) + run_export(save_xlsx=True, debug=DEBUG) except Exception as e: print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Используем имеющиеся записи в SQLite.") else: print("[0/5] Пропуск прямого экспорта из БД (--skip-export)...") - # 0.5 Копирование файлов 1С с сетевой шары \\storage\SCUD\Обмен\Штат + # 0.5 Копирование файлов 1С с сетевой шары /mnt/scud_share copy_1c_files_from_share() # 1. Валидация наличия файлов и данных @@ -296,9 +295,6 @@ def main(): f.write(report_text) print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}") - # 🎯 ОПРОС ОПЕРАТОРА (HUMAN-IN-THE-LOOP): - # Доказанные нарушения (приход во время отпуска/больничного в 1С) записываются автоматически. - # Запрос оператору выводится ТОЛЬКО при спорных/нетипичных аномалиях. suspicious_cases = [] for a in anomalies_list: if "ОФИЦИАЛЬНОМ ОТСУТСТВИИ" not in a.get('type', ''): diff --git a/modules/scud_export.py b/modules/scud_export.py index 9aee0a4..7b726ae 100644 --- a/modules/scud_export.py +++ b/modules/scud_export.py @@ -1,6 +1,7 @@ """ Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel. Исправлена логика определения направления проходов (Mode = 2 -> OUT). +Адаптирован для Linux (Ubuntu 24.04). """ import argparse @@ -56,7 +57,7 @@ SERVER_NAME = r"172.16.31.221\SQL" DATABASE_NAME = "Orion-14.01.21-1" SQL_USER = "sa" SQL_PASSWORD = "123456" -ODBC_DRIVER = "SQL Server Native Client 10.0" +ODBC_DRIVER = "ODBC Driver 18 for SQL Server" SQL_QUERY_TEMPLATE = r""" DECLARE @InputDate DATE = '{target_date}'; diff --git a/modules/zup_extractor.py b/modules/zup_extractor.py new file mode 100644 index 0000000..1aa9ff5 --- /dev/null +++ b/modules/zup_extractor.py @@ -0,0 +1,161 @@ +import os +import logging +from datetime import date +import pyodbc +import pandas as pd +from config import DATA_DIR, normalize_fio + +logger = logging.getLogger(__name__) + +# Конфигурация MS SQL Server 1С:ЗУП 3.1 +ZUP_SQL_CONFIG = { + "driver": "{ODBC Driver 18 for SQL Server}", + "server": os.getenv("ZUP_MS_SQL_SERVER", "192.168.1.10"), # Укажите IP MS SQL 1С + "database": os.getenv("ZUP_MS_SQL_DB", "zup_db"), + "user": os.getenv("ZUP_MS_SQL_USER", "sa"), + "password": os.getenv("ZUP_MS_SQL_PASS", "your_password"), +} + + +def get_zup_connection_string() -> str: + """Формирует строку подключения pyodbc к MS SQL Server.""" + return ( + f"DRIVER={ZUP_SQL_CONFIG['driver']};" + f"SERVER={ZUP_SQL_CONFIG['server']};" + f"DATABASE={ZUP_SQL_CONFIG['database']};" + f"UID={ZUP_SQL_CONFIG['user']};" + f"PWD={ZUP_SQL_CONFIG['password']};" + f"TrustServerCertificate=yes;" + f"Encrypt=no;" + ) + + +def fetch_zup_absences_from_sql(target_date: date) -> pd.DataFrame: + """ + Извлекает оперативные отсутствия и действующие декреты из MS SQL 1С:ЗУП 3.1 + на указанную дату. + """ + query = """ + DECLARE @TargetDate DATE = ?; + + -- 1. Оперативные отсутствия (Отпуска, Командировки, Больничные, Отгулы) + SELECT + LTRIM(RTRIM(ref_emp._Description)) AS [ФИО], + CASE state._Fld16925RRef + WHEN 0x9C10B2452D414FDF4A90E2B2AB81D3F7 THEN N'Отпуск основной' + WHEN 0xBA63FCF94B4AD0664ED369D2E6505D67 THEN N'Командировка' + WHEN 0x8C3B61F23954155A40EB0108FC0932DB THEN N'Болезнь' + WHEN 0x853001C18D0965EE4B2702405C94054A THEN N'Отпуск неоплачиваемый по разрешению работодателя' + WHEN 0xB7335AEFD8708C3E462861FC59489A38 THEN N'Отпуск по беременности и родам' + ELSE N'Другое отсутствие' + END AS [Вид_отсутствия] + + FROM dbo._InfoRg16921 state WITH (NOLOCK) + INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK) + ON state._Fld16922RRef = ref_emp._IDRRef + + WHERE @TargetDate BETWEEN CAST(CASE WHEN YEAR(state._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16926) ELSE state._Fld16926 END AS DATE) + AND CAST(CASE WHEN YEAR(state._Fld16927) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16927) ELSE state._Fld16927 END AS DATE) + + UNION ALL + + -- 2. Динамический выбор ДЕЙСТВУЮЩИХ декретниц по уходу за ребенком + SELECT + active_state.fio AS [ФИО], + N'Отпуск по уходу за ребенком' AS [Вид_отсутствия] + + FROM ( + SELECT + LTRIM(RTRIM(ref_emp._Description)) AS fio, + all_states._Fld16925RRef AS state_guid, + CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) AS date_start, + ROW_NUMBER() OVER ( + PARTITION BY all_states._Fld16922RRef + ORDER BY all_states._Fld16926 DESC + ) AS rn + FROM dbo._InfoRg16921 all_states WITH (NOLOCK) + INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK) + ON all_states._Fld16922RRef = ref_emp._IDRRef + WHERE CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) <= @TargetDate + ) active_state + + WHERE active_state.rn = 1 + AND active_state.state_guid = 0xA4FBA038663B3C2A48DA151C262855E1 + AND active_state.date_start >= DATEADD(YEAR, -3, @TargetDate) + + ORDER BY [ФИО] ASC; + """ + + conn_str = get_zup_connection_string() + with pyodbc.connect(conn_str) as conn: + df = pd.read_sql(query, conn, params=[target_date]) + + if not df.empty: + df['fio_clean'] = df['ФИО'].apply(normalize_fio) + return df + + +def fetch_zup_staff_from_sql() -> pd.DataFrame: + """Извлекает весь актуальный штат из MS SQL 1С:ЗУП.""" + query = """ + SELECT DISTINCT + LTRIM(RTRIM(ref_emp._Description)) AS [ФИО], + N'Организация' AS [Подразделение], + N'Сотрудник' AS [Должность] + FROM dbo._Reference299 ref_emp WITH (NOLOCK) + WHERE ref_emp._Description <> '' + AND ref_emp._Marked = 0x00 + ORDER BY [ФИО] ASC; + """ + conn_str = get_zup_connection_string() + with pyodbc.connect(conn_str) as conn: + df = pd.read_sql(query, conn) + + if not df.empty: + df['fio_clean'] = df['ФИО'].apply(normalize_fio) + return df + + +def sync_zup_to_excel(target_date: date) -> bool: + """ + Извлекает данные из MS SQL и автоматически создает локальные Excel-файлы 1С: + data/1c/Штат_DD_MM_YYYY.xlsx + data/1c/Отсутствия_DD_MM_YYYY.xlsx + """ + try: + date_str_file = target_date.strftime("%d_%m_%Y") + c_1c_dir = os.path.join(DATA_DIR, "1c") + os.makedirs(c_1c_dir, exist_ok=True) + + df_staff = fetch_zup_staff_from_sql() + df_absences = fetch_zup_absences_from_sql(target_date) + + logger.info(f"[1С Direct SQL] Выгрузка за {target_date.strftime('%d.%m.%Y')}: Штат ({len(df_staff)} чел.), Отсутствия ({len(df_absences)} чел.)") + + # Сохранение в формате, который ожидает data_loader.py + staff_excel_path = os.path.join(c_1c_dir, f"Штат_{date_str_file}.xlsx") + absences_excel_path = os.path.join(c_1c_dir, f"Отсутствия_{date_str_file}.xlsx") + + # Формируем структуру Excel с нужным количеством пропущенных строк (skiprows) + with pd.ExcelWriter(staff_excel_path, engine='openpyxl') as writer: + # Пустой заголовок (8 строк) для эмуляции отчета 1С + dummy_headers = pd.DataFrame([[""] * 13] * 8) + dummy_headers.to_excel(writer, index=False, header=False) + + # Данные + df_staff[['ФИО', 'Подразделение', 'Должность']].to_excel(writer, startrow=8, index=False) + + with pd.ExcelWriter(absences_excel_path, engine='openpyxl') as writer: + # Пустой заголовок (3 строки) для эмуляции отчета 1С + dummy_headers = pd.DataFrame([[""] * 3] * 3) + dummy_headers.to_excel(writer, index=False, header=False) + + # Данные + df_absences[['ФИО', 'Вид_отсутствия']].to_excel(writer, startrow=3, index=False) + + logger.info(f"[✓] Успешно созданы файлы 1С за {target_date.strftime('%d.%m.%Y')}: {staff_excel_path}, {absences_excel_path}") + return True + + except Exception as e: + logger.error(f"[❌] Ошибка прямого импорта из MS SQL 1С: {e}") + return False \ No newline at end of file