From 0efbe4ff99af7e6b13b21fa2cd1dd86f7e499b9c Mon Sep 17 00:00:00 2001 From: manoraga Date: Mon, 3 Aug 2026 12:59:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D1=81=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=B7=D1=8B=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20SQLite.?= =?UTF-8?q?=20=D0=92=D1=81=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=B0=D0=BA=D0=BA=D1=83=D0=BC=D1=83=D0=BB=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F=20=D0=B8=20=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=81=D1=8F=20=D0=B2=20=D0=BD=D0=B5=D0=B9.=20?= =?UTF-8?q?=D0=92=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B8=20-=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D0=B2=D0=B5?= =?UTF-8?q?=D0=B1=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81?= =?UTF-8?q?=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_SNAPSHOT_03.08.2026.md | 115 +++++++++++ config.py | 35 ++-- data/knowledge_base.json | 8 +- data/scud_orion_ai.db | Bin 0 -> 421888 bytes main.py | 335 ++++++++++++++++++--------------- modules/ai_verifier.py | 35 +++- modules/data_loader.py | 93 +++++---- modules/data_validator.py | 48 +++-- modules/database.py | 252 +++++++++++++++++++++++++ modules/excel_exporter.py | 40 ++-- modules/feedback_loop.py | 76 +++++--- modules/scud_export.py | 48 +++-- modules/share_copier.py | 69 +++++++ scripts/db_cli.py | 122 ++++++++++++ 14 files changed, 976 insertions(+), 300 deletions(-) create mode 100644 PROJECT_SNAPSHOT_03.08.2026.md create mode 100644 data/scud_orion_ai.db create mode 100644 modules/database.py create mode 100644 modules/share_copier.py create mode 100644 scripts/db_cli.py 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 0000000000000000000000000000000000000000..9e78f4c23ed96a52077eac968034d010235c56e7 GIT binary patch literal 421888 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
    BGE9KDbxrWom30~HRiK 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%0ANC?|l~Qey`bkH`eVe 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 literal 0 HcmV?d00001 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