import os import sys import json import argparse import pandas as pd from datetime import datetime 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 services.share_copier import copy_1c_files_from_share from services.data_validator import check_file_freshness from services.data_loader import load_all_data, 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, get_latest_snapshot_time, has_scud_logs_for_date ) from services.ai_verifier import resolve_department_exception_ai def apply_exceptions_from_json(df, exceptions_cfg): """ Размечает флаг is_excluded=True на основе правил из exceptions.json и БД SQLite. Сверяет одновременно 1С, СКУД и использует интеллектуальный поиск отделов. """ if df is None or df.empty or not exceptions_cfg: if df is not None: df['is_excluded'] = False return df deps = exceptions_cfg.get("departments", []) exact_pos = [p.lower() for p in exceptions_cfg.get("positions", [])] pos_kw = [k.lower() for k in exceptions_cfg.get("position_keywords", [])] exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", [])] df['is_excluded'] = False for idx, row in df.iterrows(): fio_clean = row.get('fio_clean', '') dep_1c = row.get('Подразделение', '') dep_scud = row.get('department_scud', row.get('department', '')) 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 pos else False # Двухуровневая интеллектуальная проверка отделов (1С + СКУД + ИИ-Кеш БД) is_dep_exc = resolve_department_exception_ai(dep_1c, dep_scud, deps) if is_fio_exc or is_dep_exc or is_pos_exc: df.at[idx, 'is_excluded'] = True return df 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. Сверяет отдел одновременно по полному имени из 1С и по короткой аббревиатуре из СКУД. """ if df is None or df.empty or not exceptions_cfg: if df is not None: df['is_excluded'] = False return df deps = [d.lower() for d in exceptions_cfg.get("departments", [])] exact_pos = [p.lower() for p in exceptions_cfg.get("positions", [])] pos_kw = [k.lower() for k in exceptions_cfg.get("position_keywords", [])] exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", [])] 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_dep_exc = any(d in dep_1c or d == dep_scud for d in deps) if (dep_1c or dep_scud) else False is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw) if pos else False 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 # В отчет попадают ТОЛЬКО офисные сотрудники с пропусками ИЛИ имеющие документ из 1С 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С. Исключения (ОВК/Клининг/Охрана) НЕ попадают в аномалии СКУД, если на них нет документа из 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 # 🚨 АНОМАЛИЯ 1: Физическое присутствие при документе отсутствия 1С 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}'" }) # ИСКЛЮЧЕНИЯ без документов 1С НЕ пропускаются дальше к аномалиям СКУД 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С, но ПОЛНОСТЬЮ ОТСУТСТВУЕТ в базе СКУД Орион Pro 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) def main(): help_text = """ Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai_v2) ПРИМЕРЫ ЗАПУСКА: python main.py -- Обычный дневной запуск python main.py --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite python main.py --snapshot 20260805-001 -- Расчет отчета строго по ID снапшота python main.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 снапшота или время создания") args = parser.parse_args() DEBUG = args.debug print("=" * 60) print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if 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}'") elif args.skip_export: snapshot_param = get_latest_snapshot_time() print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{snapshot_param}')") else: snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'") if not args.skip_export and not args.snapshot: try: run_export(save_xlsx=True, debug=DEBUG) except Exception as e: print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.") else: print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...") copy_1c_files_from_share() print("[1/5] Проверка актуальности и свежести входных данных...") is_valid, warnings, errors, has_today_1c = 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") print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...") if args.snapshot or args.skip_export: raw_scud_today_df = load_scud_from_db_by_snapshot(DATE_TODAY, snapshot_param) raw_scud_yesterday_df = load_scud_from_db_by_snapshot(DATE_YESTERDAY, snapshot_param) if raw_scud_yesterday_df.empty: raw_scud_yesterday_df = load_scud_from_db_by_snapshot(DATE_YESTERDAY) ( _, _, df_staff_today, df_absent_today, df_staff_yesterday, df_absent_yesterday ) = load_all_data() else: ( raw_scud_today_df, raw_scud_yesterday_df, df_staff_today, df_absent_today, df_staff_yesterday, df_absent_yesterday ) = load_all_data() if df_staff_yesterday is not None: save_staff_to_db(df_staff_yesterday, DATE_YESTERDAY) if df_absent_yesterday is not None: save_absences_to_db(df_absent_yesterday, DATE_YESTERDAY) if df_staff_today is not None: save_staff_to_db(df_staff_today, DATE_TODAY) if df_absent_today is not None: save_absences_to_db(df_absent_today, DATE_TODAY) 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] Обработка и построение детального отчета за ВЧЕРА ({DATE_YESTERDAY})...") 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 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 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) ) merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' # Расчет флага no_scud_pass для вчера 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, DATE_YESTERDAY) # ⚡️ Фильтрация: исключения НЕ попадают в вчерашний отчет (если нет отпуска из 1С) filtered_yesterday = filter_report_dataframe(merged_yesterday) generate_detailed_excel(merged_df=filtered_yesterday, date_str=DATE_YESTERDAY) # Фиксируем вчерашний снапшот строго на 22:00:00 yesterday_dt_obj = datetime.strptime(DATE_YESTERDAY, "%d.%m.%Y") yesterday_22_str = yesterday_dt_obj.strftime("%Y-%m-%d 22:00:00") save_scud_to_db(merged_yesterday, DATE_YESTERDAY, snapshot_time=yesterday_22_str, is_yesterday=True) print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite со снапшотом на {yesterday_22_str}") # ============================================================ # 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА ЗА СЕГОДНЯ # ============================================================ if not args.snapshot and not args.skip_export: save_scud_to_db(raw_scud_today_df, DATE_TODAY, 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("\n[4/5] Обработка данных за СЕГОДНЯ...") 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, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' # Расчет флага no_scud_pass для сегодня 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, DATE_TODAY) # Флаги фильтрации для ИИ-репортера 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=DATE_TODAY ) generate_summary_excel(merged_df=merged_today, date_str=DATE_TODAY) 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}") 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[ℹ️] Формирование Ежедневной сводки за {DATE_TODAY} ПРОПУЩЕНО.") if __name__ == "__main__": main()