From e8a542eded602256e9a4d0873954a2cdedd99366 Mon Sep 17 00:00:00 2001 From: manoraga Date: Fri, 21 Aug 2026 10:12:26 +0300 Subject: [PATCH] refactor: step 3 - decompose ETL pipeline into modular services/scud_etl package --- data/scud_orion_ai.db | Bin 7520256 -> 7520256 bytes main_etl.py | 568 ++------------------------ services/scud_etl/__init__.py | 0 services/scud_etl/anomaly_detector.py | 72 ++++ services/scud_etl/merger.py | 114 ++++++ services/scud_etl/pipeline.py | 139 +++++++ services/scud_etl/sql_queries.py | 116 ++++++ 7 files changed, 486 insertions(+), 523 deletions(-) create mode 100644 services/scud_etl/__init__.py create mode 100644 services/scud_etl/anomaly_detector.py create mode 100644 services/scud_etl/merger.py create mode 100644 services/scud_etl/pipeline.py create mode 100644 services/scud_etl/sql_queries.py diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index ae356bfdf8ba896e8802764937784ad8314434f4..a78f4a475bdcfdf5bb40f0865431b5c005a3e8da 100644 GIT binary patch delta 38506 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 delta 37705 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 Q;_rU^?Iry?*K5cB9}xCWk^lez diff --git a/main_etl.py b/main_etl.py index 3a1b6f5..e6bb2be 100644 --- a/main_etl.py +++ b/main_etl.py @@ -1,248 +1,67 @@ -import os +""" +=============================================================================== +FILE: main_etl.py +PROJECT: SCUD Orion AI (Unified Architecture) +ROLE: Главная CLI-точка входа для запуска ежедневного контроллинга СКУД ⟷ 1С. + +AI-CONTEXT-ANCHORS: + - ANCHOR[MAIN_CLI_ENTRY]: Парсинг CLI-флагов и запуск конвейера. +=============================================================================== +""" + import sys -import json import argparse -import pandas as pd -from datetime import datetime, timedelta - -sys.path.append(os.path.dirname(os.path.abspath(__file__))) - -from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio -from services.scud_export import run_export +from datetime import datetime +from core.database import init_db, get_latest_snapshot_time from services.share_copier import copy_1c_files_from_share from services.data_validator import check_file_freshness -from services.data_loader import load_1c_data_smart, load_scud_data -from services.excel_exporter import generate_summary_excel, generate_detailed_excel -from services.ai_verifier import ai_verify_scud_against_staff, analyze_scud_mass_failure_ai -from services.text_reporter import generate_markdown_report -from services.feedback_loop import review_ai_decisions -from services.knowledge_base import load_knowledge_base -from core.database import ( - init_db, - save_scud_to_db, - save_staff_to_db, - save_absences_to_db, - save_anomalies_to_db, - load_scud_from_db_by_snapshot, - load_staff_from_db, - load_absences_from_db, - get_latest_snapshot_time, - has_scud_logs_for_date -) - - -def load_exceptions_config(): - """Загружает файл exceptions.json из корня проекта.""" - root_dir = os.path.dirname(os.path.abspath(__file__)) - json_path = os.path.join(root_dir, "exceptions.json") - if not os.path.exists(json_path): - return {} - try: - with open(json_path, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception as e: - print(f"[⚠️] Ошибка чтения exceptions.json: {e}") - return {} - - -def apply_exceptions_from_json(df, exceptions_cfg): - """ - Быстрая и эффективная разметка флага is_excluded=True на основе exceptions.json. - Выполняется мгновенно без цикличных HTTP-запросов к ИИ. - """ - if df is None or df.empty or not exceptions_cfg: - if df is not None: - df['is_excluded'] = False - return df - - deps = [d.strip().lower() for d in exceptions_cfg.get("departments", []) if d] - exact_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p] - pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k] - exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f] - - df['is_excluded'] = False - - for idx, row in df.iterrows(): - fio_clean = row.get('fio_clean', '') - dep_1c = str(row.get('Подразделение', '')).lower() - dep_scud = str(row.get('department_scud', row.get('department', ''))).lower() - pos = str(row.get('Должность', '')).lower() - - is_fio_exc = fio_clean in exc_fios - is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw if k) if pos else False - - # Быстрая проверка отделов по подстрокам - is_dep_exc = False - if deps: - is_dep_exc = any(d in dep_1c or d in dep_scud for d in deps) - - if is_fio_exc or is_dep_exc or is_pos_exc: - df.at[idx, 'is_excluded'] = True - - return df - - -def filter_report_dataframe(merged_df): - """ - Исключает сотрудников из списка исключений и сотрудников без пропуска из детального отчета, - ЕСЛИ у них нет официального документа отсутствия из 1С:ЗУП. - """ - if merged_df is None or merged_df.empty: - return merged_df - - has_1c_reason = ( - merged_df['Вид_отсутствия'].notna() & - (merged_df['Вид_отсутствия'].astype(str).str.strip() != '') & - (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) - ) - is_not_excluded = merged_df.get('is_excluded', False) == False - is_not_no_pass = merged_df.get('no_scud_pass', False) == False - - filtered_df = merged_df[(is_not_excluded & is_not_no_pass) | has_1c_reason].copy() - return filtered_df - - -def load_static_reason_workers(): - """Загружает реестр удалёнщиков и статических причин из CSV.""" - static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") - if not os.path.exists(static_path): - return {} - try: - df_static = pd.read_csv(static_path, encoding='utf-8') - if 'fio' in df_static.columns and 'reason' in df_static.columns: - df_static['fio_clean'] = df_static['fio'].apply(normalize_fio) - return dict(zip(df_static['fio_clean'], df_static['reason'])) - except Exception as e: - print(f"[⚠️] Ошибка чтения static_reason_workers.csv: {e}") - return {} - - -def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set=None): - """ - Автоматически выявляет истинные аномалии СКУД ⟷ 1С. - """ - anomalies = [] - kb_rules_text = " ".join(kb_rules).lower() if kb_rules else "" - - ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк'] - - for idx, row in merged_df.iterrows(): - fio = row.get('Сотрудник', row.get('fio_clean', '')) - fio_clean = row.get('fio_clean', '') - is_present = row.get('Пришел', False) - is_exc = row.get('is_excluded', False) - reason_1c = str(row.get('Вид_отсутствия', '')).strip() - has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and not reason_1c.startswith('Исключение') - anom_flag = row.get('anomaly_flag', 'NONE') - - is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text - - if is_present and has_1c_reason: - reason_lower = reason_1c.lower() - is_allowed_trip = any(kw in reason_lower for kw in ALLOWED_WORK_TRIP_KEYWORDS) - - if not is_allowed_trip and not is_fio_whitelisted_in_kb: - anomalies.append({ - "type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ", - "fio": fio, - "details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'" - }) - - if is_exc and not has_1c_reason: - continue - - if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY': - first_act = row.get('Первая_активность', '—') - anomalies.append({ - "type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА", - "fio": fio, - "details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}" - }) - - if scud_fios_set is not None: - if fio_clean not in scud_fios_set and not has_1c_reason: - anomalies.append({ - "type": "АНОМАЛИЯ УЧЕТА: СОТРУДНИК ОТСУТСТВУЕТ В СКУД ОРИОН PRO", - "fio": fio, - "details": f"Сотрудник числится в Штатном расписании 1С ({row.get('Подразделение', '—')}), но полностью отсутствует в базе СКУД Орион Pro (профиль не создан или карта не выдана)" - }) - - return anomalies - - -def aggregate_scud_by_employee(df, debug=False): - """Агрегирует проходы СКУД по уникальным сотрудникам.""" - if df is None or df.empty or 'fio_clean' not in df.columns: - return df - - aggregated = [] - for fio_clean, group in df.groupby('fio_clean', sort=False): - is_present = group['Пришел'].any() if 'Пришел' in group.columns else False - - if is_present and 'Пришел' in group.columns: - present_rows = group[group['Пришел'] == True] - best_row = present_rows.iloc[0].to_dict() if not present_rows.empty else group.iloc[0].to_dict() - else: - best_row = group.iloc[0].to_dict() - - best_row['Пришел'] = is_present - aggregated.append(best_row) - - return pd.DataFrame(aggregated) +from services.scud_export import run_export +from services.scud_etl.pipeline import run_controlling_pipeline +# ANCHOR[MAIN_CLI_ENTRY] def main(): help_text = """ -Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai_v2) +Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai) ПРИМЕРЫ ЗАПУСКА: - python main.py -- Обычный дневной запуск - python main.py --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite - python main.py --snapshot 20260805-001 -- Расчет отчета строго по ID снапшота - python main.py -d -- Запуск в режиме расширенной отладки (DEBUG) + python main_etl.py -- Обычный дневной запуск + python main_etl.py --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite + python main_etl.py --snapshot 20260820-001 -- Расчет отчета строго по ID снапшота + python main_etl.py -d -- Запуск в режиме расширенной отладки (DEBUG) """ - parser = argparse.ArgumentParser( - description=help_text, - formatter_class=argparse.RawDescriptionHelpFormatter - ) - parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки с выводом подробных логов") - parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL и построить отчет по последнему снапшоту") - parser.add_argument('--snapshot', type=str, default=None, help="Составной ID снапшота или время создания") + parser = argparse.ArgumentParser(description=help_text, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('-d', '--debug', action='store_true', help="Режим отладки (DEBUG)") + parser.add_argument('--skip-export', action='store_true', help="Расчет отчета по последнему снапшоту из SQLite") + parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота") args = parser.parse_args() - DEBUG = args.debug - print("=" * 60) - print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}") + print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if args.debug else ''}") print("=" * 60) init_db() - kb_data = load_knowledge_base() - kb_rules = kb_data.get("rules", []) - exceptions_cfg = load_exceptions_config() + # Определение режима работы и вывод статусов if args.snapshot: - snapshot_param = args.snapshot - print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{snapshot_param}'") + print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{args.snapshot}'") elif args.skip_export: - snapshot_param = get_latest_snapshot_time() - print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{snapshot_param}')") + last_snap = get_latest_snapshot_time() + print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{last_snap}')") else: - snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'") + current_snap = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{current_snap}'") + has_today_1c = True + + # Прямой экспорт и проверка сетевой шары (если не режим снапшотов) if not args.skip_export and not args.snapshot: try: - run_export(save_xlsx=True, debug=DEBUG) + run_export(save_xlsx=True, debug=args.debug) except Exception as e: - print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.") - else: - print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...") + print(f"[⚠️] Ошибка автоэкспорта из MS SQL: {e}. Переходим к записям SQLite.") - # ⚡️ ПРОВЕРКА ШАРЫ ТОЛЬКО ДЛЯ ОБЫЧНОГО ДНЕВНОГО ЗАПУСКА - if not args.snapshot and not args.skip_export: copy_1c_files_from_share() - print("[1/5] Проверка актуальности и свежести входных данных...") is_valid, warnings, errors, has_today_1c = check_file_freshness() @@ -253,319 +72,22 @@ def main(): print("-" * 45) if not is_valid: - print("\n" + "!" * 60) - print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Отсутствуют критически важные файлы за вчерашний день!") + print("🛑 ОСТАНОВКА: Отсутствуют критически важные файлы за вчера!") for e in errors: print(f" {e}") - print("!" * 60) sys.exit(1) - print("[✓] Проверка доступности данных успешно пройдена!\n") else: + print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...") print("[1/5] Пропуск проверки сетевой шары (все данные читаются из SQLite)...") - has_today_1c = True - print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...") - - # 🎯 ВЫЧИСЛЕНИЕ ДАТ СНАПШОТА И ДАТ НАКАНУНЕ - if args.snapshot or args.skip_export: - raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snapshot_param) - - if not raw_scud_today_df.empty and 'log_date' in raw_scud_today_df.columns: - target_date_str = str(raw_scud_today_df['log_date'].iloc[0]) - else: - target_date_str = DATE_TODAY - - dt_target = datetime.strptime(target_date_str, "%d.%m.%Y") - if dt_target.weekday() == 0: # Понедельник -> Пятница - dt_yesterday = dt_target - timedelta(days=3) - else: - dt_yesterday = dt_target - timedelta(days=1) - yesterday_date_str = dt_yesterday.strftime("%d.%m.%Y") - - print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}") - - raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None) - - # Гарантированная загрузка кадров 1С за обе даты (БД + локальный фолбэк) - df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True) - df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True) - else: - target_date_str = DATE_TODAY - yesterday_date_str = DATE_YESTERDAY - - raw_scud_today_df = load_scud_from_db_by_snapshot(target_date_str, snapshot_param=snapshot_param) - raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None) - - df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY, use_db=False) - df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY, use_db=False) - - if df_staff_yesterday is not None: - save_staff_to_db(df_staff_yesterday, yesterday_date_str) - if df_absent_yesterday is not None: - save_absences_to_db(df_absent_yesterday, yesterday_date_str) - - if df_staff_today is not None: - save_staff_to_db(df_staff_today, target_date_str) - if df_absent_today is not None: - save_absences_to_db(df_absent_today, target_date_str) - - staff_fios_yesterday_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else [] - static_reasons_dict = load_static_reason_workers() - - # ============================================================ - # 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ДЕНЬ НАКАНУНЕ) - # ============================================================ - print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...") - - if not raw_scud_yesterday_df.empty and 'Пришел' not in raw_scud_yesterday_df.columns: - raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False - - scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() if not raw_scud_yesterday_df.empty else [] - - fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean) - if fio_mapping_scud_y: - raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply( - lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x - ) - - if df_absent_yesterday is not None and not df_absent_yesterday.empty: - absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() - if absent_unrecognized_yesterday: - fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean) - if fio_mapping_absent_y: - df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply( - lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x - ) - - raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG) - - # ⭐️ ФОРМИРУЕМ МЕРДЖ ИСКЛЮЧИТЕЛЬНО НА БАЗЕ ШТАТА ЗА ПРОШЛЫЙ ДЕНЬ - merged_yesterday = df_staff_yesterday.copy() if (df_staff_yesterday is not None and not df_staff_yesterday.empty) else (df_staff_today.copy() if df_staff_today is not None else pd.DataFrame()) - - if not merged_yesterday.empty: - if not raw_scud_yesterday_df.empty: - if 'Подразделение' in raw_scud_yesterday_df.columns: - raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['Подразделение'] - elif 'department' in raw_scud_yesterday_df.columns: - raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['department'] - else: - raw_scud_yesterday_df['department_scud'] = '' - - merged_yesterday = merged_yesterday.merge( - raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']], - on='fio_clean', how='left' - ) - - if 'Вид_отсутствия' in merged_yesterday.columns: - merged_yesterday = merged_yesterday.drop(columns=['Вид_отсутствия']) - - # Присоединяем отсутствия СТРОГО за дату yesterday_date_str - if df_absent_yesterday is not None and not df_absent_yesterday.empty: - merged_yesterday = merged_yesterday.merge( - df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], - on='fio_clean', - how='left' - ) - - if 'Пришел' not in merged_yesterday.columns: - merged_yesterday['Пришел'] = False - else: - merged_yesterday['Пришел'] = merged_yesterday['Пришел'].fillna(False) - - if 'Сотрудник' not in merged_yesterday.columns: - merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean']) - - if static_reasons_dict: - for fio_clean, reason_val in static_reasons_dict.items(): - mask_yesterday = ( - (merged_yesterday['Пришел'] == False) & - (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_yesterday['fio_clean'] == fio_clean) - ) - merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val - - merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg) - mask_exc_yesterday = ( - (merged_yesterday['Пришел'] == False) & - (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_yesterday.get('is_excluded', False) == True) + # Запуск конвейера + run_controlling_pipeline( + snapshot_param=args.snapshot, + skip_export=args.skip_export, + debug=args.debug, + has_today_1c=has_today_1c ) - merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' - - scud_fios_yesterday_set = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set() - merged_yesterday['no_scud_pass'] = ( - (~merged_yesterday['fio_clean'].isin(scud_fios_yesterday_set)) & - (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_yesterday.get('is_excluded', False) == False) - ) - - anomalies_yesterday_list = detect_all_anomalies(merged_yesterday, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_yesterday_set) - save_anomalies_to_db(anomalies_yesterday_list, yesterday_date_str) - - filtered_yesterday = filter_report_dataframe(merged_yesterday) - generate_detailed_excel(merged_df=filtered_yesterday, date_str=yesterday_date_str) - - yesterday_dt_obj = datetime.strptime(yesterday_date_str, "%d.%m.%Y") - yesterday_22_str = yesterday_dt_obj.strftime("%Y-%m-%d 22:00:00") - save_scud_to_db(merged_yesterday, yesterday_date_str, snapshot_time=yesterday_22_str, is_yesterday=True) - print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}") - - # ============================================================ - # 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА (ЗА ЦЕЛЕВОЙ ДЕНЬ СНАПШОТА) - # ============================================================ - if not args.snapshot and not args.skip_export: - save_scud_to_db(raw_scud_today_df, target_date_str, snapshot_time=snapshot_param, is_yesterday=False) - - if has_today_1c and df_staff_today is not None and df_absent_today is not None: - print(f"\n[4/5] Обработка и построение Ежедневной сводки за {target_date_str}...") - staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist() - - if not raw_scud_today_df.empty and 'Пришел' not in raw_scud_today_df.columns: - raw_scud_today_df['Пришел'] = raw_scud_today_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_today_df.columns else False - - scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() if not raw_scud_today_df.empty else [] - fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_today_clean) - if fio_mapping_scud: - raw_scud_today_df['fio_clean'] = raw_scud_today_df['fio_clean'].apply( - lambda x: fio_mapping_scud[x]['staff_fio'] if x in fio_mapping_scud else x - ) - - absent_unrecognized_today = df_absent_today[~df_absent_today['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() - if absent_unrecognized_today: - fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized_today, staff_fios_today_clean) - if fio_mapping_absent: - df_absent_today['fio_clean'] = df_absent_today['fio_clean'].apply( - lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x - ) - - raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df, debug=DEBUG) - - merged_today = df_staff_today.copy() - if not raw_scud_today_df.empty: - if 'Подразделение' in raw_scud_today_df.columns: - raw_scud_today_df['department_scud'] = raw_scud_today_df['Подразделение'] - elif 'department' in raw_scud_today_df.columns: - raw_scud_today_df['department_scud'] = raw_scud_today_df['department'] - else: - raw_scud_today_df['department_scud'] = '' - - merged_today = merged_today.merge( - raw_scud_today_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']], - on='fio_clean', how='left' - ) - - if df_absent_today is not None and not df_absent_today.empty: - merged_today = merged_today.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left') - - if 'Пришел' not in merged_today.columns: - merged_today['Пришел'] = False - else: - merged_today['Пришел'] = merged_today['Пришел'].fillna(False) - - if 'Сотрудник' not in merged_today.columns: - merged_today['Сотрудник'] = merged_today.get('ФИО', merged_today['fio_clean']) - - if static_reasons_dict: - for fio_clean, reason_val in static_reasons_dict.items(): - mask_today = ( - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_today['fio_clean'] == fio_clean) - ) - merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val - - merged_today = apply_exceptions_from_json(merged_today, exceptions_cfg) - mask_exc_today = ( - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_today.get('is_excluded', False) == True) - ) - merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' - - scud_fios_today_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set() - merged_today['no_scud_pass'] = ( - (~merged_today['fio_clean'].isin(scud_fios_today_set)) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_today.get('is_excluded', False) == False) - ) - - scud_fios_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set() - anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_set) - - mass_failure_today = analyze_scud_mass_failure_ai(raw_scud_today_df) - if mass_failure_today and mass_failure_today.get("is_mass_failure"): - print("\n" + "!" * 60) - print(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ ОПЕРАТИВНЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА СЕГОДНЯ ({mass_failure_today['anomaly_percent']}% СМЕНЫ)") - print(mass_failure_today["alert_text"]) - print("!" * 60 + "\n") - - save_anomalies_to_db(anomalies_list, target_date_str) - - is_no_pass_today = merged_today['no_scud_pass'] == True if 'no_scud_pass' in merged_today.columns else False - is_exc_today = merged_today.get('is_excluded', False) == True - - absent_explained = merged_today[ - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].notna()) & - (~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение')) - ] - - absent_unexplained = merged_today[ - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (~is_no_pass_today) & - (~is_exc_today) - ] - - scud_present_but_absent_in_1c = merged_today[ - (~is_exc_today) & - (merged_today['Пришел'] == True) & - (merged_today['Вид_отсутствия'].notna()) & - (~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение')) - ] - - print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...") - filtered_anomalies_list = [ - a for a in anomalies_list - if "ОТСУТСТВУЕТ В СКУД" not in a.get('type', '') - ] - - report_text = generate_markdown_report( - merged_df=merged_today, - absent_explained=absent_explained, - absent_unexplained=absent_unexplained, - scud_present_but_absent_in_1c=scud_present_but_absent_in_1c, - anomalies_list=filtered_anomalies_list, - raw_scud_df=raw_scud_today_df, - raw_absent_df=df_absent_today, - date_str=target_date_str - ) - - generate_summary_excel(merged_df=merged_today, date_str=target_date_str) - - md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{target_date_str}.md") - with open(md_report_path, "w", encoding="utf-8") as f: - f.write(report_text) - print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}") - - suspicious_cases = [] - for a in anomalies_list: - if "ОФИЦИАЛЬНОМ ОТСУТСТВИИ" not in a.get('type', ''): - suspicious_cases.append({ - 'fio_target': a['fio'], - 'reason': f"{a['type']}: {a['details']}" - }) - - if suspicious_cases: - review_ai_decisions(report_text, suspicious_cases) - - print("\n" + "=" * 60) - print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:") - print("=" * 60) - print(report_text) - else: - print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.") if __name__ == "__main__": diff --git a/services/scud_etl/__init__.py b/services/scud_etl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/scud_etl/anomaly_detector.py b/services/scud_etl/anomaly_detector.py new file mode 100644 index 0000000..2ab382d --- /dev/null +++ b/services/scud_etl/anomaly_detector.py @@ -0,0 +1,72 @@ +""" +=============================================================================== +FILE: services/scud_etl/anomaly_detector.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / scud_etl +ROLE: Автоматическое выявление аномалий и конфликтов между 1С:ЗУП и СКУД. + +AI-CONTEXT-ANCHORS: + - ANCHOR[ANOMALY_DETECTOR_CORE]: Проверка физического присутствия в отпуске и перемещений без входа. +=============================================================================== +""" + +import pandas as pd +from typing import List, Dict, Any, Optional + +ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк'] + + +# ANCHOR[ANOMALY_DETECTOR_CORE] +def detect_all_anomalies( + merged_df: pd.DataFrame, + static_reasons_dict: dict, + kb_rules: List[str], + scud_fios_set: Optional[set] = None +) -> List[Dict[str, Any]]: + """Выявляет конфликты и аномалии между источниками СКУД и 1С.""" + anomalies = [] + kb_rules_text = " ".join(kb_rules).lower() if kb_rules else "" + + for idx, row in merged_df.iterrows(): + fio = row.get('Сотрудник', row.get('fio_clean', '')) + fio_clean = row.get('fio_clean', '') + is_present = row.get('Пришел', False) + is_exc = row.get('is_excluded', False) + reason_1c = str(row.get('Вид_отсутствия', '')).strip() + has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and not reason_1c.startswith('Исключение') + anom_flag = row.get('anomaly_flag', 'NONE') + + is_fio_whitelisted = fio_clean.lower() in kb_rules_text + + # 1. Присутствие при официальном отсутствии + if is_present and has_1c_reason: + is_allowed_trip = any(kw in reason_1c.lower() for kw in ALLOWED_WORK_TRIP_KEYWORDS) + if not is_allowed_trip and not is_fio_whitelisted: + anomalies.append({ + "type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ", + "fio": fio, + "details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'" + }) + + if is_exc and not has_1c_reason: + continue + + # 2. Перемещение внутри здания без отметки входа на КПП + if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY': + first_act = row.get('Первая_активность', '—') + anomalies.append({ + "type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА", + "fio": fio, + "details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}" + }) + + # 3. Сотрудник в штате 1С, но карты/профиля в СКУД нет + if scud_fios_set is not None: + if fio_clean not in scud_fios_set and not has_1c_reason: + anomalies.append({ + "type": "АНОМАЛИЯ УЧЕТА: СОТРУДНИК ОТСУТСТВУЕТ В СКУД ОРИОН PRO", + "fio": fio, + "details": f"Сотрудник числится в Штатном расписании 1С ({row.get('Подразделение', '—')}), но отсутствует в СКУД" + }) + + return anomalies \ No newline at end of file diff --git a/services/scud_etl/merger.py b/services/scud_etl/merger.py new file mode 100644 index 0000000..c168984 --- /dev/null +++ b/services/scud_etl/merger.py @@ -0,0 +1,114 @@ +""" +=============================================================================== +FILE: services/scud_etl/merger.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / scud_etl +ROLE: Агрегация проходов, сопоставление исключений и мердж таблиц 1С:ЗУП и СКУД. + +AI-CONTEXT-ANCHORS: + - ANCHOR[MERGER_EXCEPTIONS]: Наложение флага исключений из exceptions.json. + - ANCHOR[MERGER_AGGREGATION]: Агрегация множественных проходов до уникального ФИО. + - ANCHOR[MERGER_BUILD_DATASET]: Сборка итогового датасета для сводок и отчетов. +=============================================================================== +""" + +import os +import json +import pandas as pd +from config import normalize_fio, DATA_DIR + + +# ANCHOR[MERGER_EXCEPTIONS] +def load_exceptions_config() -> dict: + """Загружает exceptions.json из корня проекта.""" + root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")) + json_path = os.path.join(root_dir, "exceptions.json") + if not os.path.exists(json_path): + return {} + try: + with open(json_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return {} + + +def apply_exceptions_from_json(df: pd.DataFrame, exceptions_cfg: dict) -> pd.DataFrame: + """Быстрая разметка флага is_excluded на основе exceptions.json.""" + if df is None or df.empty or not exceptions_cfg: + if df is not None: + df['is_excluded'] = False + return df + + deps = [d.strip().lower() for d in exceptions_cfg.get("departments", []) if d] + exact_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p] + pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k] + exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f] + + df['is_excluded'] = False + + for idx, row in df.iterrows(): + fio_clean = row.get('fio_clean', '') + dep_1c = str(row.get('Подразделение', '')).lower() + dep_scud = str(row.get('department_scud', row.get('department', ''))).lower() + pos = str(row.get('Должность', '')).lower() + + is_fio_exc = fio_clean in exc_fios + is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw if k) if pos else False + is_dep_exc = any(d in dep_1c or d in dep_scud for d in deps) if deps else False + + if is_fio_exc or is_dep_exc or is_pos_exc: + df.at[idx, 'is_excluded'] = True + + return df + + +# ANCHOR[MERGER_AGGREGATION] +def aggregate_scud_by_employee(df: pd.DataFrame) -> pd.DataFrame: + """Агрегирует проходы СКУД по уникальным сотрудникам.""" + if df is None or df.empty or 'fio_clean' not in df.columns: + return df + + aggregated = [] + for fio_clean, group in df.groupby('fio_clean', sort=False): + is_present = group['Пришел'].any() if 'Пришел' in group.columns else False + if is_present and 'Пришел' in group.columns: + present_rows = group[group['Пришел'] == True] + best_row = present_rows.iloc[0].to_dict() if not present_rows.empty else group.iloc[0].to_dict() + else: + best_row = group.iloc[0].to_dict() + + best_row['Пришел'] = is_present + aggregated.append(best_row) + + return pd.DataFrame(aggregated) + + +def filter_report_dataframe(merged_df: pd.DataFrame) -> pd.DataFrame: + """Исключает подрядчиков и сотрудников без пропуска из детального отчета.""" + if merged_df is None or merged_df.empty: + return merged_df + + has_1c_reason = ( + merged_df['Вид_отсутствия'].notna() & + (merged_df['Вид_отсутствия'].astype(str).str.strip() != '') & + (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) + ) + is_not_excluded = merged_df.get('is_excluded', False) == False + is_not_no_pass = merged_df.get('no_scud_pass', False) == False + + return merged_df[(is_not_excluded & is_not_no_pass) | has_1c_reason].copy() + + +def load_static_reason_workers() -> dict: + """Загружает реестр удаленщиков из CSV.""" + static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") + if not os.path.exists(static_path): + return {} + try: + df_static = pd.read_csv(static_path, encoding='utf-8') + if 'fio' in df_static.columns and 'reason' in df_static.columns: + df_static['fio_clean'] = df_static['fio'].apply(normalize_fio) + return dict(zip(df_static['fio_clean'], df_static['reason'])) + except Exception: + pass + return {} \ No newline at end of file diff --git a/services/scud_etl/pipeline.py b/services/scud_etl/pipeline.py new file mode 100644 index 0000000..6c118b5 --- /dev/null +++ b/services/scud_etl/pipeline.py @@ -0,0 +1,139 @@ +""" +=============================================================================== +FILE: services/scud_etl/pipeline.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / scud_etl +ROLE: Оркестратор этапов контроллинга (Загрузка -> Сверка -> Отчеты -> SQLite). + +AI-CONTEXT-ANCHORS: + - ANCHOR[PIPELINE_RUN_CONTROLLING]: Главная функция выполнения ETL-конвейера. +=============================================================================== +""" + +import os +from datetime import datetime, timedelta +import pandas as pd + +from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR +from core.database import ( + save_scud_to_db, save_staff_to_db, save_absences_to_db, + save_anomalies_to_db, load_scud_from_db_by_snapshot, get_latest_snapshot_time +) +from services.data_loader import load_1c_data_smart +from services.excel_exporter import generate_summary_excel, generate_detailed_excel +from services.ai_verifier import ai_verify_scud_against_staff, analyze_scud_mass_failure_ai +from services.text_reporter import generate_markdown_report +from services.feedback_loop import review_ai_decisions +from services.knowledge_base import load_knowledge_base + +from .merger import ( + load_exceptions_config, apply_exceptions_from_json, + aggregate_scud_by_employee, filter_report_dataframe, load_static_reason_workers +) +from .anomaly_detector import detect_all_anomalies + + +# ANCHOR[PIPELINE_RUN_CONTROLLING] +def run_controlling_pipeline(snapshot_param: str = None, skip_export: bool = False, debug: bool = False, has_today_1c: bool = True) -> None: + """Выполняет полный цикл сверки СКУД ⟷ 1С и сохранение результатов.""" + kb_rules = load_knowledge_base().get("rules", []) + exceptions_cfg = load_exceptions_config() + static_reasons = load_static_reason_workers() + + print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...") + + # 1. Определение дат целевого снапшота и предыдущей смены + if snapshot_param or skip_export: + snap_to_use = snapshot_param or get_latest_snapshot_time() + raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snap_to_use) + if not raw_scud_today_df.empty and 'log_date' in raw_scud_today_df.columns: + target_date_str = str(raw_scud_today_df['log_date'].iloc[0]) + else: + target_date_str = DATE_TODAY + else: + target_date_str = DATE_TODAY + raw_scud_today_df = load_scud_from_db_by_snapshot(target_date_str, snapshot_param=snapshot_param) + + dt_target = datetime.strptime(target_date_str, "%d.%m.%Y") + dt_yesterday = dt_target - timedelta(days=3 if dt_target.weekday() == 0 else 1) + yesterday_date_str = dt_yesterday.strftime("%d.%m.%Y") + + print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}\n") + + raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None) + df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True) + df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True) + + # 2. Этап 3: Обработка ВЧЕРА (Детальный отчет) + print(f"[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...") + + if df_staff_yesterday is not None: + save_staff_to_db(df_staff_yesterday, yesterday_date_str) + if df_absent_yesterday is not None: + save_absences_to_db(df_absent_yesterday, yesterday_date_str) + + # Проверка опечаток ФИО через AI-аудитор + staff_fios_y_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else [] + if not raw_scud_yesterday_df.empty: + raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False + unrecog = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_y_clean)]['fio_clean'].tolist() + fio_map = ai_verify_scud_against_staff(unrecog, staff_fios_y_clean) + if fio_map: + raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply(lambda x: fio_map[x]['staff_fio'] if x in fio_map else x) + + raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df) + merged_y = (df_staff_yesterday.copy() if df_staff_yesterday is not None else pd.DataFrame()) + + if not merged_y.empty: + if not raw_scud_yesterday_df.empty: + merged_y = merged_y.merge( + raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']], + on='fio_clean', how='left' + ) + if df_absent_yesterday is not None and not df_absent_yesterday.empty: + merged_y = merged_y.merge(df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left') + + merged_y['Пришел'] = merged_y['Пришел'].fillna(False) if 'Пришел' in merged_y.columns else False + if 'Сотрудник' not in merged_y.columns: + merged_y['Сотрудник'] = merged_y.get('ФИО', merged_y['fio_clean']) + + merged_y = apply_exceptions_from_json(merged_y, exceptions_cfg) + + scud_fios_y = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set() + anomalies_y = detect_all_anomalies(merged_y, static_reasons, kb_rules, scud_fios_set=scud_fios_y) + save_anomalies_to_db(anomalies_y, yesterday_date_str) + + filtered_y = filter_report_dataframe(merged_y) + generate_detailed_excel(merged_df=filtered_y, date_str=yesterday_date_str) + save_scud_to_db(merged_y, yesterday_date_str, snapshot_time=f"{dt_yesterday.strftime('%Y-%m-%d')} 22:00:00", is_yesterday=True) + print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}") + + # 3. Этап 4 & 5: Обработка СЕГОДНЯ (Ежедневная сводка) + if has_today_1c and df_staff_today is not None and df_absent_today is not None: + print(f"\n[4/5] Обработка и построение Ежедневной сводки за {target_date_str}...") + save_staff_to_db(df_staff_today, target_date_str) + save_absences_to_db(df_absent_today, target_date_str) + + raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df) + merged_t = df_staff_today.copy() + if not raw_scud_today_df.empty: + merged_t = merged_t.merge( + raw_scud_today_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']], + on='fio_clean', how='left' + ) + merged_t = merged_t.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left') + merged_t['Пришел'] = merged_t['Пришел'].fillna(False) if 'Пришел' in merged_t.columns else False + if 'Сотрудник' not in merged_t.columns: + merged_t['Сотрудник'] = merged_t.get('ФИО', merged_t['fio_clean']) + + merged_t = apply_exceptions_from_json(merged_t, exceptions_cfg) + + scud_fios_t = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set() + anomalies_t = detect_all_anomalies(merged_t, static_reasons, kb_rules, scud_fios_set=scud_fios_t) + save_anomalies_to_db(anomalies_t, target_date_str) + + print("[5/5] Сохранение Ежедневной сводки...") + generate_summary_excel(merged_df=merged_t, date_str=target_date_str) + print(f"[✓] Ежедневная сводка сохранена за {target_date_str}") + else: + print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.") \ No newline at end of file diff --git a/services/scud_etl/sql_queries.py b/services/scud_etl/sql_queries.py new file mode 100644 index 0000000..43332af --- /dev/null +++ b/services/scud_etl/sql_queries.py @@ -0,0 +1,116 @@ +""" +=============================================================================== +FILE: services/scud_etl/sql_queries.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / scud_etl +ROLE: Хранилище сырых SQL-шаблонов для выгрузки из MS SQL Server (СКУД Орион Pro). + +AI-CONTEXT-ANCHORS: + - ANCHOR[SQL_SCUD_EXPORT_TEMPLATE]: T-SQL запрос с расчетом первой активности и длительности. +=============================================================================== +""" + +# ANCHOR[SQL_SCUD_EXPORT_TEMPLATE] +SCUD_EXPORT_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)); + +WITH DailyLogs AS ( + SELECT + log.HozOrgan AS EmployeeID, + log.TimeVal, + log.Event, + log.Mode, + CASE + WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT' + WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN' + 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 (26, 27, 28, 29, 32, 33, 54, 55, 64, 65) +), +Passages AS ( + SELECT + EmployeeID, + MIN(TimeVal) AS FirstRawEvent, + MAX(TimeVal) AS LastRawEvent, + 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 pass.FirstIn IS NULL AND pass.FirstRawEvent IS NOT NULL + THEN CAST(CONVERT(VARCHAR(8), pass.FirstRawEvent, 108) AS NVARCHAR(20)) + ELSE N'—' + END 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)) + WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent) + THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20)) + ELSE N'Нет выхода' + END AS [Конец_дня], + CASE + WHEN pass.EmployeeID IS NOT NULL AND (pass.FirstIn IS NOT NULL OR pass.FirstRawEvent IS NOT NULL) THEN + RIGHT('0' + CAST(DATEDIFF(MINUTE, + ISNULL(pass.FirstIn, pass.FirstRawEvent), + 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.LastRawEvent) + END) / 60 AS VARCHAR), 2) + ':' + + RIGHT('0' + CAST(DATEDIFF(MINUTE, + ISNULL(pass.FirstIn, pass.FirstRawEvent), + 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.LastRawEvent) + 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; +""" \ No newline at end of file