diff --git a/HOWTO/работа с кодом.md b/HOWTO/работа с кодом.md new file mode 100644 index 0000000..6c6ee74 --- /dev/null +++ b/HOWTO/работа с кодом.md @@ -0,0 +1,73 @@ +# 🛠 HOWTO: Правила внесения изменений и передачи кода в проекте + +## 📌 Зачем это нужно +Проект `scud_orion_ai_v2` активно растет (17+ модулей, СУБД SQLite, ИИ-контур). Передача полных файлов кода при каждой мелкой правке сжигает окно контекста LLM, замедляет диалог и приводит к потере ранее внесенных архитектурных фич. + +--- + +## 🎯 Правила для разработчика и ИИ + +### 1. Формат передачи изменений +При запросах и ответах используются **точечные фрагменты кода** (Diff-стиль) с указанием привязок. + +Любой фрагмент должен содержать: +1. **Точный путь к файлу** (`services/excel_exporter.py`). +2. **Имя целевой функции/класса** (`generate_summary_excel`). +3. **Зацепку (Context Anchor)** — уникальную соседнюю строку или блок `try/except`. + +--- + +## 📐 Шаблоны фрагментов + +### Вариант А: Замена существующей логики + +> **Файл:** `services/excel_exporter.py` +> **Функция:** `generate_summary_excel()` +> **Место:** Строка ~150, блок фильтрации `no_pass_df` +> +> ❌ **Удалить:** +> ```python +> no_pass_df = merged_df[merged_df.get('no_scud_pass', False) == True] +> ``` +> +> ✅ **Вставить:** +> ```python +> if 'no_scud_pass' in merged_df.columns: +> no_pass_df = merged_df[merged_df['no_scud_pass'] == True] +> else: +> no_pass_df = pd.DataFrame() +> ``` + +--- + +### Вариант Б: Вставка нового условия/блока + +> **Файл:** `main.py` +> **Функция:** `detect_all_anomalies()` +> **Место:** После цикла `for idx, row in merged_df.iterrows():` +> +> ➕ **Вставить код:** +> ```python +> # ИСКЛЮЧЕНИЯ без документов 1С НЕ пропускаются дальше к аномалиям СКУД +> if is_exc and not has_1c_reason: +> continue +> ``` + +--- + +### Вариант В: Git Unified Diff Format + +> **Файл:** `services/data_loader.py` +> ```diff +> @@ def process_scud_anomalies(df_scud, exceptions=None): @@ +> - first_act = '—' +> + # Сохраняем вычисленную ранее активность из БД/файла +> + first_act = existing_fa if existing_fa not in ['—', '', 'None'] else '—' +> ``` + +--- + +## 🚀 Чек-лист перед запуском +1. Проверить синтаксис через `python -m py_compile <файл.py>`. +2. Запустить скрипт с флагом отладки `python main.py -d`. +3. Убедиться, что снапшот записан в SQLite (`python scripts/db_cli.py snapshots`). \ No newline at end of file diff --git a/core/database.py b/core/database.py index 3a5f6d8..2edc040 100644 --- a/core/database.py +++ b/core/database.py @@ -372,4 +372,25 @@ def add_rule_to_db(rule_text, added_by="Human"): def sync_knowledge_base_to_db(): - pass \ No newline at end of file + pass + +def delete_snapshot_by_id(snapshot_id: str): + """Удаляет конкретный снапшот из таблицы scud_logs по его ID.""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) + deleted_count = cursor.rowcount + conn.commit() + print(f"[✓] Удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}") + return deleted_count + +def delete_snapshots_by_date(date_str: str): + """Удаляет все снапшоты за указанную дату (например, '04.08.2026').""" + with get_connection() as conn: + cursor = conn.cursor() + # Удаляем по log_date или по дате внутри snapshot_id + cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%")) + deleted_count = cursor.rowcount + conn.commit() + print(f"[✓] Удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}") + return deleted_count \ No newline at end of file diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index a7085a3..1761825 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 84e501b..4babb73 100644 --- a/main.py +++ b/main.py @@ -11,7 +11,7 @@ from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_f 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 +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 @@ -46,10 +46,11 @@ def load_exceptions_config(): def apply_exceptions_from_json(df, exceptions_cfg): """ Размечает флаг is_excluded=True на основе правил из exceptions.json. - Сверяет отдел одновременно по полному имени из 1С и по аббревиатуре из СКУД. + Сверяет отдел одновременно по полному имени из 1С и по короткой аббревиатуре из СКУД. """ if df is None or df.empty or not exceptions_cfg: - df['is_excluded'] = False + if df is not None: + df['is_excluded'] = False return df deps = [d.lower() for d in exceptions_cfg.get("departments", [])] @@ -75,6 +76,27 @@ def apply_exceptions_from_json(df, exceptions_cfg): 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") @@ -90,8 +112,11 @@ def load_static_reason_workers(): return {} -def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules): - """Автоматически находит ИСТИННЫЕ аномалии.""" +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 "" @@ -101,13 +126,14 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules): 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 reason_1c != 'Исключение (ОВК/Подрядчики)' + 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С + # 🚨 АНОМАЛИЯ 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) @@ -119,7 +145,11 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules): "details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'" }) - # АНОМАЛИЯ 2: Перемещение внутри здания без отметки утреннего входа + # ИСКЛЮЧЕНИЯ без документов 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({ @@ -128,6 +158,15 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules): "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 @@ -158,10 +197,8 @@ def main(): ПРИМЕРЫ ЗАПУСКА: python main.py -- Обычный дневной запуск - python main.py --skip-export -- Расчет отчета на момент ПОСЛЕДНЕГО имеющегося снапшота из SQLite - python main.py --snapshot 20260805-001 -- Расчет отчета строго по составному ID снапшота - python main.py --snapshot Y20260805-001 -- Расчет отчета по вчерашнему фиксированному снапшоту - python main.py --snapshot "2026-08-05 09:23:31" -- Расчет отчета по точной метке времени создания + python main.py --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite + python main.py --snapshot 20260805-001 -- Расчет отчета строго по ID снапшота python main.py -d -- Запуск в режиме расширенной отладки (DEBUG) """ parser = argparse.ArgumentParser( @@ -169,9 +206,8 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки с выводом подробных логов") - parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL и построить отчет на момент последнего имеющегося снапшота из SQLite") - parser.add_argument('--snapshot', type=str, default=None, help="Составной ID (например, '20260805-001', 'Y20260805-001') или время создания конкретного снапшота") - parser.add_argument('--with-xlsx', action='store_true', help="Сохранять дублирующие сырые XLSX-файлы в папке data/") + 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 @@ -190,7 +226,7 @@ def main(): print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{snapshot_param}'") elif args.skip_export: snapshot_param = get_latest_snapshot_time() - print(f"[📸] РЕЖИМ --skip-export: Используем самый последний снапшот из SQLite ('{snapshot_param}')") + print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{snapshot_param}')") else: snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'") @@ -199,7 +235,7 @@ def main(): try: run_export(save_xlsx=True, debug=DEBUG) except Exception as e: - print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к имеющимся записям в SQLite.") + print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.") else: print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...") @@ -259,84 +295,96 @@ def main(): static_reasons_dict = load_static_reason_workers() # ============================================================ - # 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА + # 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ГАРАНТИРОВАННЫЙ РАСЧЕТ) # ============================================================ - if has_scud_logs_for_date(DATE_YESTERDAY) and not args.snapshot: - print(f"\n[3/5] Вчерашние данные за {DATE_YESTERDAY} уже обработаны и зафиксированы в SQLite. Повторный расчет пропущен.") - else: - print("\n[3/5] Обработка данных за ВЧЕРА...") - - 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 + 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' - ) - - # 1. ПРИОРИТЕТ 1: 1С-документ - 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']) - - # 2. ПРИОРИТЕТ 2: Реестр статических причин - 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 - - # 3. ПРИОРИТЕТ 3: Исключения из exceptions.json (ТОЛЬКО при пустом 1С) - 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) + 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 ) - merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' - generate_detailed_excel(merged_df=merged_yesterday, date_str=DATE_YESTERDAY) + 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 + ) - save_scud_to_db(merged_yesterday, DATE_YESTERDAY, snapshot_time=snapshot_param if not args.snapshot and not args.skip_export else None, is_yesterday=True) + 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: ЕЖЕДНЕВНАЯ СВОДКА ЗА СЕГОДНЯ @@ -382,7 +430,6 @@ def main(): on='fio_clean', how='left' ) - # 1. ПРИОРИТЕТ 1: 1С-документы 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') @@ -394,7 +441,6 @@ def main(): if 'Сотрудник' not in merged_today.columns: merged_today['Сотрудник'] = merged_today.get('ФИО', merged_today['fio_clean']) - # 2. ПРИОРИТЕТ 2: Реестр статических причин if static_reasons_dict: for fio_clean, reason_val in static_reasons_dict.items(): mask_today = ( @@ -404,7 +450,6 @@ def main(): ) merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val - # 3. ПРИОРИТЕТ 3: Исключения из exceptions.json (ТОЛЬКО при пустом 1С) merged_today = apply_exceptions_from_json(merged_today, exceptions_cfg) mask_exc_today = ( (merged_today['Пришел'] == False) & @@ -413,28 +458,65 @@ def main(): ) merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' - anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules) + # Расчет флага 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) + ) - mass_failure_result = analyze_scud_mass_failure_ai(merged_today) - if mass_failure_result and mass_failure_result.get("is_mass_failure"): + 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_result['anomaly_percent']}% СМЕНЫ)") - print(mass_failure_result["alert_text"]) + print(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ ОПЕРАТИВНЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА СЕГОДНЯ ({mass_failure_today['anomaly_percent']}% СМЕНЫ)") + print(mass_failure_today["alert_text"]) print("!" * 60 + "\n") save_anomalies_to_db(anomalies_list, DATE_TODAY) - absent_explained = merged_today[(merged_today['Пришел'] == False) & (merged_today['Вид_отсутствия'].notna())] - absent_unexplained = merged_today[(merged_today['Пришел'] == False) & (merged_today['Вид_отсутствия'].isna())] - scud_present_but_absent_in_1c = merged_today[(merged_today['Пришел'] == True) & (merged_today['Вид_отсутствия'].notna())] + # Флаги фильтрации для ИИ-репортера + 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=anomalies_list, + anomalies_list=filtered_anomalies_list, raw_scud_df=raw_scud_today_df, raw_absent_df=df_absent_today, date_str=DATE_TODAY diff --git a/scripts/add_rule_ai.py b/scripts/add_rule_ai.py new file mode 100644 index 0000000..dbb6930 --- /dev/null +++ b/scripts/add_rule_ai.py @@ -0,0 +1,19 @@ +import sys +import os + +# Добавляем корень проекта в путь поиска модулей Python +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.database import get_connection + +rule_text = ( + "Сотрудники, присутствующие в 1С:ЗУП, но отсутствующие в СКУД Орион Pro, " + "являются аномалией синхронизации профилей. ИИ должен запрашивать у СБ статус выдачи пропуска." +) + +with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text) VALUES (?)", (rule_text,)) + conn.commit() + +print("✓ Правило успешно внесено в SQLite БД!") \ No newline at end of file diff --git a/scripts/db_cli.py b/scripts/db_cli.py index 5e8a036..deae354 100644 --- a/scripts/db_cli.py +++ b/scripts/db_cli.py @@ -35,23 +35,22 @@ def print_stats(): def print_snapshots_list(date_str=None): - """Выводит реестр снапшотов с идеальным вертикальным выравниванием дефисов и цифр.""" + """Выводит реестр снапшотов с отображением даты и точного времени среза.""" rows = get_available_snapshots(date_str) - print("\n" + "=" * 95) + print("\n" + "=" * 105) print(f"📸 РЕЕСТР СОХРАНЕННЫХ СНАПШОТОВ (СВЕРХУ СВЕЖИЕ) {'ЗА ЛОГИ ' + date_str if date_str else ''}:") - print("=" * 95) + print("=" * 105) - header = f"{'ID снапшота':<16} | {'Дата снапшота (создания)':<24} | {'Дата среза':<12} | {'Записей':<8}" + header = f"{'ID снапшота':<16} | {'Дата снапшота (создания)':<24} | {'Дата и время среза':<20} | {'Записей':<8}" print(header) - print("-" * 95) + print("-" * 105) if not rows: print("Снапшотов пока нет.") - print("=" * 95 + "\n") + print("=" * 105 + "\n") return - # Внутрипитоновская хронологическая сортировка: сначала snap_time DESC, затем seq_num DESC def snapshot_sort_key(row): snap_id = row[0] or "" snap_time = row[2] or "" @@ -72,15 +71,43 @@ def print_snapshots_list(date_str=None): snap_time = r[2] if r[2] else '—' count = r[3] - # 💡 ИДЕАЛЬНОЕ ВЫРАВНИВАНИЕ: если нет префикса Y, добавляем ведущий пробел + # Извлекаем время (ЧЧ:ММ:СС) из snapshot_time, если оно есть + time_part = "—" + if snap_time and " " in snap_time: + time_part = snap_time.split(" ")[1] + + slice_datetime_str = f"{log_date} {time_part}" if time_part != "—" else log_date + if not snap_id.startswith("Y"): formatted_snap_id = f" {snap_id}" else: formatted_snap_id = snap_id - print(f"{formatted_snap_id:<16} | {snap_time:<24} | {log_date:<12} | {count:<8}") + print(f"{formatted_snap_id:<16} | {snap_time:<24} | {slice_datetime_str:<20} | {count:<8}") - print("=" * 95 + "\n") + print("=" * 105 + "\n") + + +def delete_snapshot_by_id(snapshot_id: str): + """Удаляет конкретный снапшот из таблицы scud_logs по его ID.""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) + deleted_count = cursor.rowcount + conn.commit() + print(f"\n[✓] Успешно удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}\n") + return deleted_count + + +def delete_snapshots_by_date(date_str: str): + """Удаляет все снапшоты за указанную дату (например, '04.08.2026').""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%")) + deleted_count = cursor.rowcount + conn.commit() + print(f"\n[✓] Успешно удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}\n") + return deleted_count def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None): @@ -171,28 +198,42 @@ def main(): help_text = """ CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) +ДОСТУПНЫЕ КОМАНДЫ: + stats -- Общая статистика строк по всем таблицам БД + snapshots [ДД.ММ.ГГГГ] -- Посмотреть список всех снапшотов (опционально за дату) + scud [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД и опциональный экспорт в Excel + anomalies -- Посмотреть все найденные аномалии СКУД ⟷ 1С + rules -- Посмотреть правила Базы Знаний ИИ из SQLite + dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel + snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшотов по ID или за весь день + ПРИМЕРЫ ЗАПУСКА: - python scripts/db_cli.py scud -- Выгрузка СКУД за текущую дату (последний снапшот) - python scripts/db_cli.py scud --snapshot 20260805-001 -- Выгрузка СКУД строго по ID конкретного снапшота - python scripts/db_cli.py scud --export-xlsx debug_today -- Выгрузить срез СКУД в форматированный Excel-файл - python scripts/db_cli.py snapshots -- Посмотреть список всех снапшотов с их ID (от новых к старым) - python scripts/db_cli.py snapshots 04.08.2026 -- Посмотреть снапшоты за конкретную дату - python scripts/db_cli.py stats -- Общая статистика строк по всем таблицам БД - python scripts/db_cli.py anomalies -- Посмотреть все найденные аномалии СКУД ⟷ 1С - python scripts/db_cli.py rules -- Посмотреть правила Базы Знаний ИИ из SQLite - python scripts/db_cli.py dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel + python scripts/db_cli.py stats + python scripts/db_cli.py snapshots 04.08.2026 + python scripts/db_cli.py scud --snapshot Y20260805-007 --export-xlsx вчера_1 + python scripts/db_cli.py snapshot del Y20260805-007 + python scripts/db_cli.py snapshot del --day 04.08.2026 """ parser = argparse.ArgumentParser( description=help_text, - formatter_class=argparse.RawDescriptionHelpFormatter + formatter_class=argparse.RawDescriptionHelpFormatter, + add_help=True ) - parser.add_argument('command', nargs='?', default='stats', choices=['stats', 'snapshots', 'scud', 'anomalies', 'rules', 'dump'], help="Команда: stats | snapshots | scud | anomalies | rules | dump") - parser.add_argument('param', nargs='?', default=None, help="Параметр команды (дата в формате ДД.ММ.ГГГГ или имя файла)") - parser.add_argument('--snapshot', type=str, default=None, help="Выбрать конкретный ID снапшота для инспекции (например, '20260805-001', 'Y20260805-001')") - parser.add_argument('--export-xlsx', type=str, default=None, help="Экспортировать выбранный срез СКУД в Excel-файл") + parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'anomalies', 'rules', 'dump', 'snapshot'], help="Основная команда") + parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')") + parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)") + parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции") + parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл") + parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)") + + # Если аргументы вообще не переданы, выводим статистику по умолчанию (или справку) + if len(sys.argv) == 1: + print_stats() + return args = parser.parse_args() + # Валидация команд if args.command == 'stats': print_stats() elif args.command == 'snapshots': @@ -206,7 +247,21 @@ CLI-утилита инспекции и управления SQLite базой elif args.command == 'dump': filename = args.param if args.param else "db_dump_full.xlsx" dump_all_to_excel(filename) - + elif args.command == 'snapshot': + if args.action == 'del': + if args.day: + delete_snapshots_by_date(args.day) + elif args.param: + delete_snapshot_by_id(args.param) + else: + print("\n[❌] Ошибка: Не указан ID снапшота или параметр --day для удаления.") + print("Пример: python scripts/db_cli.py snapshot del Y20260805-007\n") + else: + print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.") + print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n") + else: + print(f"\n[❌] Ошибка: Неизвестная команда.") + parser.print_help() if __name__ == "__main__": main() \ No newline at end of file diff --git a/services/ai_verifier.py b/services/ai_verifier.py index d959055..cd37cf8 100644 --- a/services/ai_verifier.py +++ b/services/ai_verifier.py @@ -36,19 +36,19 @@ def ask_ollama(prompt, system_prompt=None): def get_system_rules_context(): """ - Загружает глобальные системные правила компании прямо из Базы Данных SQLite. + Загружает динамические системные правила компании напрямую из Базы Данных SQLite. """ rules = get_all_rules_from_db() if not rules: return "" rules_text = "\n".join([f"- {r}" for r in rules]) - return f"\nОБЯЗАТЕЛЬНЫЕ ГЛОБАЛЬНЫЕ ПРАВИЛА И ПРИОРИТЕТЫ КОМПАНИИ:\n{rules_text}\n" + return f"\nОБЯЗАТЕЛЬНЫЕ ГЛОБАЛЬНЫЕ ПРАВИЛА И ПРИОРИТЕТЫ КОМПАНИИ (ИЗ SQLITE БД):\n{rules_text}\n" def analyze_scud_mass_failure_ai(df_scud): """ Оценивает процент аномалий 'ANOMALY_NO_IN_HAS_ACTIVITY' в выгрузке. - Если процент аномалий превышает 5% от смены, вызывает ИИ для генерации критического алерта. + Если процент аномалий превышает 5% от смены или 10 человек, вызывает ИИ для генерации критического алерта. """ if df_scud is None or df_scud.empty: return None @@ -66,9 +66,9 @@ def analyze_scud_mass_failure_ai(df_scud): rules_context = get_system_rules_context() prompt = f""" {rules_context} -ВНИМАНИЕ! Проведён анализ дневной смены СКУД: +ВНИМАНИЕ! Проведён анализ смены СКУД: - Всего записей за смену: {total_records} -- Выявлено сотрудников без утреннего входа, но с зафиксированной дневной активностью (первая активность): {anomaly_count} ({anomaly_percent}% от смены) +- Выявлено сотрудников без утреннего входа, но с зафиксированной дневной активностью: {anomaly_count} ({anomaly_percent}% от смены) Сформируй понятное предупреждение для Администратора СКУД и Руководителя. Объясни, что это критический массовый сбой турникетов/контроллеров входа на КПП, и порекомендуй действия. @@ -90,13 +90,16 @@ def analyze_scud_mass_failure_ai(df_scud): def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): - """ - ИИ-сверка опечаток и несовпадений ФИО между СКУД и 1С:ЗУП. - Записи из 1С считаются 100% эталоном. - """ if not unrecognized_scud_fios or not staff_fios: return {} + # 🛡 Исключаем точные совпадения (чтобы ИИ не совершал ложных замен) + staff_fios_set = set(staff_fios) + real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set] + + if not real_unrecognized: + return {} + rules_context = get_system_rules_context() prompt = f""" @@ -110,7 +113,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): 2. В СКУД допущена опечатка. 3. Любое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (кроме командировок) является гарантированной аномалией. 4. В поле "warning" опиши обнаруженную опечатку. -5. Запрещено выдумывать опечатки! +5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев! ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON! @@ -148,10 +151,10 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): return mapping + def ai_verify_department_exceptions(unexplained_df, exception_departments): """ - Локальный ИИ проверит, не являются ли оставшиеся неотмеченными отделы - синонимами/аббревиатурами отделов из exceptions.json. + Локальный ИИ проверяет, не являются ли неотмеченные отделы синонимами отделов-исключений. """ if unexplained_df.empty or not exception_departments: return [] diff --git a/services/data_loader.py b/services/data_loader.py index 88af675..14e53e0 100644 --- a/services/data_loader.py +++ b/services/data_loader.py @@ -7,6 +7,7 @@ from config import ( ) # Импортируем прямую SQL-выгрузку отсутствий из 1С from services.zup_extractor import fetch_zup_absences_from_sql +from core.database import load_scud_from_db_by_snapshot warnings.filterwarnings('ignore', category=UserWarning, module='pandas') @@ -138,27 +139,40 @@ def process_scud_anomalies(df_scud, exceptions=None): return df_scud -def load_scud_data(target_date_type="today"): - """Загружает файл СКУД за 'today' или 'yesterday' и выполняет валидацию аномалий.""" +def load_scud_data(target_date_type="today", snapshot_param=None): + """ + Загружает логи СКУД за 'today' или 'yesterday' НАПРЯМУЮ из локальной базы SQLite. + """ target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY - scud_path = find_dated_file("Сотрудники", target_date, search_dirs=[SCUD_DIR]) + + # Загружаем данные строго из SQLite БД + df_scud = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_param) + + if df_scud is None or df_scud.empty: + # Fallback на случай отсутствия записей за вчера — пробуем взять за сегодня + df_scud = load_scud_from_db_by_snapshot(DATE_TODAY, snapshot_param=snapshot_param) + if df_scud is None or df_scud.empty: + raise FileNotFoundError(f"В базе SQLite не найдены логи СКУД за {target_date}!") - if not scud_path: - scud_path = find_dated_file("Сотрудники", DATE_TODAY, search_dirs=[SCUD_DIR]) - if not scud_path: - raise FileNotFoundError(f"Файл СКУД [{target_date_type}] за {target_date} не найден в {SCUD_DIR}!") - - df_scud = pd.read_excel(scud_path) exceptions = load_exceptions() - df_scud['fio_raw'] = df_scud['Сотрудник'].astype(str) if 'Сотрудник' in df_scud.columns else df_scud[df_scud.columns[0]].astype(str) - df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light) + if 'fio_clean' not in df_scud.columns: + fio_raw_col = 'fio' if 'fio' in df_scud.columns else ('Сотрудник' if 'Сотрудник' in df_scud.columns else df_scud.columns[0]) + df_scud['fio_raw'] = df_scud[fio_raw_col].astype(str) + df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light) - # Применяем обработчик аномалий и первой активности - df_scud = process_scud_anomalies(df_scud, exceptions=exceptions) + # Нормализация колонок под единый стандарт отчета + if 'Начало_дня' not in df_scud.columns and 'time_in' in df_scud.columns: + df_scud['Начало_дня'] = df_scud['time_in'] + if 'Конец_дня' not in df_scud.columns and 'time_out' in df_scud.columns: + df_scud['Конец_дня'] = df_scud['time_out'] + if 'Первая_активность' not in df_scud.columns and 'first_activity' in df_scud.columns: + df_scud['Первая_активность'] = df_scud['first_activity'] + if 'Пришел' not in df_scud.columns and 'is_present' in df_scud.columns: + df_scud['Пришел'] = df_scud['is_present'].astype(bool) df_scud['is_excluded'] = df_scud.apply( - lambda r: is_excluded(r.get('Сотрудник', ''), r.get('Подразделение', ''), r.get('Должность', ''), exceptions), + lambda r: is_excluded(r.get('fio', r.get('Сотрудник', '')), r.get('department', r.get('Подразделение', '')), r.get('position', r.get('Должность', '')), exceptions), axis=1 ) return df_scud @@ -166,8 +180,7 @@ def load_scud_data(target_date_type="today"): def load_all_data(): """ - Загружает слитные датасеты за Сегодня и за Вчера. - Если данных за Сегодня нет, возвращает для них None. + Загружает слитные датасеты за Сегодня и за Вчера напрямую из базы SQLite и 1С. """ df_scud_today = load_scud_data(target_date_type="today") try: @@ -175,11 +188,9 @@ def load_all_data(): except FileNotFoundError: df_scud_yesterday = df_scud_today.copy() - # Загружаем 1С за Сегодня (может быть None) df_staff_today = load_staff_data(DATE_TODAY) df_absent_today = load_absent_data(DATE_TODAY) - # Загружаем 1С за Вчера (гарантированно есть) df_staff_yesterday = load_staff_data(DATE_YESTERDAY) df_absent_yesterday = load_absent_data(DATE_YESTERDAY) diff --git a/services/excel_exporter.py b/services/excel_exporter.py index db6a6ca..f83d9ca 100644 --- a/services/excel_exporter.py +++ b/services/excel_exporter.py @@ -36,13 +36,15 @@ FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_typ FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") +FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Мягкий пастельно-голубой для "Нет пропуска" CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"] # Палитра для Детального отчета YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # Обычные отсутствия -LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Неизвестные случаи +LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Потенциальные прогулы GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Аномалии (пришел в отпуске) +LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Бледно-голубой для "Нет пропуска" def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30): @@ -130,8 +132,17 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): current_row = 4 - # 3. НЕИЗВЕСТНО (По умолчанию РАСКРЫТО, чистые ФИО без цифр) - unexplained = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna())] + # Подготовка флагов для точной фильтрации + is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False + is_exc = merged_df.get('is_excluded', False) == True + + # 3. НЕИЗВЕСТНО (Исключаем категорию "Нет пропуска" и "Исключения ОВК/Охрана") + unexplained = merged_df[ + (merged_df['Пришел'] == False) & + (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) & + (~is_no_pass) & + (~is_exc) + ] ws.cell(row=current_row, column=1, value="неизвестно") ws.cell(row=current_row, column=2, value=len(unexplained)) format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font) @@ -144,7 +155,23 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): ws.row_dimensions[current_row].hidden = False current_row += 1 - # 4. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True) + # 4. РАЗДЕЛ: НЕТ ПРОПУСКА (Строго один независимый блок) + no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() + + ws.cell(row=current_row, column=1, value="Нет пропуска") + ws.cell(row=current_row, column=2, value=len(no_pass_df)) + format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font) + current_row += 1 + + if not no_pass_df.empty: + for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()): + ws.cell(row=current_row, column=1, value=fio) + format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False) + ws.row_dimensions[current_row].outlineLevel = 1 + ws.row_dimensions[current_row].hidden = False + current_row += 1 + + # 5. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True) absent_only = merged_df[ (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & @@ -168,7 +195,7 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): ws.row_dimensions[current_row].hidden = True current_row += 1 - # 5. ИТОГО НА РАБОТЕ (Свернут hidden=True) + # 6. ИТОГО НА РАБОТЕ (Свернут hidden=True) is_working_mask = (merged_df['Пришел'] == True) | ( merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) ) @@ -187,10 +214,12 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): ws.row_dimensions[current_row].hidden = True current_row += 1 - # 6. АНОМАЛИИ (Свернут hidden=True) + # 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ) anomalies = merged_df[ - ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna())) | - (merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY') + (~is_exc) & ( + ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) | + (merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY') + ) ] ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С") @@ -246,7 +275,7 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") -# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (С КОЛОНКОЙ 'первая активность' СРАЗУ ПОСЛЕ 'время входа') --- +# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА --- def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): if not filename: filename = f"{format_date_ru(date_str)} отчет.xlsx" @@ -261,7 +290,6 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): ws["B2"].font = Font(name="Arial", size=10, bold=True) ws["D2"].font = Font(name="Arial", size=10, bold=True) - # ⭐️ Порядок колонок: "первая активность" строго после "время входа" headers = [ "№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода", "находился в здании", "причина отсутствия", "норма", "отклонение от нормы" @@ -290,16 +318,18 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): anom_flag = row.get('anomaly_flag', 'NONE') in_building_str = str(row.get(hours_col, '00:00')) - first_act_val = row.get('Первая_активность', '—') - + first_act_val = str(row.get('Первая_активность', '—')).strip() + has_first_act = first_act_val not in ['—', '', 'None', 'nan'] + + dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30) ws.append([ idx + 1, row.get('Сотрудник', ''), - row.get('Подразделение', ''), + dept_scud_val, row.get(start_col, 'Нет входа'), - first_act_val, # ⭐️ Колонки №5: "первая активность" + first_act_val, row.get(end_col, 'Нет выхода'), in_building_str, absence_reason if has_reason else '', @@ -309,13 +339,14 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): row_num = 5 + idx - # Заливка строк по категориям - if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY': - row_fill = LIGHT_RED_FILL - elif is_present and has_reason: + # ЗАЛИВКА СТРОК: + if is_present and has_reason: row_fill = GREEN_FILL - elif not is_present: - row_fill = YELLOW_FILL if has_reason else LIGHT_RED_FILL + elif not is_present and has_reason: + row_fill = YELLOW_FILL + elif not is_present and not has_reason and not has_first_act: + # Розово-красный подсвечивает исключительно неизвестные случаи (потенциальные прогулы) + row_fill = LIGHT_RED_FILL else: row_fill = None @@ -339,18 +370,21 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): else: cell.alignment = Alignment(horizontal="left", vertical="center") + # Динамический компактный автоподгон ширины колонок for col in ws.columns: col_letter = get_column_letter(col[0].column) - if col_letter == 'H': - ws.column_dimensions['H'].width = 25.0 - else: - max_len = 0 - for cell in col: - if cell.value is not None: - line_max = max(len(l) for l in str(cell.value).split("\n")) - if line_max > max_len: - max_len = line_max - ws.column_dimensions[col_letter].width = max(max_len + 4, 12) + max_len = 0 + for cell in col: + if cell.value is not None: + cell_lines = str(cell.value).split("\n") + line_max = max(len(line) for line in cell_lines) + if line_max > max_len: + max_len = line_max + + optimal_width = max(max_len + 2, 8) + if optimal_width > 35: + optimal_width = 35 + ws.column_dimensions[col_letter].width = optimal_width try: wb.save(output_path) diff --git a/services/scud_export.py b/services/scud_export.py index fb8fbae..c1848ca 100644 --- a/services/scud_export.py +++ b/services/scud_export.py @@ -208,8 +208,6 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo os.makedirs(SCUD_DIR, exist_ok=True) targets = get_targets(input_date) - snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn_str = ( f"DRIVER={{{ODBC_DRIVER}}};" f"SERVER={SERVER_NAME};" @@ -227,13 +225,18 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo period_label = target["name"] is_yesterday = (period_label == "Вчера") - # ⚡️ ПРОВЕРКА: Если вчерашние данные уже есть в SQLite, повторную выгрузку из MS SQL пропускаем + # ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE: + # Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL if is_yesterday and has_scud_logs_for_date(processing_date_str): - log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---") - log(f"[ℹ️] Вчерашние логи СКУД за {processing_date_str} уже присутствуют в SQLite. Повторная выгрузка пропущена.", "INFO") + log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован в SQLite. Пропускаем запрос к MS SQL.") continue - log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---") + if is_yesterday: + snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 22:00:00" + else: + snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Снапшот: {snapshot_time}]") sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d")) connection = None