diff --git a/PROJECT_SNAPSHOT_03.08.2026.md b/PROJECT_SNAPSHOT_03.08.2026.md new file mode 100644 index 0000000..ff5dfbb --- /dev/null +++ b/PROJECT_SNAPSHOT_03.08.2026.md @@ -0,0 +1,115 @@ +# 🔄 ПАСПОРТ АЛГОРИТМА И ETL-КОНВЕЙЕРА: `scud_orion_ai_v2` + +> **Архитектурный паттерн:** Гибридный ETL (Extract, Transform, Load) с хранением аналитического следа +> **Хранилище данных:** SQLite (режим WAL) +> **Интеграционный стек:** `MS SQL Server` ⟷ `1С:ЗУП (SMB/Share)` ⟷ `Ollama (Qwen 2.5)` ⟷ `SQLite` + +--- + +## 📐 Сквозной цикл работы проекта (`main.py`) + +```plaintext + [БД MS SQL 'Орион Pro'] [Сетевая шара \storage\...] + │ │ + (0. run_export) (0.5 share_copier) + ▼ ▼ + [data/scud/*.xlsx] [data/1c/*.xlsx] + │ │ + └────────────────┬───────────────┘ + ▼ + (1. data_validator) ──(Проверка фатальных ошибок) + │ + ▼ + (2. data_loader) ──(Фоллбэки по датам) + │ + ▼ + (3. ai_verifier + Qwen 2.5) ──(Исправление опечаток) + │ + ▼ + (4. Сведение данных) + ┌──────────┴──────────┐ + ▼ ▼ + [Данные за ВЧЕРА] [Данные за СЕГОДНЯ] + │ │ + ▼ ▼ + (Detailed Excel) (Summary Excel + Markdown) + │ │ + └──────────┬──────────┘ + ▼ + (5. feedback_loop) ──(Обучение ИИ) + │ + ▼ + (database.py / SQLite WAL) ──(Сохранение фактов) +``` + +--- + +## 📋 Пошаговый алгоритм по этапам + +### Этап 0: Извлечение сырых данных (Extract) + +#### 1. БД СКУД «Орион Pro» $ ightarrow$ Excel (`scud_export.py`) +* Скрипт подключается напрямую к **MS SQL Server** системы «Орион Pro». +* Формирует агрегированные файлы `Сотрудники_DD.MM.YYYY.xlsx` в папку `data/scud/` отдельно за **Сегодня** и за **Вчера**. + +#### 2. Сетевая шара $ ightarrow$ Папка `data/1c/` (`share_copier.py`) +* Заходит по UNC-пути `\storage\SCUD\Обмен\Штат`. +* Ищет и забирает свежие выгрузки **1С:ЗУП**: `Штат_DD_MM_YYYY.xlsx` и `Отсутствия_DD_MM_YYYY.xlsx` за Сегодня и Вчера. + +--- + +### Этап 1: Валидация и защита от сбоев (`data_validator.py`) +* Проверяет наличие файлов за **Вчера** *(они критически важны для формирования ежедневного табеля)*. +* Если файлы за **Сегодня** ещё не выложены кадровой службой на сетевую шару: + * Выдаёт мягкое предупреждение. + * Отменяет генерацию сегодняшней сводки *(чтобы не плодить ложные аномалии по понедельникам/утрам)*. + * Не останавливает работу системы, мягко переходя к формированию вчерашнего отчёта. + +--- + +### Этап 2: Нормализация и загрузка (`data_loader.py`) +* Очищает и применительно нормализует ФИО через `normalize_fio`: удаление лишних пробелов, точек, приведение к единому регистру. +* Выделяет факты физического присутствия по СКУД *(наличие первого входа / последнего выхода)*. +* Исключает из анализа подрядчиков, охрану и служебные карты *(по реестру исключений)*. + +--- + +### Этап 3: ИИ-аудит опечаток и сопоставление ФИО (`ai_verifier.py`) +* Неопознанные проходы СКУД отправляются в локальную нейросеть **Qwen 2.5** *(через Ollama)*. +* ИИ сверяет опечатки в СКУД с 100% эталонным реестром **Штатного расписания 1С**. +* Сопоставленные опечатки автоматически исправляются в датасете перед сведением. + +--- + +### Этап 4: Бизнес-логика, сведение и детекция аномалий +Система разделяет контекст на два независимых потока: + +#### 1. Поток ВЧЕРА (Детальный отчёт) +* Объединяет `СКУД (Вчера)` + `1С:Отсутствия (Вчера)`. +* Считает реальные часы нахождения в здании, обед (30 мин) и отклонение от 8-часовой нормы. +* Формирует Excel `output/[Дата] отчет.xlsx`. + +#### 2. Поток СЕГОДНЯ (Ежедневная сводка) +* Объединяет `СКУД (Сегодня)` + `1С:Отсутствия (Сегодня)` + `Реестр удалёнщиков`. +* 🔴 **Детекция аномалий:** Физический приход по СКУД при наличии любого документа отсутствия в 1С *(отпуск, больничный и т.д., кроме командировок)* помечается как **Истинная Аномалия**. +* 🟢 **Удалёнщики:** Удалёнщики, пришедшие в офис, не считаются аномалией *(правило компании)*. +* Формирует Excel `output/[Дата] сводка.xlsx` *(со свернутыми группами без номеров строк)* и текстовую Markdown-сводку. + +--- + +### Этап 5: Обучение Human-in-the-Loop (`feedback_loop.py`) +* Если ИИ находит спорные случаи, он запрашивает подтверждение оператора в консоли. +* Новые правила, введённые пользователем, мгновенно сохраняются и в `knowledge_base.json`, и в таблицу `ai_knowledge_base` в БД SQLite. + +--- + +### Этап 6: Сохранение аналитического следа в БД (`database.py`) +Вся история сохраняется в локальную **SQLite БД** (`data/scud_orion_ai.db`) в режиме высокой производительности (**WAL / executemany**): + +| Таблица | Содержимое | +| :--- | :--- | +| `scud_logs` | История всех проходов по дням | +| `zup_staff` | Срезы штатного расписания | +| `zup_absences` | Оформленные документы из 1С | +| `anomalies_history` | Найденные и подтверждённые нарушения | +| `ai_knowledge_base` | Обучающие правила для нейросети | diff --git a/config.py b/config.py index 23e7c84..c2459d3 100644 --- a/config.py +++ b/config.py @@ -9,6 +9,8 @@ SCUD_DIR = os.path.join(DATA_DIR, "scud") ZUP_1C_DIR = os.path.join(DATA_DIR, "1c") OUTPUT_DIR = os.path.join(BASE_DIR, "output") +SHARE_1C_DIR = r"\\storage\SCUD\Обмен\Штат" + for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]: os.makedirs(folder, exist_ok=True) @@ -20,14 +22,7 @@ if NOW.weekday() == 0: else: DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y") -SCUD_FILE_TODAY = os.path.join(SCUD_DIR, f"Сотрудники_{DATE_TODAY}.xlsx") -SCUD_FILE_YESTERDAY = os.path.join(SCUD_DIR, f"Сотрудники_{DATE_YESTERDAY}.xlsx") -SCUD_FILE = os.path.join(SCUD_DIR, "Сотрудники.xlsx") - -STAFF_FILE_1C = os.path.join(ZUP_1C_DIR, "Штатные сотрудники - ОУП.xlsx") -ABSENT_FILE_1C = os.path.join(ZUP_1C_DIR, "Отсутствия сотрудников - выгрузка.xlsx") - -OLLAMA_URL = "http://10.121.18.227:11434/api/generate" +OLLAMA_URL = "http://192.168.11.3:11434/api/generate" OLLAMA_MODEL = "qwen2.5:14b" MODEL_NAME = OLLAMA_MODEL @@ -35,13 +30,25 @@ KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json") EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json") +def find_dated_file(prefix, date_str, search_dirs=[ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]): + """ + Ищет файлы по префиксу и дате (поддерживает и 30.07.2026, и 30_07_2026). + """ + date_dots = date_str + date_underscores = date_str.replace('.', '_') + + for d in search_dirs: + if not os.path.exists(d): + continue + for f in os.listdir(d): + if f.endswith('.xlsx') or f.endswith('.csv'): + if f.lower().startswith(prefix.lower()): + if date_dots in f or date_underscores in f: + return os.path.join(d, f) + return None + + def normalize_fio(fio): - """ - Нормализует ФИО: - 1. Вырезает скобки и их содержимое: (осн.), (совм.), (сводная), (внешн.) - 2. Заменяет неразрывные пробелы \xa0 на обычные - 3. Приводит к регистру Заглавных Букв - """ if not fio or not isinstance(fio, str): return "" fio_clean = re.sub(r'\(.*?\)', '', fio) diff --git a/data/knowledge_base.json b/data/knowledge_base.json index fab60ee..b284975 100644 --- a/data/knowledge_base.json +++ b/data/knowledge_base.json @@ -1,12 +1,8 @@ { "rules": [ "Кондрашова Елена Владимировна имеет 2 пропуска в СКУД. Если один 'Отсутствовал', а второй с временем входа — считать ПРИШЕДШЕЙ.", - "Сотрудники подразделения ОА с должностью 'Инженер-программист' часто являются внутренними совместителями.", - "Сотрудник Незнанова Валерия Игоревна: Незнанова иногда присутствует в офисе, не считать аномалией. Когда в офисе, из удаленщиков в отчете исключать!", - "Сотрудник Ковалев Владимир Владимирович: Все сотрудники на удаленной работе имеют право находится в здании в любой день. При этом в отчет они должны попадать, как на работе и вычитаться из удаленщиков на этот день.", - "Сотрудник Королёва Наталья Александровна: Все сотрудники на удаленной работе имеют право находится в здании в любой день. При этом в отчет они должны попадать, как на работе и вычитаться из удаленщиков на этот день.", - "Сотрудник Кожокарь Татьяна Юрьевна: Нет. Уже указывал, что любые удаленщики имеют право находитться в офисе. Это не является аномалией. Должны исключаться из отчета из удаленщиков, а включаться в список тех, кто на работе.", - "Сотрудник Познякова Татьяна Сергеевна: Нет. Уже указывал, что любые удаленщики имеют право находитться в офисе. Это не является аномалией. Должны исключаться из отчета из удаленщиков, а включаться в список тех, кто на работе." + "ГЛОБАЛЬНОЕ ПРАВИЛО 1: Документы отсутствий из 1С:ЗУП (отпуск, больничный, командировка) имеют 100% высший приоритет над статическим реестром удалёнщиков.", + "ГЛОБАЛЬНОЕ ПРАВИЛО 2: Удалённые сотрудники имеют право беспрепятственно работать в офисе по СКУД в любое время (это не является аномалией)." ], "fio_corrections": { "кондрашава елена владимировна": "Кондрашова Елена Владимировна" diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db new file mode 100644 index 0000000..9e78f4c Binary files /dev/null and b/data/scud_orion_ai.db differ diff --git a/main.py b/main.py index 8587280..e6d25b9 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__))) from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio from modules.scud_export import run_export +from modules.share_copier import copy_1c_files_from_share from modules.data_validator import check_file_freshness from modules.data_loader import load_all_data from modules.excel_exporter import generate_summary_excel, generate_detailed_excel @@ -14,9 +15,17 @@ from modules.ai_verifier import ai_verify_scud_against_staff from modules.text_reporter import generate_markdown_report from modules.feedback_loop import review_ai_decisions from modules.knowledge_base import load_knowledge_base +from modules.database import ( + init_db, + save_scud_to_db, + save_staff_to_db, + save_absences_to_db, + save_anomalies_to_db +) def load_static_reason_workers(): + """Загружает реестр удалёнщиков и статических причин из CSV.""" static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") if not os.path.exists(static_path): return {} @@ -32,58 +41,45 @@ def load_static_reason_workers(): def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules): """ - Автоматически находит логические конфликты между СКУД, 1С, статической удалёнкой и исключениями - с учётом динамических правил из базы знаний (knowledge_base.json). + Автоматически находит ИСТИННЫЕ аномалии. + + ПРАВИЛА КОМПАНИИ: + 1. Удалённые сотрудники имеют право беспрепятственно работать в офисе (НЕ аномалия). + 2. Документы отсутствий из 1С имеют 100% приоритет над стат. реестром (НЕ аномалия). + 3. АНОМАЛИЯ — Физический выход по СКУД при наличии в 1С ЛЮБЫХ причин отсутствия + (отпуск, больничный, уход за ребенком и т.д.), КРОМЕ командировок и служебных поездок. """ anomalies = [] kb_rules_text = " ".join(kb_rules).lower() if kb_rules else "" + ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк'] + for idx, row in merged_df.iterrows(): fio = row.get('Сотрудник', row.get('fio_clean', '')) fio_clean = row.get('fio_clean', '') is_present = row.get('Пришел', False) reason_1c = str(row.get('Вид_отсутствия', '')).strip() has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' - has_static_reason = fio_clean in static_reasons_dict is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text - # 🚨 АНОМАЛИЯ 1: Пришел по СКУД, но в 1С числится отсутствие (Отпуск/Больничный) if is_present and has_1c_reason: - if not is_fio_whitelisted_in_kb: + reason_lower = reason_1c.lower() + is_allowed_trip = any(kw in reason_lower for kw in ALLOWED_WORK_TRIP_KEYWORDS) + + if not is_allowed_trip and not is_fio_whitelisted_in_kb: anomalies.append({ "type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ", "fio": fio, - "details": f"Сотрудник пришел по СКУД, но в 1С оформлено: '{reason_1c}'" + "details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'" }) - # 🚨 АНОМАЛИЯ 2: Удаленщик физически пришел в офис - if is_present and has_static_reason: - static_val = static_reasons_dict[fio_clean] - if 'удален' in str(static_val).lower(): - if not is_fio_whitelisted_in_kb: - anomalies.append({ - "type": "УДАЛЁНЩИК В ОФИСЕ", - "fio": fio, - "details": f"Сотрудник числится как '{static_val}' в реестре, но сегодня зафиксирован вход по СКУД." - }) - - # 🚨 АНОМАЛИЯ 3: Конфликт 1С и Статического реестра - if has_static_reason and has_1c_reason: - static_val = static_reasons_dict[fio_clean] - if reason_1c.lower() != static_val.lower() and not is_present: - if not is_fio_whitelisted_in_kb: - anomalies.append({ - "type": "КОНФЛИКТ СПРАВОЧНИКОВ (1С vs РЕЕСТР)", - "fio": fio, - "details": f"В стат. реестре указано '{static_val}', а в 1С оформлен документ '{reason_1c}'" - }) - return anomalies def aggregate_scud_by_employee(df, debug=False): - if df.empty or 'fio_clean' not in df.columns: + """Агрегирует проходы СКУД по уникальным сотрудникам.""" + if df is None or df.empty or 'fio_clean' not in df.columns: return df aggregated = [] @@ -106,186 +102,221 @@ def main(): parser = argparse.ArgumentParser(description="Система модульного контроллинга СКУД ⟷ 1С") parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки") parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL") + parser.add_argument('--with-xlsx', action='store_true', help="Сохранять дублирующие сырые XLSX-файлы в папке data/") args = parser.parse_args() DEBUG = args.debug + SAVE_XLSX = args.with_xlsx print("=" * 60) print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}") print("=" * 60) - # Загружаем существующие правила из Базы Знаний + # 0. Инициализация локальной базы данных SQLite и Базы Знаний + init_db() kb_data = load_knowledge_base() kb_rules = kb_data.get("rules", []) - # 0. Выгрузка из БД СКУД + # 0.1 Выгрузка из БД СКУД напрямую в SQLite if not args.skip_export: - print("[0/5] Экспорт свежих данных из базы СКУД 'Орион Pro'...") try: - run_export(debug=DEBUG) + run_export(save_xlsx=SAVE_XLSX, debug=DEBUG) except Exception as e: - print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Используем имеющиеся файлы в data/scud.") + print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Используем имеющиеся записи в SQLite.") else: print("[0/5] Пропуск прямого экспорта из БД (--skip-export)...") - # 1. Валидация файлов - print("[1/5] Проверка актуальности и свежести входных файлов...") - is_valid, warnings, errors = check_file_freshness() + # 0.5 Копирование файлов 1С с сетевой шары \\storage\SCUD\Обмен\Штат + copy_1c_files_from_share() + + # 1. Валидация наличия файлов и данных + 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(f" • {w}") print("-" * 45) if not is_valid: print("\n" + "!" * 60) - print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Обнаружены критические ошибки данных!") + print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Отсутствуют критически важные файлы за вчерашний день!") for e in errors: print(f" {e}") print("!" * 60) sys.exit(1) - print("[✓] Проверка актуальности успешно пройдена!\n") + print("[✓] Проверка доступности данных успешно пройдена!\n") - # 2. Раздельная загрузка данных (Сегодня и Вчера) - print("[2/5] Загрузка данных из data/scud, data/1c, реестра причин и исключений...") - raw_scud_today_df, raw_scud_yesterday_df, staff_fios_clean, df_absent = load_all_data() + # 2. Раздельная загрузка данных за Сегодня и за Вчера + print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...") + ( + raw_scud_today_df, + raw_scud_yesterday_df, + df_staff_today, + df_absent_today, + df_staff_yesterday, + df_absent_yesterday + ) = load_all_data() + + # 💾 СОХРАНЕНИЕ КАДРОВЫХ СРЕЗОВ 1С В БАЗУ ДАННЫХ SQLite + if df_staff_yesterday is not None: + save_staff_to_db(df_staff_yesterday, DATE_YESTERDAY) + if df_absent_yesterday is not None: + save_absences_to_db(df_absent_yesterday, DATE_YESTERDAY) + + if df_staff_today is not None: + save_staff_to_db(df_staff_today, DATE_TODAY) + if df_absent_today is not None: + save_absences_to_db(df_absent_today, DATE_TODAY) + + staff_fios_yesterday_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else [] static_reasons_dict = load_static_reason_workers() - # 3. ИИ-сверка ФИО (СКУД + 1С) - print("[3/5] Проверка опечаток и сопоставление ФИО через ИИ...") + # ============================================================ + # 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (Строится Всегда) + # ============================================================ + print("\n[3/5] Обработка данных за ВЧЕРА...") + 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 [] - # Сверка СКУД (Сегодня) - scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist() - fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_clean) - if fio_mapping_scud: - print(f" [ИИ] Найдено совпадений в СКУД (Сегодня): {len(fio_mapping_scud)}") - raw_scud_today_df['fio_clean'] = raw_scud_today_df['fio_clean'].apply( - lambda x: fio_mapping_scud[x]['staff_fio'] if x in fio_mapping_scud else x - ) - - # Сверка СКУД (Вчера) - scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist() - fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_clean) + fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean) if fio_mapping_scud_y: - print(f" [ИИ] Найдено совпадений в СКУД (Вчера): {len(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 ) - # Сверка 1С:Отсутствий - absent_unrecognized = df_absent[~df_absent['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist() - if absent_unrecognized: - fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized, staff_fios_clean) - if fio_mapping_absent: - print(f" [ИИ] Найдено совпадений в 1С Отсутствиях: {len(fio_mapping_absent)}") - df_absent['fio_clean'] = df_absent['fio_clean'].apply( - lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x - ) + 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_today_df = aggregate_scud_by_employee(raw_scud_today_df, debug=DEBUG) raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG) - # 4. Объединение и детекция - print("[4/5] Объединение реестров 1С, СКУД, статических причин и исключений...") - - # Датасет СЕГОДНЯ - merged_today = raw_scud_today_df[raw_scud_today_df['fio_clean'].isin(staff_fios_clean)].copy() - merged_today = merged_today.merge(df_absent, on='fio_clean', how='left') + merged_yesterday = raw_scud_yesterday_df[raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)].copy() + if df_absent_yesterday is not None: + merged_yesterday = merged_yesterday.merge(df_absent_yesterday, on='fio_clean', how='left') - # Датасет ВЧЕРА - merged_yesterday = raw_scud_yesterday_df[raw_scud_yesterday_df['fio_clean'].isin(staff_fios_clean)].copy() - merged_yesterday = merged_yesterday.merge(df_absent, on='fio_clean', how='left') - - # Детекция аномалий С УЧЁТОМ БАЗЫ ЗНАНИЙ (по сегодняшнему дню) - anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules) - - # Применение статических причин и исключений для СЕГОДНЯ и ВЧЕРА if static_reasons_dict: for fio_clean, reason_val in static_reasons_dict.items(): - mask_apply_static = ( - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_today['fio_clean'] == fio_clean) - ) - merged_today.loc[mask_apply_static, 'Вид_отсутствия'] = reason_val - - mask_apply_static_y = ( + mask_yesterday = ( (merged_yesterday['Пришел'] == False) & (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) & (merged_yesterday['fio_clean'] == fio_clean) ) - merged_yesterday.loc[mask_apply_static_y, 'Вид_отсутствия'] = reason_val + merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val - mask_apply_exceptions = ( - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_today.get('is_excluded', False) == True) - ) - merged_today.loc[mask_apply_exceptions, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' - - mask_apply_exceptions_y = ( + 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_apply_exceptions_y, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' + merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' - 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())] - - if DEBUG: - print("\n--- [DEBUG STATS] ---") - print(f" Всего по списку: {len(merged_today)}") - is_working_mask = (merged_today['Пришел'] == True) | ( - merged_today['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) - ) - print(f" Работают сегодня: {len(merged_today[is_working_mask])}") - print(f" Официально отсутствуют: {len(absent_explained)}") - print(f" Истинно неизвестные случаи: {len(absent_unexplained)}") - print(f" Обнаружено аномалий: {len(anomalies_list)}") - print("---------------------\n") - - # 5. ИИ-аудитор и сборка финальных Excel-отчетов - print("[5/5] Запуск ИИ-аудитора и сборка финальных Excel-отчетов...") - 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, - raw_scud_df=raw_scud_today_df, - raw_absent_df=df_absent, # <-- ВАЖНО: передаем выгрузку 1С для ИИ-кросс-сверки - date_str=DATE_TODAY - ) - - suspicious_cases = [] - if anomalies_list: - for a in anomalies_list: - suspicious_cases.append({ - 'fio_target': a['fio'], - 'reason': f"{a['type']}: {a['details']}" - }) - - review_ai_decisions(report_text, suspicious_cases) - - # 1. Ежедневная сводка -> формируется по СЕГОДНЯШНЕМУ датасету - generate_summary_excel(merged_df=merged_today, date_str=DATE_TODAY) - - # 2. Детальный отчет -> формируется по ВЧЕРАШНЕМУ датасету generate_detailed_excel(merged_df=merged_yesterday, date_str=DATE_YESTERDAY) - print("\n" + "=" * 60) - print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИС-АУДИТОРА:") - print("=" * 60) - print(report_text) - - md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{DATE_TODAY}.md") - with open(md_report_path, "w", encoding="utf-8") as f: - f.write(report_text) - print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}") + # 💾 СОХРАНЕНИЕ ВЧЕРАШНИХ ДАННЫХ В SQLite: + save_scud_to_db(merged_yesterday, DATE_YESTERDAY) + + # ============================================================ + # 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА ЗА СЕГОДНЯ + # ============================================================ + # 💾 СОХРАНЯЕМ СЕГОДНЯШНИЙ СКУД В SQLite (Даже если 1С еще не пришла): + save_scud_to_db(raw_scud_today_df, DATE_TODAY) + + if has_today_1c and df_staff_today is not None and df_absent_today is not None: + print("\n[4/5] Обработка данных за СЕГОДНЯ...") + staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist() + + scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() + fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_today_clean) + if fio_mapping_scud: + raw_scud_today_df['fio_clean'] = raw_scud_today_df['fio_clean'].apply( + lambda x: fio_mapping_scud[x]['staff_fio'] if x in fio_mapping_scud else x + ) + + absent_unrecognized_today = df_absent_today[~df_absent_today['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() + if absent_unrecognized_today: + fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized_today, staff_fios_today_clean) + if fio_mapping_absent: + df_absent_today['fio_clean'] = df_absent_today['fio_clean'].apply( + lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x + ) + + raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df, debug=DEBUG) + + merged_today = raw_scud_today_df[raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)].copy() + merged_today = merged_today.merge(df_absent_today, on='fio_clean', how='left') + + if static_reasons_dict: + for fio_clean, reason_val in static_reasons_dict.items(): + mask_today = ( + (merged_today['Пришел'] == False) & + (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & + (merged_today['fio_clean'] == fio_clean) + ) + merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val + + mask_exc_today = ( + (merged_today['Пришел'] == False) & + (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & + (merged_today.get('is_excluded', False) == True) + ) + merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' + + anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules) + + # 💾 СОХРАНЕНИЕ СЕГОДНЯШНИХ АНОМАЛИЙ В БАЗУ ДАННЫХ SQLite + 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())] + + print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...") + 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, + raw_scud_df=raw_scud_today_df, + raw_absent_df=df_absent_today, + date_str=DATE_TODAY + ) + + generate_summary_excel(merged_df=merged_today, date_str=DATE_TODAY) + + md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{DATE_TODAY}.md") + with open(md_report_path, "w", encoding="utf-8") as f: + f.write(report_text) + print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}") + + # 🎯 ОПРОС ОПЕРАТОРА (HUMAN-IN-THE-LOOP): + # Доказанные нарушения (приход во время отпуска/больничного в 1С) записываются автоматически. + # Запрос оператору выводится ТОЛЬКО при спорных/нетипичных аномалиях. + suspicious_cases = [] + for a in anomalies_list: + if "ОФИЦИАЛЬНОМ ОТСУТСТВИИ" not in a.get('type', ''): + suspicious_cases.append({ + 'fio_target': a['fio'], + 'reason': f"{a['type']}: {a['details']}" + }) + + if suspicious_cases: + review_ai_decisions(report_text, suspicious_cases) + + print("\n" + "=" * 60) + print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:") + print("=" * 60) + print(report_text) + else: + print(f"\n[ℹ️] Формирование Ежедневной сводки за {DATE_TODAY} ПРОПУЩЕНО.") + print(" Ждём появления файлов 1С за текущую дату на сетевой шаре.") if __name__ == "__main__": diff --git a/modules/ai_verifier.py b/modules/ai_verifier.py index 495cdad..a1441eb 100644 --- a/modules/ai_verifier.py +++ b/modules/ai_verifier.py @@ -2,10 +2,12 @@ import json import re import requests from config import OLLAMA_URL, OLLAMA_MODEL +from modules.database import get_all_rules_from_db + def ask_ollama(prompt, system_prompt=None): """ - Универсальная функция отправки запросов к Ollama (поддерживает /api/generate и /api/chat) + Универсальная функция отправки запросов к локальной модели Ollama (Qwen 2.5). """ payload = { "model": OLLAMA_MODEL, @@ -17,10 +19,8 @@ def ask_ollama(prompt, system_prompt=None): response = requests.post(OLLAMA_URL, json=payload, timeout=120) if response.status_code == 200: data = response.json() - # Проверяем формат /api/generate if "response" in data: return data["response"].strip() - # Проверяем формат /api/chat elif "message" in data and "content" in data["message"]: return data["message"]["content"].strip() else: @@ -32,21 +32,40 @@ def ask_ollama(prompt, system_prompt=None): except Exception as e: return f"⚠️ Ошибка при обращении к Ollama: {e}" + +def get_system_rules_context(): + """ + Загружает глобальные системные правила компании прямо из Базы Данных. + """ + 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" + + def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): - """ИИ-сверка опечаток и несовпадений ФИО между СКУД и 1С:ЗУП""" + """ + ИИ-сверка опечаток и несовпадений ФИО между СКУД и 1С:ЗУП. + Записи из 1С считаются 100% эталоном. + """ if not unrecognized_scud_fios or not staff_fios: return {} + rules_context = get_system_rules_context() + prompt = f""" Ты — кадровый аудитор безопасности СКУД. +{rules_context} В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)} В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)} СТРОГИЕ ПРАВИЛА: 1. Запись из Штат 1С — это 100% ПРАВИЛЬНЫЙ эталон. 2. В СКУД допущена опечатка. -3. В поле "warning" описать опечатку. -4. Запрещено выдумывать опечатки! +3. Любое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (кроме командировок) является гарантированной аномалией. +4. В поле "warning" опиши обнаруженную опечатку. +5. Запрещено выдумывать опечатки! ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON! @@ -65,6 +84,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): prompt, system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки." ) + mapping = {} try: match = re.search(r'\{.*\}', raw_response, re.DOTALL) @@ -81,5 +101,4 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): except Exception as e: print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}") - return mapping -# тестовая строка для проверки работы функции \ No newline at end of file + return mapping \ No newline at end of file diff --git a/modules/data_loader.py b/modules/data_loader.py index f55e585..75edaf4 100644 --- a/modules/data_loader.py +++ b/modules/data_loader.py @@ -3,14 +3,14 @@ import pandas as pd import warnings from config import ( normalize_fio, clean_scud_fio_light, load_exceptions, - SCUD_FILE, SCUD_FILE_TODAY, SCUD_FILE_YESTERDAY, - STAFF_FILE_1C, ABSENT_FILE_1C + DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR ) warnings.filterwarnings('ignore', category=UserWarning, module='pandas') def is_excluded(fio, dept, pos, exceptions): + """Проверяет, входит ли сотрудник или должность в список исключений.""" fio_clean = normalize_fio(fio) dept_str = str(dept).strip().upper() if pd.notna(dept) else "" pos_str = str(pos).strip().lower() if pd.notna(pos) else "" @@ -22,42 +22,54 @@ def is_excluded(fio, dept, pos, exceptions): return False -def load_staff_data(): - if not os.path.exists(STAFF_FILE_1C): - raise FileNotFoundError(f"Файл штатного расписания не найден по пути: {STAFF_FILE_1C}") - df_staff = pd.read_excel(STAFF_FILE_1C, skiprows=7) - df_staff = df_staff.rename(columns={df_staff.columns[1]: 'ФИО'}) +def load_staff_data(date_str): + """ + Загружает файл Штатного расписания 1С за конкретную дату. + Если файла за эту дату нет — возвращает None без подмены на старые даты. + """ + filepath = find_dated_file("Штат", date_str) + if not filepath: + return None + + df_raw = pd.read_excel(filepath, skiprows=8) + df_staff = df_raw.iloc[:, [1, 5, 12]].copy() + df_staff.columns = ['ФИО', 'Подразделение', 'Должность'] + df_staff = df_staff.dropna(subset=['ФИО']).reset_index(drop=True) + df_staff = df_staff[~df_staff['ФИО'].astype(str).str.contains('Всего|Организация|Сотрудник|ФИО', case=False, na=False)] df_staff['fio_clean'] = df_staff['ФИО'].apply(normalize_fio) return df_staff -def load_absent_data(): - if not os.path.exists(ABSENT_FILE_1C): - raise FileNotFoundError(f"Файл отсутствий не найден по пути: {ABSENT_FILE_1C}") - df_absent_raw = pd.read_excel(ABSENT_FILE_1C, skiprows=3) - df_absent = df_absent_raw[1:].copy().reset_index(drop=True) - df_absent = df_absent.rename(columns={df_absent.columns[0]: 'ФИО', df_absent.columns[2]: 'Вид_отсутствия'}) +def load_absent_data(date_str): + """ + Загружает файл Отсутствий сотрудников 1С за конкретную дату. + Если файла за эту дату нет — возвращает None без подмены на старые даты. + """ + filepath = find_dated_file("Отсутствия", date_str) + if not filepath: + return None + + df_raw = pd.read_excel(filepath, skiprows=3) + df_absent = df_raw[1:].copy().reset_index(drop=True) + df_absent = df_absent.rename(columns={ + df_absent.columns[0]: 'ФИО', + df_absent.columns[2]: 'Вид_отсутствия' + }) + 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']) def load_scud_data(target_date_type="today"): - """ - Загружает файл СКУД за конкретную дату: - • target_date_type="today" -> data/scud/Сотрудники_СЕГОДНЯ.xlsx - • target_date_type="yesterday" -> data/scud/Сотрудники_ВЧЕРА.xlsx - """ - if target_date_type == "yesterday": - scud_path = SCUD_FILE_YESTERDAY - else: - scud_path = SCUD_FILE_TODAY + """Загружает файл СКУД за 'today' или 'yesterday'.""" + target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY + scud_path = find_dated_file("Сотрудники", target_date, search_dirs=[SCUD_DIR]) - # Резервный фоллбэк, если конкретный файл отсутствия еще не создан - if not os.path.exists(scud_path): - if os.path.exists(SCUD_FILE): - scud_path = SCUD_FILE - else: - raise FileNotFoundError(f"Файл СКУД [{target_date_type}] не найден по пути: {scud_path}") + 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() @@ -87,19 +99,28 @@ def load_scud_data(target_date_type="today"): def load_all_data(): """ - Загружает слитный датасет за сегодня, а также отдельный датасет за вчера. - Возвращает: df_scud_today, df_scud_yesterday, staff_fios_clean, df_absent + Загружает слитные датасеты за Сегодня и за Вчера. + Если файла 1С за Сегодня нет, соответствующие датасеты вернет как None. """ df_scud_today = load_scud_data(target_date_type="today") - - # Пытаемся забрать вчерашние данные СКУД try: df_scud_yesterday = load_scud_data(target_date_type="yesterday") except FileNotFoundError: df_scud_yesterday = df_scud_today.copy() - df_staff = load_staff_data() - df_absent = load_absent_data() - staff_fios_clean = df_staff['fio_clean'].dropna().tolist() + # Загружаем 1С за Сегодня (может быть None) + df_staff_today = load_staff_data(DATE_TODAY) + df_absent_today = load_absent_data(DATE_TODAY) - return df_scud_today, df_scud_yesterday, staff_fios_clean, df_absent \ No newline at end of file + # Загружаем 1С за Вчера (гарантированно есть) + df_staff_yesterday = load_staff_data(DATE_YESTERDAY) + df_absent_yesterday = load_absent_data(DATE_YESTERDAY) + + return ( + df_scud_today, + df_scud_yesterday, + df_staff_today, + df_absent_today, + df_staff_yesterday, + df_absent_yesterday + ) \ No newline at end of file diff --git a/modules/data_validator.py b/modules/data_validator.py index da812a2..2657f41 100644 --- a/modules/data_validator.py +++ b/modules/data_validator.py @@ -1,34 +1,42 @@ import os import time from datetime import datetime -from config import SCUD_FILE_TODAY, STAFF_FILE_1C, ABSENT_FILE_1C +from config import DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR def check_file_freshness(max_age_hours=24): """ - Проверяет существование и свежесть входных файлов. + Проверяет существование входных файлов 1С и СКУД. + Возвращает флаги доступности файлов за Сегодня и Вчера. """ warnings = [] errors = [] - now = time.time() - max_age_sec = max_age_hours * 3600 - # 1. Проверка файлов 1С в data/1c - for file_path, label in [(STAFF_FILE_1C, "Штатное расписание 1С"), (ABSENT_FILE_1C, "Отсутствия 1С")]: - if not os.path.exists(file_path): - errors.append(f"Файл '{label}' не найден по пути: {file_path}") - else: - mtime = os.path.getmtime(file_path) - age_hours = (now - mtime) / 3600 - if age_hours > max_age_hours: - warnings.append( - f"Файл '{label}' обновлялся давно ({age_hours:.1f} ч. назад). " - f"Дата изменения: {datetime.fromtimestamp(mtime).strftime('%d.%m.%Y %H:%M')}" - ) + # 1. Проверка файлов 1С за СЕГОДНЯ + staff_today = find_dated_file("Штат", DATE_TODAY) + absent_today = find_dated_file("Отсутствия", DATE_TODAY) + has_today_1c = bool(staff_today and absent_today) - # 2. Проверка файла СКУД в data/scud - if not os.path.exists(SCUD_FILE_TODAY): - warnings.append(f"Сегодняшний файл СКУД еще не сформирован: {SCUD_FILE_TODAY}") + if not has_today_1c: + warnings.append( + f"Файлы 1С за СЕГОДНЯ ({DATE_TODAY}) еще не выложены на сетевую шару. " + f"Формирование Ежедневной сводки за сегодня будет временно пропущено." + ) + + # 2. Проверка файлов 1С за ВЧЕРА (критически важны для детального отчета) + staff_yesterday = find_dated_file("Штат", DATE_YESTERDAY) + absent_yesterday = find_dated_file("Отсутствия", DATE_YESTERDAY) + + if not staff_yesterday or not absent_yesterday: + errors.append( + f"Отсутствуют файлы 1С за ВЧЕРА ({DATE_YESTERDAY}) в папке {ZUP_1C_DIR}. " + f"Невозможно построить детальный отчет за прошлую смену." + ) + + # 3. Проверка файлов СКУД + scud_today = find_dated_file("Сотрудники", DATE_TODAY, search_dirs=[SCUD_DIR]) + if not scud_today: + warnings.append(f"Сегодняшний файл СКУД за {DATE_TODAY} еще не сформирован в {SCUD_DIR}") is_valid = len(errors) == 0 - return is_valid, warnings, errors \ No newline at end of file + return is_valid, warnings, errors, has_today_1c \ No newline at end of file diff --git a/modules/database.py b/modules/database.py new file mode 100644 index 0000000..d5abfe0 --- /dev/null +++ b/modules/database.py @@ -0,0 +1,252 @@ +import os +import sqlite3 +import pandas as pd +from datetime import datetime +from config import DATA_DIR + +DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") + + +def get_connection(): + """Создает подключение к базе данных SQLite с оптимизированными настройками.""" + conn = sqlite3.connect(DB_PATH) + conn.execute("PRAGMA foreign_keys = ON;") + conn.execute("PRAGMA journal_mode = WAL;") # Режим быстрого чтения/записи + return conn + + +def init_db(): + """Создает структуру таблиц и индексов в базе данных.""" + with get_connection() as conn: + cursor = conn.cursor() + + # 1. Таблица СКУД + cursor.execute(""" + CREATE TABLE IF NOT EXISTS scud_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + log_date TEXT NOT NULL, + fio TEXT NOT NULL, + fio_clean TEXT NOT NULL, + department TEXT, + position TEXT, + time_in TEXT, + time_out TEXT, + time_in_building TEXT, + is_present INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 2. Таблица Штатного расписания 1С + cursor.execute(""" + CREATE TABLE IF NOT EXISTS zup_staff ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_date TEXT NOT NULL, + fio TEXT NOT NULL, + fio_clean TEXT NOT NULL, + department TEXT, + position TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 3. Таблица Отсутствий из 1С + cursor.execute(""" + CREATE TABLE IF NOT EXISTS zup_absences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + absence_date TEXT NOT NULL, + fio TEXT NOT NULL, + fio_clean TEXT NOT NULL, + absence_type TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 4. Реестр аномалий + cursor.execute(""" + CREATE TABLE IF NOT EXISTS anomalies_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + anomaly_date TEXT NOT NULL, + fio TEXT NOT NULL, + anomaly_type TEXT NOT NULL, + details TEXT NOT NULL, + human_status TEXT DEFAULT 'Pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 5. База знаний ИИ + cursor.execute(""" + CREATE TABLE IF NOT EXISTS ai_knowledge_base ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_text TEXT UNIQUE NOT NULL, + added_by TEXT DEFAULT 'Human', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # ⚡ ИНДЕКСЫ ДЛЯ УСКОРЕНИЯ ЗАПРОСОВ + cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_absences_date ON zup_absences(absence_date);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_staff_date ON zup_staff(snapshot_date);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_anomalies_date ON anomalies_history(anomaly_date);") + + conn.commit() + + # Автоматически загружаем базовые правила компании + sync_knowledge_base_to_db() + + +def sync_knowledge_base_to_db(): + """ + Автоматически мигрирует все правила из knowledge_base.json в таблицу ai_knowledge_base SQLite. + """ + kb_path = os.path.join(os.path.dirname(DATA_DIR), "knowledge_base.json") + rules_to_insert = [ + "ГЛОБАЛЬНОЕ ПРАВИЛО 1: Любое физическое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (отпуск, больничный, уход за ребенком и др.), КРОМЕ командировок и служебных поездок — ЯВЛЯЕТСЯ СТРОГОЙ ИСТИННОЙ АНОМАЛИЕЙ.", + "ГЛОБАЛЬНОЕ ПРАВИЛО 2: Документы отсутствий из 1С:ЗУП имеют 100% высший приоритет над статическим реестром удалёнщиков.", + "ГЛОБАЛЬНОЕ ПРАВИЛО 3: Удалённые сотрудники имеют право беспрепятственно работать в офисе по СКУД в любое время (это не является аномалией)." + ] + + if os.path.exists(kb_path): + try: + with open(kb_path, "r", encoding="utf-8") as f: + kb_data = json.load(f) + file_rules = kb_data.get("rules", []) + for r in file_rules: + if r not in rules_to_insert: + rules_to_insert.append(r) + except Exception as e: + print(f"[⚠️] Ошибка чтения knowledge_base.json: {e}") + + with get_connection() as conn: + cursor = conn.cursor() + for rule in rules_to_insert: + cursor.execute( + "INSERT OR IGNORE INTO ai_knowledge_base (rule_text) VALUES (?)", + (rule.strip(),) + ) + conn.commit() + +def save_scud_to_db(df_scud, date_str): + """Оптимизированное сохранение логов СКУД за дату.""" + if df_scud.empty: + return + + data_to_insert = [] + for _, r in df_scud.iterrows(): + data_to_insert.append(( + date_str, + r.get('Сотрудник', r.get('fio_raw', '')), + r.get('fio_clean', ''), + r.get('Подразделение', ''), + r.get('Должность', ''), + r.get('Начало_дня', 'Нет входа'), + r.get('Конец_дня', 'Нет выхода'), + r.get('Находился_в_здании', '00:00'), + 1 if r.get('Пришел', False) else 0 + )) + + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_logs WHERE log_date = ?", (date_str,)) + cursor.executemany(""" + INSERT INTO scud_logs (log_date, fio, fio_clean, department, position, time_in, time_out, time_in_building, is_present) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, data_to_insert) + conn.commit() + + +def save_staff_to_db(df_staff, date_str): + """Сохраняет выгрузку Штатного расписания 1С в БД.""" + if df_staff is None or df_staff.empty: + return + + data_to_insert = [] + for _, r in df_staff.iterrows(): + data_to_insert.append(( + date_str, + r.get('ФИО', ''), + r.get('fio_clean', ''), + r.get('Подразделение', ''), + r.get('Должность', '') + )) + + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,)) + cursor.executemany(""" + INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) + VALUES (?, ?, ?, ?, ?) + """, data_to_insert) + conn.commit() + + +def save_absences_to_db(df_absent, date_str): + """Оптимизированное сохранение отсутствий из 1С в БД.""" + if df_absent is None or df_absent.empty: + return + + data_to_insert = [] + for _, r in df_absent.iterrows(): + data_to_insert.append(( + date_str, + r.get('ФИО', r.get('fio_clean', '')), + r.get('fio_clean', ''), + r.get('Вид_отсутствия', '') + )) + + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,)) + cursor.executemany(""" + INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) + VALUES (?, ?, ?, ?) + """, data_to_insert) + conn.commit() + + +def save_anomalies_to_db(anomalies_list, date_str): + """Записывает обнаруженные аномалии за дату.""" + if not anomalies_list: + return + + data_to_insert = [ + (date_str, a.get('fio', ''), a.get('type', ''), a.get('details', '')) + for a in anomalies_list + ] + + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,)) + cursor.executemany(""" + INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) + VALUES (?, ?, ?, ?) + """, data_to_insert) + conn.commit() + + +def get_all_rules_from_db(): + """Возвращает список всех правил для ИИ из базы данных.""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT rule_text FROM ai_knowledge_base") + rows = cursor.fetchall() + return [r[0] for r in rows] + + +def add_rule_to_db(rule_text, added_by="Human"): + """Добавляет новое динамическое правило в базу данных.""" + if not rule_text or not rule_text.strip(): + return + with get_connection() as conn: + cursor = conn.cursor() + try: + cursor.execute( + "INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", + (rule_text.strip(), added_by) + ) + conn.commit() + except Exception as e: + print(f"[⚠️] Ошибка записи правила в БД: {e}") \ No newline at end of file diff --git a/modules/excel_exporter.py b/modules/excel_exporter.py index 4465afd..405192d 100644 --- a/modules/excel_exporter.py +++ b/modules/excel_exporter.py @@ -128,22 +128,26 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): current_row = 4 - # 3. НЕИЗВЕСТНО (По умолчанию РАСКРЫТО, нумерация 1. ФИО) + # 3. НЕИЗВЕСТНО (По умолчанию РАСКРЫТО, чистые ФИО без цифр) unexplained = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna())] 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) current_row += 1 - for idx, fio in enumerate(sorted(unexplained['Сотрудник'].dropna().unique()), 1): - ws.cell(row=current_row, column=1, value=f"{idx}. {fio}") + for fio in sorted(unexplained['Сотрудник'].dropna().unique()): + ws.cell(row=current_row, column=1, value=fio) format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = False current_row += 1 - # 4. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True) - absent_only = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna())] + # 4. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True, ИСКЛЮЧЕНИЯ ИСКЛЮЧЕНЫ) + absent_only = merged_df[ + (merged_df['Пришел'] == False) & + (merged_df['Вид_отсутствия'].notna()) & + (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) + ] absent_groups = absent_only.groupby('Вид_отсутствия') for idx_cat, (cat_name, group) in enumerate(absent_groups): @@ -155,14 +159,14 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font) current_row += 1 - for idx, fio in enumerate(sorted(group['Сотрудник'].dropna().unique()), 1): - ws.cell(row=current_row, column=1, value=f"{idx}. {fio}") + for fio in sorted(group['Сотрудник'].dropna().unique()): + ws.cell(row=current_row, column=1, value=fio) format_row_cells(ws, current_row, cat_fill, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = True current_row += 1 - # 5. ИТОГО НА РАБОТЕ (Раскрывающийся список, свернут hidden=True) + # 5. ИТОГО НА РАБОТЕ (Свернут hidden=True) is_working_mask = (merged_df['Пришел'] == True) | ( merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) ) @@ -174,14 +178,14 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): current_row += 1 if not present.empty: - for idx, fio in enumerate(sorted(present['Сотрудник'].dropna().unique()), 1): - ws.cell(row=current_row, column=1, value=f"{idx}. {fio}") + for fio in sorted(present['Сотрудник'].dropna().unique()): + ws.cell(row=current_row, column=1, value=fio) format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = True current_row += 1 - # 6. АНОМАЛИИ (В самом низу таблицы, раскрывающийся список) + # 6. АНОМАЛИИ (Свернут hidden=True) anomalies = merged_df[(merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna())] ws.cell(row=current_row, column=1, value="Аномалии") @@ -189,15 +193,15 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font) current_row += 1 - chars_per_line_b = 30 # Влезает символов в столбец B шириной 230px (32.0 units) + chars_per_line_b = 30 if not anomalies.empty: - for idx, (_, row) in enumerate(anomalies.iterrows(), 1): + for _, row in anomalies.iterrows(): fio = row.get('Сотрудник', '') reason = row.get('Вид_отсутствия', 'Неизвестная причина') reason_text = f"В 1С: {reason}" - cell_a = ws.cell(row=current_row, column=1, value=f"{idx}. {fio} (На работе)") + cell_a = ws.cell(row=current_row, column=1, value=f"{fio} (На работе)") cell_b = ws.cell(row=current_row, column=2, value=reason_text) cell_a.fill = FILL_ANOMALY @@ -219,7 +223,7 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): current_row += 1 ws.column_dimensions['A'].width = 45.0 - ws.column_dimensions['B'].width = 32.0 # Ровно 230px в Excel + ws.column_dimensions['B'].width = 32.0 try: wb.save(output_path) @@ -274,7 +278,6 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): in_building_str = str(row.get(hours_col, '00:00')) - # Расчет отклонения с учетом вычета 30 мин обеда и причины в 1С deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30) ws.append([ @@ -291,11 +294,10 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): row_num = 5 + idx - # Разграничение заливки для Детального отчета: if is_present and has_reason: - row_fill = GREEN_FILL # Аномалия (Пришел в отпуске/на больничном) -> Бледно-зеленый + row_fill = GREEN_FILL elif not is_present: - row_fill = YELLOW_FILL if has_reason else LIGHT_RED_FILL # Обычные отсутствия / Неизвестные + row_fill = YELLOW_FILL if has_reason else LIGHT_RED_FILL else: row_fill = None diff --git a/modules/feedback_loop.py b/modules/feedback_loop.py index 63e4f62..83dc612 100644 --- a/modules/feedback_loop.py +++ b/modules/feedback_loop.py @@ -1,33 +1,57 @@ -from modules.knowledge_base import add_rule_to_kb +import os +import json +from modules.database import add_rule_to_db -def review_ai_decisions(ai_report, suspicious_cases): +try: + from modules.knowledge_base import save_rule +except ImportError: + try: + from modules.knowledge_base import add_rule as save_rule + except ImportError: + save_rule = None + + +def review_ai_decisions(report_text, suspicious_cases): """ - Интерактивный модуль: показывает спорные моменты человеку - и записывает его вердикт в память ИИ. + Интерактивный консольный модуль обучения (Human-in-the-Loop). + Запрашивает подтверждение оператора ТОЛЬКО по нетипичным/сложным случаям. """ - print("\n" + "="*60) - print("РЕЖИМ ОБУЧЕНИЯ И ПРОВЕРКИ РЕШЕНИЙ ИИ (HUMAN-IN-THE-LOOP)") - print("="*60) - if not suspicious_cases: - print("Спорных случаев для обучения сегодня не обнаружено.") + print("\n[✓] Все аномалии валидированы автоматически. Спорных вопросов для оператора нет.") return - for case in suspicious_cases: - fio = case.get('fio_target') - reason = case.get('reason') - print(f"\n[?] Спорный случай: {fio}") - print(f" Причина аномалии: {reason}") + print("\n" + "=" * 60) + print("РЕЖИМ ОБУЧЕНИЯ И ПРОВЕРКИ РЕШЕНИЙ ИИ (HUMAN-IN-THE-LOOP)") + print("=" * 60) + + for idx, case in enumerate(suspicious_cases, 1): + fio_target = case.get('fio_target', 'Неизвестно') + reason = case.get('reason', 'Нет описания') + + print(f"\n[?] Спорный случай {idx}/{len(suspicious_cases)}: {fio_target}") + print(f" Детали: {reason}") - user_input = input("Согласны с решением ИИ? (д - да / н - нет / введите новое правило): ").strip() - - if user_input.lower() == 'н' or len(user_input) > 5: - if user_input.lower() == 'н': - rule_text = input(f"Опишите правило для {fio} (например: 'Считать совместителем'): ") - else: - rule_text = user_input - - # Записываем правило в Базу Знаний - full_rule = f"Сотрудник {fio}: {rule_text}" - add_rule_to_kb(full_rule) - print(f"[+] ИИ 'запомнил' это правило и учитывает его со следующего запуска!") \ No newline at end of file + user_input = input("Подтверждаете аномалию? (д / да / н / нет / или введите текст правила): ").strip() + user_input_clean = user_input.lower() + + # Расширенный список вариантов "ДА" + if user_input_clean in ['д', 'да', 'da', 'yes', 'y', 'ок', 'ага', '+']: + print(" [✓] Решение подтверждено оператором.") + elif user_input_clean in ['н', 'нет', 'no', 'n', '-']: + print(" [ℹ️] Решение отклонено оператором.") + elif user_input: + # Если оператор ввел действительно новое текстовое правило + new_rule = f"Правило по {fio_target}: {user_input}" + + add_rule_to_db(new_rule, added_by="Human_Admin") + if save_rule: + try: + save_rule(new_rule) + except Exception: + pass + + print(f" [✓] Новое правило записано в Базу Знаний: {new_rule}") + else: + print(" [ℹ️] Запись пропущена.") + + print("\n[✓] ЭТАП ОБУЧЕНИЯ ЗАВЕРШЕН.") \ No newline at end of file diff --git a/modules/scud_export.py b/modules/scud_export.py index 44983fc..320e442 100644 --- a/modules/scud_export.py +++ b/modules/scud_export.py @@ -1,5 +1,5 @@ """ -Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в Excel. +Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel. Исправлена логика определения направления проходов (Mode = 2 -> OUT). """ @@ -14,7 +14,8 @@ import pandas as pd import pyodbc from openpyxl.utils import get_column_letter -from config import SCUD_DIR +from config import SCUD_DIR, clean_scud_fio_light, load_exceptions +from modules.database import save_scud_to_db warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable") @@ -64,7 +65,6 @@ DECLARE @TargetDate DATE = @InputDate; DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME); DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate)); --- 1. Фильтруем логи только за нужные 24 часа с ПРАВИЛЬНЫМ ПРИОРИТЕТОМ ВЫХОДА (Mode = 2) WITH DailyLogs AS ( SELECT log.HozOrgan AS EmployeeID, @@ -83,7 +83,6 @@ WITH DailyLogs AS ( AND log.HozOrgan > 0 AND (log.Event IN (28, 29, 26, 27, 32, 33) OR log.Mode IN (1, 2)) ), --- 2. Агрегируем первичное время входа и последнее время выхода Passages AS ( SELECT EmployeeID, @@ -195,7 +194,7 @@ def get_targets(input_date: str | None): return targets -def run_export(input_date: str | None = None, debug: bool = False): +def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: bool = False): if debug: logger.setLevel(logging.DEBUG) log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING") @@ -223,15 +222,6 @@ def run_export(input_date: str | None = None, debug: bool = False): log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---") - file_name = f"Сотрудники_{processing_date_str}.xlsx" - file_path = os.path.join(SCUD_DIR, file_name) - - if os.path.exists(file_path): - try: - os.remove(file_path) - except OSError as e: - log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR") - sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d")) connection = None @@ -242,9 +232,28 @@ def run_export(input_date: str | None = None, debug: bool = False): log(f"База вернула {len(df)} строк за {processing_date_str}") if len(df) > 0: - df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl") - auto_fit_columns(file_path, sheet_name="Отчет") - log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS") + # 1. Готовим колонки для локальной БД + df['fio_clean'] = df['Сотрудник'].apply(clean_scud_fio_light) + df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) + + # 2. ПРЯМОЕ СОХРАНЕНИЕ В SQLite (scud_orion_ai.db) + save_scud_to_db(df, processing_date_str) + log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS") + + # 3. Сохранение в Excel (если включен флаг save_xlsx) + if save_xlsx: + file_name = f"Сотрудники_{processing_date_str}.xlsx" + file_path = os.path.join(SCUD_DIR, file_name) + + if os.path.exists(file_path): + try: + os.remove(file_path) + except OSError as e: + log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR") + + df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl") + auto_fit_columns(file_path, sheet_name="Отчет") + log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS") else: log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING") @@ -260,9 +269,10 @@ def run_export(input_date: str | None = None, debug: bool = False): if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Экспорт данных СКУД в Excel") + parser = argparse.ArgumentParser(description="Экспорт данных СКУД в SQLite и Excel") parser.add_argument("--date", dest="input_date", default=None) parser.add_argument("-d", "--debug", action="store_true") + parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить сохранение в XLSX") args = parser.parse_args() - run_export(args.input_date, debug=args.debug) \ No newline at end of file + run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug) \ No newline at end of file diff --git a/modules/share_copier.py b/modules/share_copier.py new file mode 100644 index 0000000..03d796d --- /dev/null +++ b/modules/share_copier.py @@ -0,0 +1,69 @@ +import os +import shutil +from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR + + +def find_file_strictly_on_share(prefix, date_str): + """ + Ищет файл со строгой привязкой ТОЛЬКО к сетевой шаре SHARE_1C_DIR, + не обращаясь к локальным папкам. + """ + if not os.path.exists(SHARE_1C_DIR): + return None + + date_dots = date_str + date_underscores = date_str.replace('.', '_') + + try: + for f in os.listdir(SHARE_1C_DIR): + if f.endswith('.xlsx') or f.endswith('.csv'): + if f.lower().startswith(prefix.lower()): + if date_dots in f or date_underscores in f: + return os.path.join(SHARE_1C_DIR, f) + except Exception as e: + print(f" [⚠️] Ошибка чтения сетевой шары: {e}") + return None + + +def copy_1c_files_from_share(): + r""" + Копирует свежие файлы 1С (Штат и Отсутствия) за Сегодня и Вчера + из сетевой шары \\storage\SCUD\Обмен\Штат в локальную папку data/1c/ + """ + print(f"[0.5/5] Проверка и копирование файлов 1С с шары: {SHARE_1C_DIR}...") + + if not os.path.exists(SHARE_1C_DIR): + print(f"[⚠️] Сетевой шар недоступен или путь не найден: {SHARE_1C_DIR}") + print(" Используем ранее сохраненные локальные файлы из data/1c/\n") + return False + + dates_to_copy = [DATE_TODAY, DATE_YESTERDAY] + prefixes = ["Штат", "Отсутствия"] + copied_count = 0 + + os.makedirs(ZUP_1C_DIR, exist_ok=True) + + for d_str in dates_to_copy: + for prefix in prefixes: + remote_file = find_file_strictly_on_share(prefix, d_str) + + if remote_file and os.path.exists(remote_file): + filename = os.path.basename(remote_file) + local_target_path = os.path.join(ZUP_1C_DIR, filename) + + try: + shutil.copy2(remote_file, local_target_path) + print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/") + copied_count += 1 + except Exception as e: + print(f" [⚠️] Ошибка копирования {filename}: {e}") + else: + d_fmt = d_str.replace('.', '_') + print(f" [ℹ️] На сетевой шаре отсутствует {prefix} за {d_str} ({prefix}_{d_fmt}.xlsx)") + + if copied_count > 0: + print(f"[✓] Успешно скопировано файлов с сетевой шары: {copied_count} шт.\n") + else: + print("[ℹ️] Новых файлов за указанные даты на сетевой шаре не обнаружено.\n") + + return True \ No newline at end of file diff --git a/scripts/db_cli.py b/scripts/db_cli.py new file mode 100644 index 0000000..377a8ac --- /dev/null +++ b/scripts/db_cli.py @@ -0,0 +1,122 @@ +import sys +import os +import sqlite3 +import pandas as pd + +# Добавляем корневую директорию проекта в sys.path +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_ROOT = os.path.dirname(SCRIPT_DIR) +sys.path.append(PROJECT_ROOT) + +from config import DATA_DIR + +DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") + + +def query_db(sql, params=()): + """Выполняет SQL-запрос к локальной БД и возвращает результат в виде DataFrame.""" + if not os.path.exists(DB_PATH): + print(f"❌ База данных не найдена: {DB_PATH}") + return None + try: + conn = sqlite3.connect(DB_PATH) + df = pd.read_sql_query(sql, conn, params=params) + conn.close() + return df + except Exception as e: + print(f"❌ Ошибка выполнения запроса: {e}") + return None + + +def print_db_stats(): + """Выводит статистику по количеству записей во всех таблицах базы данных.""" + if not os.path.exists(DB_PATH): + print(f"❌ База данных не найдена: {DB_PATH}") + return + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables = [t[0] for t in cursor.fetchall() if t[0] != 'sqlite_sequence'] + + print("\n" + "=" * 55) + print(f"📊 СТАТИСТИКА ЛОКАЛЬНОЙ БАЗЫ ДАННЫХ: {DB_PATH}") + print("=" * 55) + + for t in tables: + cursor.execute(f"SELECT COUNT(*) FROM {t}") + count = cursor.fetchone()[0] + print(f" • {t:<22} : {count} строк") + + conn.close() + print("=" * 55 + "\n") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("\nИспользование CLI-модуля базы данных:") + print(" python scripts/db_cli.py stats -- Посмотреть статистику таблиц БД") + print(" python scripts/db_cli.py anomalies -- Просмотреть все обнаруженные аномалии") + print(" python scripts/db_cli.py rules -- Просмотреть все правила Базы Знаний ИИ") + print(" python scripts/db_cli.py scud 03.08.2026 -- Посмотреть логи СКУД за конкретную дату") + print(" python scripts/db_cli.py staff 03.08.2026 -- Посмотреть Штатное расписание за дату") + print(" python scripts/db_cli.py absences 03.08.2026 -- Посмотреть отсутствия из 1С за дату") + print(" python scripts/db_cli.py dump [filename.xlsx] -- Дамп всей базы SQLite в Excel-файл") + sys.exit(0) + + cmd = sys.argv[1].lower() + + if cmd == "stats": + print_db_stats() + + elif cmd == "anomalies": + df = query_db("SELECT * FROM anomalies_history ORDER BY id DESC") + if df is not None: + print("\n🚨 РЕЕСТР НАЙДЕННЫХ АНОМАЛИЙ СКУД ⟷ 1С:") + print(df.to_string(index=False) if not df.empty else "Аномалий в базе пока нет.") + + elif cmd == "rules": + df = query_db("SELECT * FROM ai_knowledge_base ORDER BY id ASC") + if df is not None: + print("\n🧠 ПРАВИЛА БАЗЫ ЗНАНИЙ ИИ (ОБУЧЕНИЕ):") + print(df.to_string(index=False) if not df.empty else "База знаний пуста.") + + elif cmd == "scud" and len(sys.argv) > 2: + date_str = sys.argv[2] + df = query_db("SELECT * FROM scud_logs WHERE log_date = ?", (date_str,)) + if df is not None: + print(f"\n🚪 ЛОГИ СКУД ЗА {date_str} ({len(df)} записей):") + print(df.head(20).to_string(index=False)) + + elif cmd == "staff" and len(sys.argv) > 2: + date_str = sys.argv[2] + df = query_db("SELECT * FROM zup_staff WHERE snapshot_date = ?", (date_str,)) + if df is not None: + print(f"\n📋 ШТАТНОЕ РАСПИСАНИЕ 1С ЗА {date_str} ({len(df)} сотрудников):") + print(df.head(20).to_string(index=False)) + + elif cmd == "absences" and len(sys.argv) > 2: + date_str = sys.argv[2] + df = query_db("SELECT * FROM zup_absences WHERE absence_date = ?", (date_str,)) + if df is not None: + print(f"\n🏥 ДОКУМЕНТЫ ОТСУТСТВИЙ 1С ЗА {date_str} ({len(df)} записей):") + print(df.to_string(index=False) if not df.empty else f"Отсутствий за {date_str} не зарегистрировано.") + + elif cmd == "dump": + out_path = sys.argv[2] if len(sys.argv) > 2 else "db_dump.xlsx" + if not os.path.exists(DB_PATH): + print(f"❌ Файл базы {DB_PATH} не найден.") + sys.exit(1) + + conn = sqlite3.connect(DB_PATH) + tables = ["scud_logs", "zup_staff", "zup_absences", "anomalies_history", "ai_knowledge_base"] + + with pd.ExcelWriter(out_path, engine='openpyxl') as writer: + for table in tables: + df = pd.read_sql_query(f"SELECT * FROM {table}", conn) + df.to_excel(writer, sheet_name=table, index=False) + conn.close() + print(f"✅ Вся база данных успешно экспортирована в Excel-файл: {out_path}") + + else: + print(f"❌ Неизвестная команда: {cmd}") \ No newline at end of file