import os import sys import argparse import pandas as pd sys.path.append(os.path.dirname(os.path.abspath(__file__))) from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio from modules.scud_export import run_export from modules.data_validator import check_file_freshness from modules.data_loader import load_all_data from modules.excel_exporter import generate_summary_excel, generate_detailed_excel from modules.ai_verifier import ai_verify_scud_against_staff from modules.text_reporter import generate_markdown_report from modules.feedback_loop import review_ai_decisions from modules.knowledge_base import load_knowledge_base def load_static_reason_workers(): 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): """ Автоматически находит логические конфликты между СКУД, 1С, статической удалёнкой и исключениями с учётом динамических правил из базы знаний (knowledge_base.json). """ 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) reason_1c = str(row.get('Вид_отсутствия', '')).strip() has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' is_excluded = row.get('is_excluded', False) has_static_reason = fio_clean in static_reasons_dict is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text # 🚨 АНОМАЛИЯ 1: Пришел по СКУД, но в 1С числится отсутствие if is_present and has_1c_reason: if not is_fio_whitelisted_in_kb: anomalies.append({ "type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ", "fio": fio, "details": f"Сотрудник пришел по СКУД, но в 1С оформлено: '{reason_1c}'" }) # 🚨 АНОМАЛИЯ 2: Удаленщик физически пришел в офис if is_present and has_static_reason: static_val = static_reasons_dict[fio_clean] if 'удален' in str(static_val).lower(): if not is_fio_whitelisted_in_kb: anomalies.append({ "type": "УДАЛЁНЩИК В ОФИСЕ", "fio": fio, "details": f"Сотрудник числится как '{static_val}' в реестре, но сегодня зафиксирован вход по СКУД." }) # 🚨 АНОМАЛИЯ 3: Конфликт 1С и Статического реестра if has_static_reason and has_1c_reason: static_val = static_reasons_dict[fio_clean] if reason_1c.lower() != static_val.lower() and not is_present: if not is_fio_whitelisted_in_kb: anomalies.append({ "type": "КОНФЛИКТ СПРАВОЧНИКОВ (1С vs РЕЕСТР)", "fio": fio, "details": f"В стат. реестре указано '{static_val}', а в 1С оформлен документ '{reason_1c}'" }) return anomalies def aggregate_scud_by_employee(df, debug=False): if 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 main(): parser = argparse.ArgumentParser(description="Система модульного контроллинга СКУД ⟷ 1С") parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки") parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL") args = parser.parse_args() DEBUG = args.debug print("=" * 60) print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}") print("=" * 60) # Загружаем существующие правила из Базы Знаний kb_data = load_knowledge_base() kb_rules = kb_data.get("rules", []) # 0. Выгрузка из БД if not args.skip_export: print("[0/5] Экспорт свежих данных из базы СКУД 'Орион Pro'...") try: run_export(debug=DEBUG) except Exception as e: print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Используем файлы в data/scud.") else: print("[0/5] Пропуск прямого экспорта из БД (--skip-export)...") # 1. Валидация print("[1/5] Проверка актуальности и свежести входных файлов...") is_valid, warnings, errors = check_file_freshness() if warnings: print("\n--- ⚠️ ПРЕДУПРЕЖДЕНИЯ ОБ АКТУАЛЬНОСТИ ---") for w in warnings: print(f" {w}") print("-" * 45) if not is_valid: print("\n" + "!" * 60) print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Обнаружены критические ошибки данных!") for e in errors: print(f" {e}") print("!" * 60) sys.exit(1) print("[✓] Проверка актуальности успешно пройдена!\n") # 2. Загрузка данных print("[2/5] Загрузка данных из data/scud, data/1c, реестра причин и исключений...") raw_scud_df, staff_fios_clean, df_absent = load_all_data() static_reasons_dict = load_static_reason_workers() # 3. ИИ-сверка ФИО (СКУД + 1С) print("[3/5] Проверка опечаток и сопоставление ФИО через ИИ...") scud_unrecognized = raw_scud_df[~raw_scud_df['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist() fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_clean) if fio_mapping_scud: print(f" [ИИ] Найдено совпадений в СКУД: {len(fio_mapping_scud)}") raw_scud_df['fio_clean'] = raw_scud_df['fio_clean'].apply( lambda x: fio_mapping_scud[x]['staff_fio'] if x in fio_mapping_scud else x ) absent_unrecognized = df_absent[~df_absent['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist() if absent_unrecognized: fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized, staff_fios_clean) if fio_mapping_absent: print(f" [ИИ] Найдено совпадений в 1С Отсутствиях: {len(fio_mapping_absent)}") df_absent['fio_clean'] = df_absent['fio_clean'].apply( lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x ) raw_scud_df = aggregate_scud_by_employee(raw_scud_df, debug=DEBUG) # 4. Объединение и детекция print("[4/5] Объединение реестров 1С, СКУД, статических причин и исключений...") merged = raw_scud_df[raw_scud_df['fio_clean'].isin(staff_fios_clean)].copy() merged = merged.merge(df_absent, on='fio_clean', how='left') # Детекция аномалий С УЧЁТОМ НАКОПЛЕННОЙ БАЗЫ ЗНАНИЙ anomalies_list = detect_all_anomalies(merged, static_reasons_dict, kb_rules) # Применение статических причин (ТОЛЬКО ДЛЯ ТЕХ, КТО НЕ ПРИШЕЛ В ОФИС) if static_reasons_dict: for fio_clean, reason_val in static_reasons_dict.items(): mask_apply_static = ( (merged['Пришел'] == False) & (merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) & (merged['fio_clean'] == fio_clean) ) merged.loc[mask_apply_static, 'Вид_отсутствия'] = reason_val # Применение исключений mask_apply_exceptions = ( (merged['Пришел'] == False) & (merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) & (merged.get('is_excluded', False) == True) ) merged.loc[mask_apply_exceptions, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' absent_explained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].notna())] absent_unexplained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].isna())] scud_present_but_absent_in_1c = merged[(merged['Пришел'] == True) & (merged['Вид_отсутствия'].notna())] if DEBUG: print("\n--- [DEBUG STATS] ---") print(f" Всего по списку: {len(merged)}") is_working_mask = (merged['Пришел'] == True) | ( merged['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) ) print(f" Работают (офис / удаленка / командировки): {len(merged[is_working_mask])}") print(f" Официально отсутствуют: {len(absent_explained)}") print(f" Истинно неизвестные случаи: {len(absent_unexplained)}") print(f" Обнаружено комплексных аномалий: {len(anomalies_list)}") print("---------------------\n") # 5. ИИ-аудитор print("[5/5] Запуск ИИ-аудитора и глубокий анализ аномалий...") report_text = generate_markdown_report( merged_df=merged, absent_explained=absent_explained, absent_unexplained=absent_unexplained, scud_present_but_absent_in_1c=scud_present_but_absent_in_1c, anomalies_list=anomalies_list, raw_scud_df=raw_scud_df, date_str=DATE_TODAY ) suspicious_cases = [] if anomalies_list: for a in anomalies_list: suspicious_cases.append({ 'fio_target': a['fio'], 'reason': f"{a['type']}: {a['details']}" }) review_ai_decisions(report_text, suspicious_cases) generate_summary_excel(merged_df=merged, date_str=DATE_TODAY) generate_detailed_excel(merged_df=merged, date_str=DATE_YESTERDAY) print("\n" + "=" * 60) print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИС-АУДИТОРА:") print("=" * 60) print(report_text) md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{DATE_TODAY}.md") with open(md_report_path, "w", encoding="utf-8") as f: f.write(report_text) print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}") if __name__ == "__main__": main()