diff --git a/HOWTO/помощь со скриптами диагностики.md b/HOWTO/помощь со скриптами диагностики.md new file mode 100644 index 0000000..4e51764 --- /dev/null +++ b/HOWTO/помощь со скриптами диагностики.md @@ -0,0 +1,59 @@ +# 🛠 HOWTO: Помощь в технической диагностике системы (`scud_orion_ai_v2`) + +Данный документ содержит руководство по работе со служебными диагностическими утилитами проекта. Скрипты предназначены для быстрой оценки состояния базы данных SQLite, анализа файловой структуры и формирования контекстного снимка кодовой базы для ИИ. + +--- + +## 📋 Содержимое папки `scripts/diagnostics/` + +Рекомендуется размещать диагностические скрипты в отдельной директории `scripts/diagnostics/`: + +1. **`inspect_db.py`** — Инспектор таблиц и записей базы данных SQLite. +2. **`inspect_files.py`** — Сканер дискового пространства и структуры файлов. +3. **`make_code_snapshot.py`** — Генератор единого текстового слепка кода для ИИ. + +--- + +## 🚀 Назначение и запуск утилит + +### 1. Инспектор базы данных SQLite (`inspect_db.py`) + +* **Назначение:** + * Проверяет доступность базы данных (`data/scud_orion_ai.db`). + * Автоматически считывает список всех таблиц и их колонок. + * Подсчитывает количество записей в каждой таблице (`scud_logs`, `zup_staff`, `zup_absences` и др.). + * Выводит список всех активных правил из таблицы `ai_knowledge_base`. + +* **Команда для запуска:** + ```bash + python scripts/diagnostics/inspect_db.py +2. Сканер файловой структуры (inspect_files.py) +Назначение: + +Обходит структуру каталогов проекта. + +Показывает точный размер каждого файла в байтах и итоговый вес проекта в мегабайтах. + +Автоматически игнорирует служебные и временные папки (.git, __pycache__, venv, .venv). + +Команда для запуска: + +Bash +python scripts/diagnostics/inspect_files.py +3. Генератор контекстного снимка кода (make_code_snapshot.py) +Назначение: + +Собирает весь исходный код Python (.py), настройки (.json), инструкции (.md) и скрипты (.sh) в единый файл project_code_snapshot.md. + +Исключает тяжелые архивы, логи и виртуальное окружение. + +Сформированный файл передается нейросети (LLM) для быстрого погружения в контекст проекта без потери архитектурных деталей. + +Команда для запуска: + +Bash +python scripts/diagnostics/make_code_snapshot.py +🎯 Чек-лист применения при сбоях +Ошибка или несостыковка в отчетах: запустить inspect_db.py и проверить, заполнены ли таблицы scud_logs и zup_absences. + +Передача задачи или кода ИИ-ассистенту: запустить make_code_snapshot.py и прикрепить итоговый project_code_snapshot.md к запросу. \ No newline at end of file diff --git a/compare_xlsx.py b/compare_xlsx.py new file mode 100644 index 0000000..2755c89 --- /dev/null +++ b/compare_xlsx.py @@ -0,0 +1,21 @@ +import openpyxl + +wb_good = openpyxl.load_workbook('output/5 августа 2026 отчет.xlsx') # или ваш путь к рабочему +wb_bad = openpyxl.load_workbook('output/10 августа 2026 отчет.xlsx') + +ws_good = wb_good.active +ws_bad = wb_bad.active + +print(f"Размер хорошего: {ws_good.max_row} строк, {ws_good.max_column} колонок") +print(f"Размер с ошибкой: {ws_bad.max_row} строк, {ws_bad.max_column} колонок") + +# Сравним типы ячеек в колонках +for r in range(4, min(ws_good.max_row, ws_bad.max_row) + 1): + for c in range(1, 11): + v_g = ws_good.cell(row=r, column=c).value + v_b = ws_bad.cell(row=r, column=c).value + t_g = type(v_g) + t_b = type(v_b) + if t_g != t_b: + + print(f"Строка {r}, колонка {c}: тип в хорошем {t_g}, в плохом {t_b}") \ No newline at end of file diff --git a/core/database.py b/core/database.py index a57063e..0e0ed14 100644 --- a/core/database.py +++ b/core/database.py @@ -231,57 +231,73 @@ def get_latest_snapshot_time(date_str=None): def load_scud_from_db_by_snapshot(date_str, snapshot_param=None): """ - Загружает логи СКУД по ID снапшота ('20260805-001', 'Y20260805-001') или по штампу времени. - С автоматическим маппингом полей Подразделения и Должности. + Загружает логи СКУД по ID снапшота. + Если snapshot_param не задан (или искался прошлый день), приоритетно выбирает + финальный вечерний снапшот, начинающийся с буквы 'Y' (срез на 22:00) за дату date_str. """ with get_connection() as conn: df = pd.DataFrame() + # 1. Если передан конкретный ID снапшота (например, '20260810-001') if snapshot_param: - if "-" in str(snapshot_param) or str(snapshot_param).startswith('Y'): - df = pd.read_sql_query( - "SELECT * FROM scud_logs WHERE log_date = ? AND snapshot_id = ?", - conn, params=(date_str, str(snapshot_param)) - ) - if df.empty: - df = pd.read_sql_query( - "SELECT * FROM scud_logs WHERE snapshot_id = ?", - conn, params=(str(snapshot_param),) - ) + df = pd.read_sql_query( + "SELECT * FROM scud_logs WHERE snapshot_id = ?", + conn, params=(str(snapshot_param),) + ) - if df.empty: - df = pd.read_sql_query( - "SELECT * FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", - conn, params=(date_str, str(snapshot_param)) - ) - - if df.empty: - latest_time = get_latest_snapshot_time(date_str) - if latest_time: - df = pd.read_sql_query( - "SELECT * FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", - conn, params=(date_str, latest_time) - ) - - if not df.empty: - df['Сотрудник'] = df['fio'] - df['Пришел'] = df['is_present'].astype(int) == 1 if 'is_present' in df.columns else False - df['Начало_дня'] = df['time_in'] - df['Первая_активность'] = df['first_activity'] - df['Конец_дня'] = df['time_out'] - df['Находился_в_здании'] = df['time_in_building'] + # 2. Если по snapshot_param ничего не найдено ИЛИ snapshot_param=None (запрос за вчерашний день): + if df.empty and date_str: + cursor = conn.cursor() - # 💡 Маппинг колонок БД в стандартные русские наименования - if 'department' in df.columns and 'Подразделение' not in df.columns: - df['Подразделение'] = df['department'] - if 'position' in df.columns and 'Должность' not in df.columns: - df['Должность'] = df['position'] - else: - df = pd.DataFrame(columns=[ - 'Сотрудник', 'fio_clean', 'Подразделение', 'Должность', - 'Начало_дня', 'Первая_активность', 'Конец_дня', - 'Находился_в_здании', 'Пришел', 'anomaly_flag' - ]) + # ⭐️ ПРИОРИТЕТ 1: Ищем срез, начальный символ которого 'Y' (вечерний зафиксированный Y-снапшот) + cursor.execute( + "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1", + (date_str,) + ) + row = cursor.fetchone() + + # ⭐️ ПРИОРИТЕТ 2: Если Y-снапшот не найден, берем самый свежий обычный + if not row: + cursor.execute( + "SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1", + (date_str,) + ) + row = cursor.fetchone() + + if row and row[0]: + target_snap_id = row[0] + df = pd.read_sql_query( + "SELECT * FROM scud_logs WHERE snapshot_id = ?", + conn, params=(target_snap_id,) + ) + + # Приводим названия колонок к стандарту + if not df.empty: + rename_map = { + 'department': 'Подразделение', + 'position': 'Должность', + 'fio': 'Сотрудник', + 'time_in': 'Начало_дня', + 'first_activity': 'Первая_активность', + 'time_out': 'Конец_дня', + 'duration': 'Находился_в_здании', + 'is_present': 'Пришел' + } + # Переименовываем имеющиеся колонки + df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns}) + + # Заполняем недостающие колонки значениями по умолчанию, если их не было в БД + required_cols = ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag'] + for col in required_cols: + if col not in df.columns: + if col == 'Пришел': + df[col] = False + else: + df[col] = '—' + + # Приводим тип флага Пришел к bool + if 'Пришел' in df.columns: + df['Пришел'] = df['Пришел'].astype(bool) return df @@ -443,3 +459,22 @@ def add_department_synonym_to_db(short_name, full_name): ) conn.commit() print(f"[✓] В базу SQLite добавлен новый синоним отдела: '{short_name}' ⟷ '{full_name}'") + +def load_staff_from_db(date_str): + """Загружает Штатное расписание 1С из базы SQLite за указанную дату.""" + with get_connection() as conn: + df = pd.read_sql_query( + "SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?", + conn, params=(date_str,) + ) + return df if not df.empty else None + +def load_absences_from_db(date_str): + """Загружает документальные отсутствия 1С из базы SQLite за указанную дату.""" + with get_connection() as conn: + df = pd.read_sql_query( + "SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?", + conn, params=(date_str,) + ) + return df if not df.empty else None + \ No newline at end of file diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index 0283c27..ea4bd45 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 e409130..3a1b6f5 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,7 @@ import sys import json import argparse import pandas as pd -from datetime import datetime +from datetime import datetime, timedelta sys.path.append(os.path.dirname(os.path.abspath(__file__))) @@ -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, load_scud_data +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 @@ -24,45 +24,12 @@ from core.database import ( 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 ) -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 из корня проекта.""" @@ -80,18 +47,18 @@ def load_exceptions_config(): def apply_exceptions_from_json(df, exceptions_cfg): """ - Размечает флаг is_excluded=True на основе правил из exceptions.json. - Сверяет отдел одновременно по полному имени из 1С и по короткой аббревиатуре из СКУД. + Быстрая и эффективная разметка флага 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.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", [])] + 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 @@ -102,8 +69,12 @@ def apply_exceptions_from_json(df, exceptions_cfg): 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 + 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 @@ -127,7 +98,6 @@ def filter_report_dataframe(merged_df): 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 @@ -150,7 +120,6 @@ def load_static_reason_workers(): 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 "" @@ -168,7 +137,6 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set 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) @@ -180,11 +148,9 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set "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({ @@ -193,7 +159,6 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set "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({ @@ -274,65 +239,84 @@ def main(): else: print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...") - copy_1c_files_from_share() + # ⚡️ ПРОВЕРКА ШАРЫ ТОЛЬКО ДЛЯ ОБЫЧНОГО ДНЕВНОГО ЗАПУСКА + 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() + 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 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) + if not is_valid: + print("\n" + "!" * 60) + print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Отсутствуют критически важные файлы за вчерашний день!") + for e in errors: + print(f" {e}") + print("!" * 60) + sys.exit(1) - print("[✓] Проверка доступности данных успешно пройдена!\n") + print("[✓] Проверка доступности данных успешно пройдена!\n") + else: + 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(DATE_TODAY, snapshot_param) - raw_scud_yesterday_df = load_scud_from_db_by_snapshot(DATE_YESTERDAY, snapshot_param) + raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snapshot_param) - if raw_scud_yesterday_df.empty: - raw_scud_yesterday_df = load_scud_from_db_by_snapshot(DATE_YESTERDAY) + 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 - ( - _, _, - df_staff_today, df_absent_today, - df_staff_yesterday, df_absent_yesterday - ) = load_all_data() + 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: - ( - raw_scud_today_df, raw_scud_yesterday_df, - df_staff_today, df_absent_today, - df_staff_yesterday, df_absent_yesterday - ) = load_all_data() + 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, DATE_YESTERDAY) + save_staff_to_db(df_staff_yesterday, yesterday_date_str) if df_absent_yesterday is not None: - save_absences_to_db(df_absent_yesterday, DATE_YESTERDAY) + save_absences_to_db(df_absent_yesterday, yesterday_date_str) if df_staff_today is not None: - save_staff_to_db(df_staff_today, DATE_TODAY) + save_staff_to_db(df_staff_today, target_date_str) if df_absent_today is not None: - save_absences_to_db(df_absent_today, DATE_TODAY) + 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: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ГАРАНТИРОВАННЫЙ РАСЧЕТ) + # 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ДЕНЬ НАКАНУНЕ) # ============================================================ - print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({DATE_YESTERDAY})...") + 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 @@ -356,7 +340,9 @@ def main(): 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() + # ⭐️ ФОРМИРУЕМ МЕРДЖ ИСКЛЮЧИТЕЛЬНО НА БАЗЕ ШТАТА ЗА ПРОШЛЫЙ ДЕНЬ + 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: @@ -371,8 +357,16 @@ def main(): 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') + merged_yesterday = merged_yesterday.merge( + df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], + on='fio_clean', + how='left' + ) if 'Пришел' not in merged_yesterday.columns: merged_yesterday['Пришел'] = False @@ -399,7 +393,6 @@ def main(): ) 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)) & @@ -407,28 +400,25 @@ def main(): (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) + save_anomalies_to_db(anomalies_yesterday_list, yesterday_date_str) - # ⚡️ Фильтрация: исключения НЕ попадают в вчерашний отчет (если нет отпуска из 1С) filtered_yesterday = filter_report_dataframe(merged_yesterday) - generate_detailed_excel(merged_df=filtered_yesterday, date_str=DATE_YESTERDAY) + generate_detailed_excel(merged_df=filtered_yesterday, date_str=yesterday_date_str) - # Фиксируем вчерашний снапшот строго на 22:00:00 - yesterday_dt_obj = datetime.strptime(DATE_YESTERDAY, "%d.%m.%Y") + 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, DATE_YESTERDAY, snapshot_time=yesterday_22_str, is_yesterday=True) - print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite со снапшотом на {yesterday_22_str}") + save_scud_to_db(merged_yesterday, yesterday_date_str, snapshot_time=yesterday_22_str, is_yesterday=True) + print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}") # ============================================================ - # 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА ЗА СЕГОДНЯ + # 🎯 ЧАСТЬ 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) + 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("\n[4/5] Обработка данных за СЕГОДНЯ...") + 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: @@ -493,7 +483,6 @@ def main(): ) 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)) & @@ -504,7 +493,6 @@ def main(): 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) @@ -512,9 +500,8 @@ def main(): print(mass_failure_today["alert_text"]) print("!" * 60 + "\n") - save_anomalies_to_db(anomalies_list, DATE_TODAY) + 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 @@ -524,7 +511,6 @@ def main(): (~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение')) ] - # Истинно неизвестные случаи (исключаем тех, у кого нет пропуска, и спецотделы ОВК/Охрана) absent_unexplained = merged_today[ (merged_today['Пришел'] == False) & (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & @@ -540,7 +526,6 @@ def main(): ] print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...") - # Фильтруем аномалии СКУД для текстового отчета (убираем аномалии "ОТСУТСТВУЕТ В СКУД", т.к. они ушли в раздел "Нет пропуска") filtered_anomalies_list = [ a for a in anomalies_list if "ОТСУТСТВУЕТ В СКУД" not in a.get('type', '') @@ -554,12 +539,12 @@ def main(): anomalies_list=filtered_anomalies_list, raw_scud_df=raw_scud_today_df, raw_absent_df=df_absent_today, - date_str=DATE_TODAY + date_str=target_date_str ) - generate_summary_excel(merged_df=merged_today, date_str=DATE_TODAY) + generate_summary_excel(merged_df=merged_today, date_str=target_date_str) - md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{DATE_TODAY}.md") + 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}") @@ -580,7 +565,7 @@ def main(): print("=" * 60) print(report_text) else: - print(f"\n[ℹ️] Формирование Ежедневной сводки за {DATE_TODAY} ПРОПУЩЕНО.") + print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.") if __name__ == "__main__": diff --git a/scripts/db_cli.py b/scripts/db_cli.py index 055ed4f..5fa6101 100644 --- a/scripts/db_cli.py +++ b/scripts/db_cli.py @@ -71,7 +71,6 @@ def print_snapshots_list(date_str=None): snap_time = r[2] if r[2] else '—' count = r[3] - # Извлекаем время (ЧЧ:ММ:СС) из snapshot_time, если оно есть time_part = "—" if snap_time and " " in snap_time: time_part = snap_time.split(" ")[1] @@ -155,6 +154,30 @@ def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None): print("=" * 90 + "\n") +def print_absences(date_str=None): + """Выводит список официально отсутствующих сотрудников из 1С:ЗУП за выбранный день.""" + target_date = date_str if date_str else DATE_TODAY + print("\n" + "=" * 90) + print(f"📋 ОФИЦИАЛЬНЫЕ ОТСУТСТВИЯ ИЗ 1С:ЗУП ЗА ДАТУ [{target_date}]:") + print("=" * 90) + + with get_connection() as conn: + df = pd.read_sql_query( + "SELECT fio as 'ФИО', absence_type as 'Причина отсутствия 1С' FROM zup_absences WHERE absence_date = ? ORDER BY absence_type, fio", + conn, + params=(target_date,) + ) + + if df.empty: + print(f"Записи об отсутствиях 1С за {target_date} в базе не найдены.") + else: + print(f"Всего зафиксировано документов 1С: {len(df)}") + print("-" * 90) + print(df.to_string(index=False)) + + print("=" * 90 + "\n") + + def print_anomalies(): """Выводит список аномалий СКУД из БД.""" print("\n" + "=" * 80) @@ -193,6 +216,7 @@ def dump_all_to_excel(out_filename="db_dump_full.xlsx"): df.to_excel(writer, sheet_name=table[:31], index=False) print(f"[✓] Дамп успешно сохранен: {out_path}\n") + def print_system_prompts(): """Выводит все системные промпты из базы данных.""" print("\n" + "=" * 80) @@ -211,6 +235,7 @@ def print_system_prompts(): print(f"{r[4]}\n") print("=" * 80 + "\n") + def print_session_states(): """Выводит текущие активные сессии и превью (session_states).""" print("\n" + "=" * 80) @@ -230,14 +255,14 @@ def print_session_states(): print("=" * 80 + "\n") -def main(): - help_text = """ +HELP_TEXT = """ CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) ДОСТУПНЫЕ КОМАНДЫ: stats -- Общая статистика строк по всем таблицам БД snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату) scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel + absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С rules -- Посмотреть правила Базы Знаний ИИ из SQLite prompts -- Посмотреть системные промпты (system_prompts) @@ -250,22 +275,26 @@ CLI-утилита инспекции и управления SQLite базой python scripts/db_cli.py snapshots 06.08.2026 python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг python scripts/db_cli.py scud --snapshot Y20260805-007 + python scripts/db_cli.py absences 07.08.2026 python scripts/db_cli.py prompts python scripts/db_cli.py sessions python scripts/db_cli.py snapshot del Y20260805-007 python scripts/db_cli.py snapshot del --day 04.08.2026 python scripts/db_cli.py dump my_dump.xlsx """ + + +def main(): if any(arg in sys.argv for arg in ['-h', '--help']): - print(help_text) + print(HELP_TEXT) sys.exit(0) parser = argparse.ArgumentParser( - description=help_text, + description=HELP_TEXT, formatter_class=argparse.RawDescriptionHelpFormatter, - add_help=False # Отключаем стандартный вывод справки argparse + add_help=False ) - parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot'], help="Основная команда") + parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', '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 конкретного снапшота для инспекции") @@ -278,7 +307,6 @@ CLI-утилита инспекции и управления SQLite базой args = parser.parse_args() - # Валидация команд if args.command == 'stats': print_stats() elif args.command == 'snapshots': @@ -286,6 +314,9 @@ CLI-утилита инспекции и управления SQLite базой print_snapshots_list(date_str=date_val) elif args.command == 'scud': inspect_scud(snapshot_id=args.snapshot, date_str=args.param, export_xlsx=args.export_xlsx) + elif args.command == 'absences': + date_val = args.param or args.action + print_absences(date_str=date_val) elif args.command == 'anomalies': print_anomalies() elif args.command == 'rules': @@ -311,7 +342,7 @@ CLI-утилита инспекции и управления SQLite базой print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n") else: print(f"\n[❌] Ошибка: Неизвестная команда.") - parser.print_help() + print(HELP_TEXT) if __name__ == "__main__": main() \ No newline at end of file diff --git a/scripts/diagnostics/inspect_db.py b/scripts/diagnostics/inspect_db.py new file mode 100644 index 0000000..c21d018 --- /dev/null +++ b/scripts/diagnostics/inspect_db.py @@ -0,0 +1,47 @@ +import os +import sqlite3 + +# Автопоиск файла базы данных в проекте +db_path = 'data/scud_orion_ai.db' if os.path.exists('data/scud_orion_ai.db') else 'scud_orion_ai.db' + +print("=" * 80) +print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}") +print("=" * 80) + +if not os.path.exists(db_path): + print(f"❌ Файл базы данных {db_path} не найден!") + exit(1) + +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +# 1. Список всех таблиц и колонок +cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") +tables = [t[0] for t in cursor.fetchall()] + +print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:") +print("-" * 80) +for t_name in tables: + cursor.execute(f"PRAGMA table_info({t_name})") + cols = [c[1] for c in cursor.fetchall()] + + cursor.execute(f"SELECT COUNT(*) FROM {t_name}") + count = cursor.fetchone()[0] + + print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}") + +# 2. Просмотр правил Базы Знаний +if 'ai_knowledge_base' in tables: + print("\n" + "=" * 80) + print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):") + print("=" * 80) + cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC") + rules = cursor.fetchall() + if not rules: + print("Таблица ai_knowledge_base пуста.") + else: + for r_id, r_text, r_author in rules: + print(f" {r_id}. [{r_author}] {r_text}\n") + +conn.close() +print("=" * 80) \ No newline at end of file diff --git a/scripts/diagnostics/inspect_files.py b/scripts/diagnostics/inspect_files.py new file mode 100644 index 0000000..a012c5b --- /dev/null +++ b/scripts/diagnostics/inspect_files.py @@ -0,0 +1,23 @@ +import os + +print("=" * 80) +print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_ai_v2)") +print("=" * 80) + +total_files = 0 +total_size = 0 + +for root, dirs, files in os.walk('.'): + # Исключаем служебные каталоги + dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']] + + for f in files: + p = os.path.join(root, f) + size = os.path.getsize(p) + total_files += 1 + total_size += size + print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' ')) + +print("-" * 80) +print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB") +print("=" * 80) \ No newline at end of file diff --git a/scripts/diagnostics/make_code_snapshot.py b/scripts/diagnostics/make_code_snapshot.py new file mode 100644 index 0000000..8942f22 --- /dev/null +++ b/scripts/diagnostics/make_code_snapshot.py @@ -0,0 +1,31 @@ +import os + +OUTPUT_SNAPSHOT = "project_code_snapshot.md" + +# Расширения файлов для включения в снимок +ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini'} +EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} +EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'} + +print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...") + +with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: + out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2\n\n") + + for root, dirs, files in os.walk('.'): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + + for file in sorted(files): + ext = os.path.splitext(file)[1].lower() + if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES: + filepath = os.path.join(root, file) + out.write(f"## File: `{filepath}`\n") + out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n") + try: + with open(filepath, 'r', encoding='utf-8', errors='replace') as f: + out.write(f.read()) + except Exception as e: + out.write(f"// Ошибка чтения файла: {e}\n") + out.write("\n```\n\n") + +print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)") \ No newline at end of file diff --git a/scud_orion_ai_old.db b/scud_orion_ai.db similarity index 100% rename from scud_orion_ai_old.db rename to scud_orion_ai.db diff --git a/scud_orion_ai_v2.tar.gz b/scud_orion_ai_v2.tar.gz index 475b46e..bb07b46 100644 Binary files a/scud_orion_ai_v2.tar.gz and b/scud_orion_ai_v2.tar.gz differ diff --git a/services/data_loader.py b/services/data_loader.py index 14e53e0..b95221d 100644 --- a/services/data_loader.py +++ b/services/data_loader.py @@ -5,9 +5,13 @@ from config import ( normalize_fio, clean_scud_fio_light, load_exceptions, DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR ) -# Импортируем прямую SQL-выгрузку отсутствий из 1С +# Импортируем прямую SQL-выгрузку отсутствий из 1С и функции работы с SQLite from services.zup_extractor import fetch_zup_absences_from_sql -from core.database import load_scud_from_db_by_snapshot +from core.database import ( + load_scud_from_db_by_snapshot, + load_staff_from_db, + load_absences_from_db +) warnings.filterwarnings('ignore', category=UserWarning, module='pandas') @@ -56,8 +60,10 @@ def load_staff_data(date_str): def load_absent_data(date_str): """ Загружает документально подтвержденные отсутствия сотрудников - НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату. + НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату, + а также автоматически обогащает их удаленщиками из static_reason_workers.csv. """ + df_absent = None try: df_absent = fetch_zup_absences_from_sql(date_str) @@ -65,19 +71,63 @@ def load_absent_data(date_str): df_absent = df_absent.dropna(subset=['ФИО', 'Вид_отсутствия']) df_absent = df_absent[~df_absent['ФИО'].astype(str).str.contains('АО "ЛЕНМОРНИИПРОЕКТ"|Сотрудник', case=False, na=False)] df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio) - return df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean']) + df_absent = df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean']) else: - return None + df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) except Exception as e: print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}") - return None + df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) + + # ⭐️ ОБОГАЩЕНИЕ УДАЛЕНЩИКАМИ ИЗ static_reason_workers.csv + try: + from config import DATA_DIR + static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") + if os.path.exists(static_path): + 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) + existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set() + + new_rows = [] + for _, s_row in df_static.iterrows(): + if s_row['fio_clean'] not in existing_fios: + new_rows.append({ + 'fio_clean': s_row['fio_clean'], + 'Вид_отсутствия': s_row['reason'] + }) + if new_rows: + df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True) + except Exception as e: + print(f"[⚠️] Ошибка обогащения удаленщиками в load_absent_data: {e}") + + return df_absent if not df_absent.empty else None + + +def load_1c_data_smart(date_str, use_db=False): + """ + Универсальный загрузчик данных 1С (Штат и Отсутствия). + Если use_db=True, приоритетно извлекает сохраненные данные из SQLite БД + за целевую дату указанного снапшота. + """ + df_staff = None + df_absent = None + + if use_db: + df_staff = load_staff_from_db(date_str) + df_absent = load_absences_from_db(date_str) + + # Если в базе нет записей за эту дату или use_db=False — загружаем из файлов/SQL + if df_staff is None: + df_staff = load_staff_data(date_str) + if df_absent is None: + df_absent = load_absent_data(date_str) + + return df_staff, df_absent def process_scud_anomalies(df_scud, exceptions=None): """ Вычисляет 'Первую активность' и маркирует аномалии СКУД. - Если 'Начало дня' == 'Нет входа', но есть любая физическая активность — - записывает первейшее время в 'Первая_активность', сбрасывает 'Пришел' в False и выставляет флаг аномалии. """ if exceptions is None: exceptions = load_exceptions() @@ -100,7 +150,6 @@ def process_scud_anomalies(df_scud, exceptions=None): val_end = str(row.get(end_col, '')).strip() if end_col else '' val_status = str(row.get(status_col, '')).strip().lower() if status_col else '' - # Извлечение первого зафиксированного события из сырых логов (pLogData), если передавалось raw_first_event = row.get('raw_first_event', None) if pd.isna(raw_first_event) or str(raw_first_event).strip() in ['', 'None', 'nan', '00:00:00']: raw_first_event = None @@ -110,18 +159,13 @@ def process_scud_anomalies(df_scud, exceptions=None): first_act = '—' anom_flag = 'NONE' - - # Стандартная проверка присутствия is_present = not ('отсутствовал' in val_status or has_no_in) - # ЛОГИКА ВЫЧИСЛЕНИЯ ПЕРВОЙ АКТИВНОСТИ И ДЕТЕКТИРОВАНИЯ АНОМАЛИИ if has_no_in: - # Если нет входа, но есть проход внутри или зафиксирован выход if raw_first_event is not None or not has_no_out: first_act = str(raw_first_event) if raw_first_event else val_end anom_flag = 'ANOMALY_NO_IN_HAS_ACTIVITY' - # ЖЕСТКАЯ ВАЛИДАЦИЯ: Если человек не исключен официально — сбрасываем фейковый Пришел = True if not is_excluded(fio, dept, pos, exceptions): is_present = False else: @@ -145,14 +189,13 @@ def load_scud_data(target_date_type="today", snapshot_param=None): """ target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY - # Загружаем данные строго из 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}!") + print(f"[ℹ️] В базе SQLite / файлах отсутствует СКУД за {target_date}. Возвращаем пустой набор.") + return pd.DataFrame() exceptions = load_exceptions() @@ -161,7 +204,6 @@ def load_scud_data(target_date_type="today", snapshot_param=None): df_scud['fio_raw'] = df_scud[fio_raw_col].astype(str) df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light) - # Нормализация колонок под единый стандарт отчета 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: @@ -188,11 +230,8 @@ def load_all_data(): except FileNotFoundError: df_scud_yesterday = df_scud_today.copy() - df_staff_today = load_staff_data(DATE_TODAY) - df_absent_today = load_absent_data(DATE_TODAY) - - df_staff_yesterday = load_staff_data(DATE_YESTERDAY) - df_absent_yesterday = load_absent_data(DATE_YESTERDAY) + df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY) + df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY) return ( df_scud_today, diff --git a/services/scud_export.py b/services/scud_export.py index d5e74f3..e340480 100644 --- a/services/scud_export.py +++ b/services/scud_export.py @@ -81,11 +81,13 @@ WITH DailyLogs AS ( 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 @@ -116,21 +118,23 @@ SELECT 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 THEN + WHEN pass.EmployeeID IS NOT NULL AND (pass.FirstIn IS NOT NULL OR pass.FirstRawEvent IS NOT NULL) THEN RIGHT('0' + CAST(DATEDIFF(MINUTE, - pass.FirstIn, + 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.FirstIn) + ELSE ISNULL(pass.LastOut, pass.LastRawEvent) END) / 60 AS VARCHAR), 2) + ':' + RIGHT('0' + CAST(DATEDIFF(MINUTE, - pass.FirstIn, + 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.FirstIn) + ELSE ISNULL(pass.LastOut, pass.LastRawEvent) END) % 60 AS VARCHAR), 2) ELSE N'00:00' END AS [Находился_в_здании],