From 9347c42d224ef03ba0256d67b496ecdb33cae837 Mon Sep 17 00:00:00 2001 From: manoraga Date: Thu, 30 Jul 2026 12:42:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=BC=D0=B5=D0=B6=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D0=B6=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82.=20=D0=9E?= =?UTF-8?q?=D1=82=D1=87=D0=B5=D1=82=D1=8B=20=D0=BA=D0=BE=D1=80=D1=80=D0=BA?= =?UTF-8?q?=D1=82=D0=BD=D0=BE=20=D1=84=D0=BE=D1=80=D0=BC=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F.=20=D0=9F=D0=B5=D1=80=D0=B5=D0=B4=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=201?= =?UTF-8?q?=D0=A1.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_SNAPSHOT.md | 185 ++++++++++++--------------------- PROJECT_SNAPSHOT_old.md | 133 ++++++++++++++++++++++++ config.py | 23 ++-- data/knowledge_base.json | 6 +- data/static_reason_workers.csv | 4 +- main.py | 113 +++++++++++++------- modules/data_loader.py | 43 ++++++-- modules/excel_exporter.py | 75 +++++++++---- modules/scud_export.py | 11 +- modules/text_reporter.py | 121 ++++++++++++++------- 10 files changed, 477 insertions(+), 237 deletions(-) create mode 100644 PROJECT_SNAPSHOT_old.md diff --git a/PROJECT_SNAPSHOT.md b/PROJECT_SNAPSHOT.md index ecf079e..4f642a4 100644 --- a/PROJECT_SNAPSHOT.md +++ b/PROJECT_SNAPSHOT.md @@ -1,133 +1,82 @@ -# Архитектура и документация проекта СКУД v2 +# 📦 ПАСПОРТ СНАПШОТА ПРОЕКТА: `scud_orion_ai_v2` -## 1. Фактическая структура проекта +> **Архитектура:** Автоматизированный конвейер кадрового контроллинга и детекции аномалий +> **Интеграционный стек:** `СКУД «Орион Pro»` ⟷ `1С:ЗУП` ⟷ `AI-Аудитор` +> **Статус:** 🟢 **Готовая боевая сборка** с прямой SQL-интеграцией, обработкой 1С-реестров и обучением на лету. -```text -scud_root_v2/ +--- + +## 📁 Итоговая структура каталогов + +```plaintext +scud_orion_ai_v2/ +├── .gitignore # Настройки исключений отслеживания Git +├── exceptions.json # Реестр постоянных исключений (ОВК, подрядчики, особые роли) +├── knowledge_base.json # Динамическая база знаний (накопленные правила ИИ из режима Human-in-the-Loop) +├── config.py # Глобальная конфигурация путей, моделей Ollama и функций нормализации +├── main.py # Главный оркестратор (Этапы 0–5, логика слияния, детекция аномалий) │ -├── config.py # Конфигурация, глобальные пути (DATA_DIR, OUTPUT_DIR), даты (DATE_TODAY, DATE_YESTERDAY) -├── exceptions.json # Фильтры исключений (ОВК, клинеры, конкретные ФИО) -├── PROJECT_SNAPSHOT.md # 📄 Архитектурный снимок состояния проекта -├── main.py # Главный скрипт запуска и оркестрации всего конвейера +├── data/ # 📂 Папка входных сырых данных +│ ├── scud/ # [Автовыгрузка СКУД] Файлы Сотрудники_ДД.ММ.ГГГГ.xlsx из MS SQL +│ ├── 1c/ # [Кадровые реестры] Файлы из 1С:ЗУП: +│ │ # • Штатные сотрудники - ОУП.xlsx +│ │ # • Отсутствия сотрудников - выгрузка.xlsx +│ └── static_reason_workers.csv# [Справочник] Реестр постоянных причин (Удаленка и т.д.) │ -├── data/ # 📂 Входные файлы (Выгрузки СКУД и 1С) -│ ├── knowledge_base.json # Накопленная база знаний и динамические правила компании -│ ├── Штатные сотрудники - ОУП.xlsx # Выгрузка штата из 1С -│ ├── Отсутствия сотрудников - выгрузка.xlsx # Выгрузка докум. отсутствий из 1С -│ ├── Сотрудники_24.07.2026.xlsx # Выгрузка СКУД за ВЧЕРА (для детального отчета) -│ └── Сотрудники_27.07.2026.xlsx # Выгрузка СКУД за СЕГОДНЯ (для оперативной сводки) +├── output/ # 📂 Результаты работы (Excel-сводки и ИИ-отчеты в Markdown) +│ ├── Сводка_контроллинга_ДД.ММ.ГГГГ.md +│ ├── ДД месяца ГГГГ сводка.xlsx +│ └── ДД месяца ГГГГ отчет.xlsx │ -├── modules/ # 📂 Исполняемые модули системы -│ ├── __init__.py -│ ├── data_validator.py # 🛡️ Детерминированный контроль актуальности и свежести входных файлов СКУД/1С -│ ├── data_loader.py # Загрузка и объединение Excel-выгрузок СКУД и 1С -│ ├── ai_verifier.py # Интеграция с Ollama (qwen2.5:14b) для исправления опечаток в ФИО из СКУД -│ ├── excel_exporter.py # 📊 Единый модуль генерации Excel-файлов (Сводка + Детальный отчет за вчера) -│ ├── text_reporter.py # 📝 ИИ-аудитор (формирование текстовой сводки с проверкой совмещений) -│ ├── knowledge_base.py # Чтение/запись динамических правил компании -│ └── feedback_loop.py # Модуль обратной связи для обучения базы знаний -│ -└── output/ # 📂 Результаты работы (генерируются автоматически): - ├── 26 июля 2026 отчет.xlsx - ├── 27 июля 2026 сводка.xlsx - └── Сводка_контроллинга_27.07.2026.md +└── modules/ # 📂 Функциональные ядро-модули + ├── scud_export.py # Модуль прямого подключения к MS SQL Server (Орион Pro) + ├── data_loader.py # Загрузка и первичная предобработка СКУД/1С файлов + ├── data_validator.py # Валидатор свежести и корректности входных таблиц + ├── ai_verifier.py # Интеграция с Ollama (Qwen2.5) для нечеткого поиска ФИО и опечаток + ├── text_reporter.py # Формирователь экспертного аналитического отчета ИИ + ├── excel_exporter.py # Генератор итоговых стилизованных Excel-отчетов + ├── feedback_loop.py # Модуль интерактивного обучения и дообучения ИИ (Human-in-the-Loop) + └── knowledge_base.py # Менеджер чтения/записи базы знаний knowledge_base.json ``` --- -## 2. Подробное описание модулей и их логики +## 🛠️ Главные технические наработки и решённые задачи -### ⚙️ `config.py` -**Константы:** -- `OLLAMA_URL = "http://localhost:11434/api/chat"` -- `OLLAMA_MODEL = "qwen2.5:14b"` -- `DATE_TODAY`, `DATE_YESTERDAY` — расчет рабочих дат. +### 1. Автоматический забор данных СКУД из БД MS SQL Server (`scud_export.py`) +* **Прямой коннект:** Запрос к БД СКУД `Orion-14.01.21-1` через `pyodbc`. +* **Высокая скорость (0.2 сек):** Использование оконной функции `ROW_NUMBER() OVER (PARTITION BY HozOrgan ORDER BY TimeVal DESC)` для мгновенного сбора событий за 24 часа. +* **Корректный учет текущего присутствия (`СЕГОДНЯ`):** + * Если день ещё не окончен и последнее событие — **Вход**, в колонке выводится `Нет выхода`. + * Ограничено искажение времени для тех, кто ещё находится в здании или уходил на обед. +* **Поддержка отладки (`-d` / `--debug`):** Вывод детальной статистики в консоль (сколько в здании, сколько ушло, сколько отсутствовало). -**Основные функции:** -- `normalize_fio()` — приводит ФИО к нижнему регистру, удаляет скобки `(...)`, меняет латинские визуальные дубликаты букв на кириллицу, заменяет `ё` на `е`. -- `clean_scud_fio_light()` — облегченная очистка ФИО из СКУД от системных символов и артефактов. -- `load_exceptions()` — загружает фильтры и исключения из `exceptions.json`. +### 2. Нормализация и ИИ-сборка данных (`config.py` + `data_loader.py` + `ai_verifier.py`) +* **Удаление технического мусора:** Автоматическая очистка служебных пометок 1С в скобках (`(осн.)`, `(совм.)`, `(сводная)`), предотвращающая расхождение ФИО. +* **Умное слияние с 1С:** Поиск опечаток и несопоставленных записей в реестре отсутствий через нейросеть `Qwen2.5:14b`. + +### 3. Комплексный детектор аномалий (`main.py`) +Автоматическое пересечение всех 4 источников (*СКУД* + *1С* + *Статический реестр* + *Исключения*) с перехватом конфликтов: +* 🔴 **Критическая аномалия:** Физический проход по СКУД при наличии оформленного документа в 1С (*Отпуск* / *Больничный* / *Командировка*). +* 🟠 **Удалёнщик в офисе:** Сотрудник числится на постоянной удалёнке, но пришёл через турникет. +* 🟡 **Конфликт справочников:** Нестыковка данных 1С:ЗУП и реестра статических причин. + +### 4. Обучение на лету / Human-in-the-Loop (`feedback_loop.py` + `knowledge_base.json`) +* **Запоминание правил:** Если пользователь разрешает спорную ситуацию *(например: «Незнанова иногда в офисе — не считать аномалией»)*, правило фиксируется в `knowledge_base.json`. +* **Авто-фильтрация:** Детектор аномалий проверяет Базу Знаний до формирования вопросов, избавляя пользователя от повторных уведомлений по решённым случаям. + +### 5. Лаконичная аналитическая отчетность (`text_reporter.py`) +* Формирование строгой сводки в формате Markdown. +* Выведение отдельным блоком нумерованного списка истинно неизвестных случаев. +* Отсутствие дублирования ФИО в разделе кадровых рекомендаций. --- -### 🛡️ `modules/data_validator.py` -Модуль детерминированного контроля актуальности данных (**Data Quality & Freshness Control**). +## 🚀 Команды для запуска снапшота -- Проверяет наличие файлов за сегодня и вчера в папке `data/`. -- Проверяет дату и время изменения файлов (`mtime`), предотвращая запуск системы на устаревших выгрузках прошлого дня. -- В случае отсутствия или устаревания данных блокирует запуск основного конвейера и предупреждает оператора. - ---- - -### 🛡️ `exceptions.json` -Содержит конфигурационные списки исключений, отфильтровываемые при сборе аналитики: -- **Департаменты:** отдел ОВК и др. -- **Должности:** Уборщик, Клинер, Дворник и т.д. -- **Персональные исключения:** конкретные ФИО сотрудников или подрядчиков. - ---- - -### 📥 `modules/data_loader.py` -- Выполняет загрузку данных из папки `data/`. -- Определяет факт присутствия на рабочем месте (`Пришел = True`, если зафиксирован проход в СКУД). -- Применяет правила фильтрации `is_excluded()` на основе данных из `exceptions.json`. - ---- - -### 🤖 `modules/ai_verifier.py` -- Отправляет список нераспознанных ФИО из СКУД и эталонный список из 1С в модель Ollama (`qwen2.5:14b`). -- Автоматически выявляет опечатки, несоответствия в написании и возвращает JSON сопоставлений. - ---- - -### 📊 `modules/excel_exporter.py` -Универсальный модуль формирования отчетов в формате Excel. Автоматически формирует имена файлов с названием месяца прописью (например, `27 июля 2026 сводка.xlsx` и `26 июля 2026 отчет.xlsx`). - -#### 1. Оперативная сводка за сегодня (`generate_summary_excel`): -- **Древовидная группировка:** Категория *«неизвестно»* раскрыта по умолчанию, остальные категории свернуты под плюсики `[+]` / `[-]`. -- **Настройка структуры:** Принудительно включено отображение боковой панели со значками группировки через `showOutlineSymbols = True`. -- **Палитра:** - - Шапка: `#D9E1F2` *(Светло-голубой)* - - По списку: `#F2F2F2` *(Светло-серый)* - - Неизвестно: `#FCE4D6` *(Персиковый)* - - Категории 1С: Циклическая пастельная палитра (`#FFF2CC`, `#E1D5E7`, `#E1F5FE`, `#FFF0F5`, `#E8F8F5`, `#FCF3CF`) - - Итого на работе: `#E2EFDA` *(Светло-зеленый)* - -#### 2. Детальный отчет за вчера (`generate_detailed_excel`): -- **Динамическая ширина колонок:** Ширина столбцов рассчитывается по длине самого широкого значения ячеек. -- **Столбец G (Причина отсутствия):** Ширина фиксирована на `25.0` (180 px), включен автоперенос текста (`wrap_text = True`) с адаптивным расширением высоты строк под длинные тексты. -- **Цветовая маркировка:** - - `#FFF2CC` *(Желтый)* — есть официальная причина в 1С. - - `#FCE4D6` *(Светло-красный)* — приход отсутствует, документ в 1С не найден. - ---- - -### 📝 `modules/text_reporter.py` -- Формирует с помощью Ollama текстовую Markdown-сводку для руководства. -- **Форматирование нумерованного списка неизвестных:** Строгий формат `N. ФИО — [Аббревиатура], Должность`. -- **Контроль математики:** Количество человек в поле *«Неизвестно»* строго совпадает с количеством элементов в сгенерированном нумерованном списке. -- **Подсветка аномалий:** Выявление совмещений и сотрудников с несколькими табельными номерами/картами СКУД (например, Кондрашова Е.В.). - ---- - -### 🚀 `main.py` -Главный исполнительный модуль системы. Координирует конвейер обработки: - -1. `check_file_freshness()` — **Этап 0:** жесткая проверка свежести файлов. -2. `load_all_data()` — загрузка реестров 1С и СКУД. -3. `ai_verify()` — сопоставление опечаток в ФИО через ИИ. -4. `merge()` — связывание, фильтрация и категоризация данных. -5. **Генерация отчетов** — создание `[дата] сводка.xlsx` и `[дата] отчет.xlsx`. -6. **Вывод ИИ-сводки** — формирование и сохранение текстового итогового отчета в `output/`. - ---- - -## 3. Текущие и будущие задачи - -### 🗄️ Разворачивание локальной базы данных -- Переход от плоских файлов Excel к локальной СУБД (SQLite / PostgreSQL). -- Хранение истории проходов и статусов сотрудников с временными метками для быстрого формирования аналитики за любой выбранный период. - -### 🔄 Переход на прямое подключение к СКУД через T-SQL -- Настройка прямого подключения Python к базе данных СКУД через `pyodbc` / `sqlalchemy`. -- Отказ от ручной выгрузки Excel-файлов СКУД оператором. \ No newline at end of file +| Режим запуска | Команда в терминале | +| :--- | :--- | +| **Стандартный запуск** *(Экспорт СКУД из SQL + ИИ Анализ)* | `python main.py` | +| **Режим отладки** *(С детальными логами)* | `python main.py -d` | +| **Без повторного обращения к SQL** *(На сохраненных файлах)* | `python main.py --skip-export` | +| **Отладка только модуля выгрузки СКУД** | `python modules/scud_export.py -d` | diff --git a/PROJECT_SNAPSHOT_old.md b/PROJECT_SNAPSHOT_old.md new file mode 100644 index 0000000..ecf079e --- /dev/null +++ b/PROJECT_SNAPSHOT_old.md @@ -0,0 +1,133 @@ +# Архитектура и документация проекта СКУД v2 + +## 1. Фактическая структура проекта + +```text +scud_root_v2/ +│ +├── config.py # Конфигурация, глобальные пути (DATA_DIR, OUTPUT_DIR), даты (DATE_TODAY, DATE_YESTERDAY) +├── exceptions.json # Фильтры исключений (ОВК, клинеры, конкретные ФИО) +├── PROJECT_SNAPSHOT.md # 📄 Архитектурный снимок состояния проекта +├── main.py # Главный скрипт запуска и оркестрации всего конвейера +│ +├── data/ # 📂 Входные файлы (Выгрузки СКУД и 1С) +│ ├── knowledge_base.json # Накопленная база знаний и динамические правила компании +│ ├── Штатные сотрудники - ОУП.xlsx # Выгрузка штата из 1С +│ ├── Отсутствия сотрудников - выгрузка.xlsx # Выгрузка докум. отсутствий из 1С +│ ├── Сотрудники_24.07.2026.xlsx # Выгрузка СКУД за ВЧЕРА (для детального отчета) +│ └── Сотрудники_27.07.2026.xlsx # Выгрузка СКУД за СЕГОДНЯ (для оперативной сводки) +│ +├── modules/ # 📂 Исполняемые модули системы +│ ├── __init__.py +│ ├── data_validator.py # 🛡️ Детерминированный контроль актуальности и свежести входных файлов СКУД/1С +│ ├── data_loader.py # Загрузка и объединение Excel-выгрузок СКУД и 1С +│ ├── ai_verifier.py # Интеграция с Ollama (qwen2.5:14b) для исправления опечаток в ФИО из СКУД +│ ├── excel_exporter.py # 📊 Единый модуль генерации Excel-файлов (Сводка + Детальный отчет за вчера) +│ ├── text_reporter.py # 📝 ИИ-аудитор (формирование текстовой сводки с проверкой совмещений) +│ ├── knowledge_base.py # Чтение/запись динамических правил компании +│ └── feedback_loop.py # Модуль обратной связи для обучения базы знаний +│ +└── output/ # 📂 Результаты работы (генерируются автоматически): + ├── 26 июля 2026 отчет.xlsx + ├── 27 июля 2026 сводка.xlsx + └── Сводка_контроллинга_27.07.2026.md +``` + +--- + +## 2. Подробное описание модулей и их логики + +### ⚙️ `config.py` +**Константы:** +- `OLLAMA_URL = "http://localhost:11434/api/chat"` +- `OLLAMA_MODEL = "qwen2.5:14b"` +- `DATE_TODAY`, `DATE_YESTERDAY` — расчет рабочих дат. + +**Основные функции:** +- `normalize_fio()` — приводит ФИО к нижнему регистру, удаляет скобки `(...)`, меняет латинские визуальные дубликаты букв на кириллицу, заменяет `ё` на `е`. +- `clean_scud_fio_light()` — облегченная очистка ФИО из СКУД от системных символов и артефактов. +- `load_exceptions()` — загружает фильтры и исключения из `exceptions.json`. + +--- + +### 🛡️ `modules/data_validator.py` +Модуль детерминированного контроля актуальности данных (**Data Quality & Freshness Control**). + +- Проверяет наличие файлов за сегодня и вчера в папке `data/`. +- Проверяет дату и время изменения файлов (`mtime`), предотвращая запуск системы на устаревших выгрузках прошлого дня. +- В случае отсутствия или устаревания данных блокирует запуск основного конвейера и предупреждает оператора. + +--- + +### 🛡️ `exceptions.json` +Содержит конфигурационные списки исключений, отфильтровываемые при сборе аналитики: +- **Департаменты:** отдел ОВК и др. +- **Должности:** Уборщик, Клинер, Дворник и т.д. +- **Персональные исключения:** конкретные ФИО сотрудников или подрядчиков. + +--- + +### 📥 `modules/data_loader.py` +- Выполняет загрузку данных из папки `data/`. +- Определяет факт присутствия на рабочем месте (`Пришел = True`, если зафиксирован проход в СКУД). +- Применяет правила фильтрации `is_excluded()` на основе данных из `exceptions.json`. + +--- + +### 🤖 `modules/ai_verifier.py` +- Отправляет список нераспознанных ФИО из СКУД и эталонный список из 1С в модель Ollama (`qwen2.5:14b`). +- Автоматически выявляет опечатки, несоответствия в написании и возвращает JSON сопоставлений. + +--- + +### 📊 `modules/excel_exporter.py` +Универсальный модуль формирования отчетов в формате Excel. Автоматически формирует имена файлов с названием месяца прописью (например, `27 июля 2026 сводка.xlsx` и `26 июля 2026 отчет.xlsx`). + +#### 1. Оперативная сводка за сегодня (`generate_summary_excel`): +- **Древовидная группировка:** Категория *«неизвестно»* раскрыта по умолчанию, остальные категории свернуты под плюсики `[+]` / `[-]`. +- **Настройка структуры:** Принудительно включено отображение боковой панели со значками группировки через `showOutlineSymbols = True`. +- **Палитра:** + - Шапка: `#D9E1F2` *(Светло-голубой)* + - По списку: `#F2F2F2` *(Светло-серый)* + - Неизвестно: `#FCE4D6` *(Персиковый)* + - Категории 1С: Циклическая пастельная палитра (`#FFF2CC`, `#E1D5E7`, `#E1F5FE`, `#FFF0F5`, `#E8F8F5`, `#FCF3CF`) + - Итого на работе: `#E2EFDA` *(Светло-зеленый)* + +#### 2. Детальный отчет за вчера (`generate_detailed_excel`): +- **Динамическая ширина колонок:** Ширина столбцов рассчитывается по длине самого широкого значения ячеек. +- **Столбец G (Причина отсутствия):** Ширина фиксирована на `25.0` (180 px), включен автоперенос текста (`wrap_text = True`) с адаптивным расширением высоты строк под длинные тексты. +- **Цветовая маркировка:** + - `#FFF2CC` *(Желтый)* — есть официальная причина в 1С. + - `#FCE4D6` *(Светло-красный)* — приход отсутствует, документ в 1С не найден. + +--- + +### 📝 `modules/text_reporter.py` +- Формирует с помощью Ollama текстовую Markdown-сводку для руководства. +- **Форматирование нумерованного списка неизвестных:** Строгий формат `N. ФИО — [Аббревиатура], Должность`. +- **Контроль математики:** Количество человек в поле *«Неизвестно»* строго совпадает с количеством элементов в сгенерированном нумерованном списке. +- **Подсветка аномалий:** Выявление совмещений и сотрудников с несколькими табельными номерами/картами СКУД (например, Кондрашова Е.В.). + +--- + +### 🚀 `main.py` +Главный исполнительный модуль системы. Координирует конвейер обработки: + +1. `check_file_freshness()` — **Этап 0:** жесткая проверка свежести файлов. +2. `load_all_data()` — загрузка реестров 1С и СКУД. +3. `ai_verify()` — сопоставление опечаток в ФИО через ИИ. +4. `merge()` — связывание, фильтрация и категоризация данных. +5. **Генерация отчетов** — создание `[дата] сводка.xlsx` и `[дата] отчет.xlsx`. +6. **Вывод ИИ-сводки** — формирование и сохранение текстового итогового отчета в `output/`. + +--- + +## 3. Текущие и будущие задачи + +### 🗄️ Разворачивание локальной базы данных +- Переход от плоских файлов Excel к локальной СУБД (SQLite / PostgreSQL). +- Хранение истории проходов и статусов сотрудников с временными метками для быстрого формирования аналитики за любой выбранный период. + +### 🔄 Переход на прямое подключение к СКУД через T-SQL +- Настройка прямого подключения Python к базе данных СКУД через `pyodbc` / `sqlalchemy`. +- Отказ от ручной выгрузки Excel-файлов СКУД оператором. \ No newline at end of file diff --git a/config.py b/config.py index f5fd21d..23e7c84 100644 --- a/config.py +++ b/config.py @@ -1,28 +1,25 @@ import os +import re from datetime import datetime, timedelta BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, "data") -# 📂 ПОДПАПКИ DАТА 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") -# Создаем директории при их отсутствии for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]: os.makedirs(folder, exist_ok=True) -# 📅 Даты NOW = datetime.now() DATE_TODAY = NOW.strftime("%d.%m.%Y") -if NOW.weekday() == 0: # Понедельник -> Вчера = Пятница +if NOW.weekday() == 0: DATE_YESTERDAY = (NOW - timedelta(days=3)).strftime("%d.%m.%Y") 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") @@ -30,7 +27,6 @@ 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 / RAG (объявляем оба имени для совместимости) OLLAMA_URL = "http://10.121.18.227:11434/api/generate" OLLAMA_MODEL = "qwen2.5:14b" MODEL_NAME = OLLAMA_MODEL @@ -40,17 +36,22 @@ EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json") def normalize_fio(fio): + """ + Нормализует ФИО: + 1. Вырезает скобки и их содержимое: (осн.), (совм.), (сводная), (внешн.) + 2. Заменяет неразрывные пробелы \xa0 на обычные + 3. Приводит к регистру Заглавных Букв + """ if not fio or not isinstance(fio, str): return "" - parts = fio.strip().split() + fio_clean = re.sub(r'\(.*?\)', '', fio) + fio_clean = fio_clean.replace('\xa0', ' ') + parts = fio_clean.strip().split() return " ".join(parts).title() def clean_scud_fio_light(fio_str): - if not isinstance(fio_str, str): - return "" - fio_clean = fio_str.replace("(сводная)", "").strip() - return normalize_fio(fio_clean) + return normalize_fio(fio_str) def load_exceptions(): diff --git a/data/knowledge_base.json b/data/knowledge_base.json index 6403666..fab60ee 100644 --- a/data/knowledge_base.json +++ b/data/knowledge_base.json @@ -2,7 +2,11 @@ "rules": [ "Кондрашова Елена Владимировна имеет 2 пропуска в СКУД. Если один 'Отсутствовал', а второй с временем входа — считать ПРИШЕДШЕЙ.", "Сотрудники подразделения ОА с должностью 'Инженер-программист' часто являются внутренними совместителями.", - "Сотрудник Незнанова Валерия Игоревна: Незнанова иногда присутствует в офисе, не считать аномалией. Когда в офисе, из удаленщиков в отчете исключать!" + "Сотрудник Незнанова Валерия Игоревна: Незнанова иногда присутствует в офисе, не считать аномалией. Когда в офисе, из удаленщиков в отчете исключать!", + "Сотрудник Ковалев Владимир Владимирович: Все сотрудники на удаленной работе имеют право находится в здании в любой день. При этом в отчет они должны попадать, как на работе и вычитаться из удаленщиков на этот день.", + "Сотрудник Королёва Наталья Александровна: Все сотрудники на удаленной работе имеют право находится в здании в любой день. При этом в отчет они должны попадать, как на работе и вычитаться из удаленщиков на этот день.", + "Сотрудник Кожокарь Татьяна Юрьевна: Нет. Уже указывал, что любые удаленщики имеют право находитться в офисе. Это не является аномалией. Должны исключаться из отчета из удаленщиков, а включаться в список тех, кто на работе.", + "Сотрудник Познякова Татьяна Сергеевна: Нет. Уже указывал, что любые удаленщики имеют право находитться в офисе. Это не является аномалией. Должны исключаться из отчета из удаленщиков, а включаться в список тех, кто на работе." ], "fio_corrections": { "кондрашава елена владимировна": "Кондрашова Елена Владимировна" diff --git a/data/static_reason_workers.csv b/data/static_reason_workers.csv index fdb4137..57e5807 100644 --- a/data/static_reason_workers.csv +++ b/data/static_reason_workers.csv @@ -7,4 +7,6 @@ fio,reason,note Шуличенко Иван Иванович,Удаленная работа,Постоянная удаленка Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка -Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка \ No newline at end of file +Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка +Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка +Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка \ No newline at end of file diff --git a/main.py b/main.py index 2104bdb..8587280 100644 --- a/main.py +++ b/main.py @@ -44,12 +44,11 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules): is_present = row.get('Пришел', False) reason_1c = str(row.get('Вид_отсутствия', '')).strip() has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' - is_excluded = row.get('is_excluded', False) has_static_reason = fio_clean in static_reasons_dict is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text - # 🚨 АНОМАЛИЯ 1: Пришел по СКУД, но в 1С числится отсутствие + # 🚨 АНОМАЛИЯ 1: Пришел по СКУД, но в 1С числится отсутствие (Отпуск/Больничный) if is_present and has_1c_reason: if not is_fio_whitelisted_in_kb: anomalies.append({ @@ -119,17 +118,17 @@ def main(): kb_data = load_knowledge_base() kb_rules = kb_data.get("rules", []) - # 0. Выгрузка из БД + # 0. Выгрузка из БД СКУД if not args.skip_export: print("[0/5] Экспорт свежих данных из базы СКУД 'Орион Pro'...") try: run_export(debug=DEBUG) except Exception as e: - print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Используем файлы в data/scud.") + print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Используем имеющиеся файлы в data/scud.") else: print("[0/5] Пропуск прямого экспорта из БД (--skip-export)...") - # 1. Валидация + # 1. Валидация файлов print("[1/5] Проверка актуальности и свежести входных файлов...") is_valid, warnings, errors = check_file_freshness() @@ -149,22 +148,33 @@ def main(): print("[✓] Проверка актуальности успешно пройдена!\n") - # 2. Загрузка данных + # 2. Раздельная загрузка данных (Сегодня и Вчера) print("[2/5] Загрузка данных из data/scud, data/1c, реестра причин и исключений...") - raw_scud_df, staff_fios_clean, df_absent = load_all_data() + raw_scud_today_df, raw_scud_yesterday_df, staff_fios_clean, df_absent = load_all_data() static_reasons_dict = load_static_reason_workers() # 3. ИИ-сверка ФИО (СКУД + 1С) print("[3/5] Проверка опечаток и сопоставление ФИО через ИИ...") - scud_unrecognized = raw_scud_df[~raw_scud_df['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist() - fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_clean) + # Сверка СКУД (Сегодня) + 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_df['fio_clean'] = raw_scud_df['fio_clean'].apply( + 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) + 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) @@ -174,60 +184,80 @@ def main(): lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x ) - raw_scud_df = aggregate_scud_by_employee(raw_scud_df, debug=DEBUG) + 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 = raw_scud_df[raw_scud_df['fio_clean'].isin(staff_fios_clean)].copy() - merged = merged.merge(df_absent, on='fio_clean', how='left') + # Датасет СЕГОДНЯ + 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') - # Детекция аномалий С УЧЁТОМ НАКОПЛЕННОЙ БАЗЫ ЗНАНИЙ - anomalies_list = detect_all_anomalies(merged, static_reasons_dict, kb_rules) + # Датасет ВЧЕРА + 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['Пришел'] == False) & - (merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged['fio_clean'] == fio_clean) + (merged_today['Пришел'] == False) & + (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & + (merged_today['fio_clean'] == fio_clean) ) - merged.loc[mask_apply_static, 'Вид_отсутствия'] = reason_val + merged_today.loc[mask_apply_static, 'Вид_отсутствия'] = reason_val + + mask_apply_static_y = ( + (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 - # Применение исключений mask_apply_exceptions = ( - (merged['Пришел'] == False) & - (merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged.get('is_excluded', False) == True) + (merged_today['Пришел'] == False) & + (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & + (merged_today.get('is_excluded', False) == True) ) - merged.loc[mask_apply_exceptions, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' + merged_today.loc[mask_apply_exceptions, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' - absent_explained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].notna())] - absent_unexplained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].isna())] - scud_present_but_absent_in_1c = merged[(merged['Пришел'] == True) & (merged['Вид_отсутствия'].notna())] + mask_apply_exceptions_y = ( + (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, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' + + 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)}") - is_working_mask = (merged['Пришел'] == True) | ( - merged['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) + 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[is_working_mask])}") + print(f" Работают сегодня: {len(merged_today[is_working_mask])}") print(f" Официально отсутствуют: {len(absent_explained)}") print(f" Истинно неизвестные случаи: {len(absent_unexplained)}") - print(f" Обнаружено комплексных аномалий: {len(anomalies_list)}") + print(f" Обнаружено аномалий: {len(anomalies_list)}") print("---------------------\n") - # 5. ИИ-аудитор - print("[5/5] Запуск ИИ-аудитора и глубокий анализ аномалий...") + # 5. ИИ-аудитор и сборка финальных Excel-отчетов + print("[5/5] Запуск ИИ-аудитора и сборка финальных Excel-отчетов...") report_text = generate_markdown_report( - merged_df=merged, + 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_df, + raw_scud_df=raw_scud_today_df, + raw_absent_df=df_absent, # <-- ВАЖНО: передаем выгрузку 1С для ИИ-кросс-сверки date_str=DATE_TODAY ) @@ -241,8 +271,11 @@ def main(): review_ai_decisions(report_text, suspicious_cases) - generate_summary_excel(merged_df=merged, date_str=DATE_TODAY) - generate_detailed_excel(merged_df=merged, date_str=DATE_YESTERDAY) + # 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("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИС-АУДИТОРА:") diff --git a/modules/data_loader.py b/modules/data_loader.py index dd50cea..f55e585 100644 --- a/modules/data_loader.py +++ b/modules/data_loader.py @@ -3,12 +3,13 @@ import pandas as pd import warnings from config import ( normalize_fio, clean_scud_fio_light, load_exceptions, - DATA_DIR, SCUD_FILE, SCUD_FILE_TODAY, SCUD_FILE_YESTERDAY, + SCUD_FILE, SCUD_FILE_TODAY, SCUD_FILE_YESTERDAY, STAFF_FILE_1C, ABSENT_FILE_1C ) 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 "" @@ -20,6 +21,7 @@ def is_excluded(fio, dept, pos, exceptions): return True return False + def load_staff_data(): if not os.path.exists(STAFF_FILE_1C): raise FileNotFoundError(f"Файл штатного расписания не найден по пути: {STAFF_FILE_1C}") @@ -28,6 +30,7 @@ def load_staff_data(): 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}") @@ -37,10 +40,24 @@ def load_absent_data(): df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio) return df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean']) -def load_scud_data(): - scud_path = SCUD_FILE_TODAY if os.path.exists(SCUD_FILE_TODAY) else (SCUD_FILE_YESTERDAY if os.path.exists(SCUD_FILE_YESTERDAY) else SCUD_FILE) + +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 + + # Резервный фоллбэк, если конкретный файл отсутствия еще не создан if not os.path.exists(scud_path): - raise FileNotFoundError(f"Файл СКУД не найден в папке data/scud: {scud_path}") + if os.path.exists(SCUD_FILE): + scud_path = SCUD_FILE + else: + raise FileNotFoundError(f"Файл СКУД [{target_date_type}] не найден по пути: {scud_path}") df_scud = pd.read_excel(scud_path) exceptions = load_exceptions() @@ -51,7 +68,6 @@ def load_scud_data(): start_col = 'Начало_дня' if 'Начало_дня' in df_scud.columns else ('Начало дня' if 'Начало дня' in df_scud.columns else None) status_col = 'Статус' if 'Статус' in df_scud.columns else None - # 🎯 ФИЗИЧЕСКИЙ ФАКТ ПРИСУТСТВИЯ def is_person_present(row): val_status = str(row.get(status_col, '')).strip().lower() if status_col else '' val_time = str(row.get(start_col, '')).strip() if start_col else '' @@ -68,9 +84,22 @@ def load_scud_data(): ) return df_scud + def load_all_data(): - df_scud = load_scud_data() + """ + Загружает слитный датасет за сегодня, а также отдельный датасет за вчера. + Возвращает: df_scud_today, df_scud_yesterday, staff_fios_clean, df_absent + """ + 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() - return df_scud, staff_fios_clean, df_absent \ No newline at end of file + + return df_scud_today, df_scud_yesterday, staff_fios_clean, df_absent \ No newline at end of file diff --git a/modules/excel_exporter.py b/modules/excel_exporter.py index daa5b79..b856ae9 100644 --- a/modules/excel_exporter.py +++ b/modules/excel_exporter.py @@ -38,8 +38,43 @@ FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type=" CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"] # Палитра для Детального отчета -YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") -LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") +YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # Обычные отсутствия +LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Неизвестные случаи +GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Аномалии (пришел в отпуске) + + +def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30): + """Рассчитывает точное отклонение от нормы с учетом обеда 30 мин и уважительных причин""" + if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "": + return "0:00" + + if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']: + return f"-{norm_hours}:00" + + try: + parts = time_in_building_str.strip().split(':') + hh = int(parts[0]) + mm = int(parts[1]) if len(parts) > 1 else 0 + total_in_building_minutes = hh * 60 + mm + + if total_in_building_minutes == 0: + return f"-{norm_hours}:00" + + work_minutes = max(0, total_in_building_minutes - lunch_minutes) + norm_minutes = norm_hours * 60 + diff = work_minutes - norm_minutes + + if diff == 0: + return "0:00" + + sign = "-" if diff < 0 else "" + abs_diff = abs(diff) + res_hh = abs_diff // 60 + res_mm = abs_diff % 60 + + return f"{sign}{res_hh}:{res_mm:02d}" + except Exception: + return f"-{norm_hours}:00" def apply_borders_to_cell(cell, border=THIN_BORDER): @@ -61,7 +96,7 @@ def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_f cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b) -# --- 1. СВОДКА НА СЕГОДНЯ --- +# --- 1. СВОДКА НА СЕГОДНЯ (ОСТАВЛЕНА БЕЗ ИЗМЕНЕНИЙ, КАК ЕСТЬ) --- def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): if not filename: filename = f"{format_date_ru(date_str)} сводка.xlsx" @@ -127,7 +162,7 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): ws.row_dimensions[current_row].hidden = True current_row += 1 - # 🎯 5. ИТОГО НА РАБОТЕ (Включает: пришли по СКУД + Командировки + Удаленная работа) + # 5. ИТОГО НА РАБОТЕ (Включает: пришли по СКУД + Командировки + Удаленная работа) is_working_mask = (merged_df['Пришел'] == True) | ( merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) ) @@ -138,10 +173,7 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font) current_row += 1 - # 🚨 6. АНОМАЛИИ (В самом низу таблицы, раскрывающийся список) - # Столбец A: f"{idx}. {fio} (На работе)" - # Столбец B: f"В 1С: {reason}" - # Ширина столбца B = 32.0 (ровно 230px). Автоперенос + динамическая высота строк. + # 6. АНОМАЛИИ (В самом низу таблицы, раскрывающийся список) anomalies = merged_df[(merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna())] ws.cell(row=current_row, column=1, value="Аномалии") @@ -165,11 +197,9 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): apply_borders_to_cell(cell_a) apply_borders_to_cell(cell_b) - # Автоперенос текста в столбце B cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) cell_a.alignment = Alignment(horizontal="left", vertical="center") - # Адаптивный расчет высоты строки if len(reason_text) > chars_per_line_b: lines_count = math.ceil(len(reason_text) / chars_per_line_b) ws.row_dimensions[current_row].height = max(lines_count * 18, 22) @@ -177,10 +207,9 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): ws.row_dimensions[current_row].height = 20 ws.row_dimensions[current_row].outlineLevel = 1 - ws.row_dimensions[current_row].hidden = True # Свернут по умолчанию + ws.row_dimensions[current_row].hidden = True current_row += 1 - # 📏 Настройка ширины колонок: B = 32.0 (ровно 230 пикселей) ws.column_dimensions['A'].width = 45.0 ws.column_dimensions['B'].width = 32.0 # Ровно 230px в Excel @@ -194,7 +223,7 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") -# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА --- +# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (С ДОРАБОТАННЫМ РАСЧЕТОМ И ЗЕЛЕНОЙ ПОДСВЕТКОЙ АНОМАЛИЙ) --- def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): if not filename: filename = f"{format_date_ru(date_str)} отчет.xlsx" @@ -235,22 +264,32 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): absence_reason = row.get('Вид_отсутствия', '') has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != '' + 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([ idx + 1, row.get('Сотрудник', ''), row.get('Подразделение', ''), row.get(start_col, 'Нет входа'), row.get(end_col, 'Нет выхода'), - row.get(hours_col, '0:00'), + in_building_str, absence_reason if has_reason else '', 8, - "-8:00" if not is_present else "0:00" + deviation_val ]) row_num = 5 + idx - row_fill = None - if not is_present: - row_fill = YELLOW_FILL if has_reason else LIGHT_RED_FILL + + # Разграничение заливки для Детального отчета: + if is_present and has_reason: + row_fill = GREEN_FILL # Аномалия (Пришел в отпуске/на больничном) -> Бледно-зеленый + elif not is_present: + row_fill = YELLOW_FILL if has_reason else LIGHT_RED_FILL # Обычные отсутствия / Неизвестные + else: + row_fill = None val_g_str = str(absence_reason) if has_reason else "" if len(val_g_str) > chars_per_line_g: diff --git a/modules/scud_export.py b/modules/scud_export.py index 2d47d9f..44983fc 100644 --- a/modules/scud_export.py +++ b/modules/scud_export.py @@ -1,5 +1,6 @@ """ Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в Excel. +Исправлена логика определения направления проходов (Mode = 2 -> OUT). """ import argparse @@ -63,15 +64,18 @@ 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, log.TimeVal, log.Event, log.Mode, - CASE WHEN log.Event IN (28, 26, 32) OR log.Mode = 1 THEN 'IN' - WHEN log.Event IN (29, 27, 33) OR log.Mode = 2 THEN 'OUT' - ELSE 'OTHER' END AS Direction, + CASE + WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT' + WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN' + ELSE 'OTHER' + END AS Direction, ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc FROM pLogData log WITH (NOLOCK) WHERE log.TimeVal BETWEEN @StartDate AND @EndDate @@ -79,6 +83,7 @@ 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, diff --git a/modules/text_reporter.py b/modules/text_reporter.py index 6c5e2f8..34b333c 100644 --- a/modules/text_reporter.py +++ b/modules/text_reporter.py @@ -1,34 +1,72 @@ import json +import difflib +import re from modules.ai_verifier import ask_ollama from modules.knowledge_base import load_knowledge_base -def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, date_str="28.07.2026"): +def find_python_fuzzy_matches(unexplained_df, raw_absent_df): + """ + Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ). + Сравнивает фамилию и полное имя с порогом сходства >= 0.80. + """ + if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty: + return [] + + # Собираем словарь чистых ФИО 1С -> сырые ФИО 1С + absent_dict = {} + for idx, r in raw_absent_df.iterrows(): + raw_fio = str(r.get('ФИО', '')) + clean_fio = str(r.get('fio_clean', '')) + reason = str(r.get('Вид_отсутствия', '')) + if clean_fio and clean_fio not in ['Ао "Ленморниипроект"', 'Сотрудник', 'Nan', 'None']: + absent_dict[clean_fio] = {'raw_fio': raw_fio, 'reason': reason} + + matches = [] + unexplained_fios = unexplained_df['fio_clean'].unique() if 'fio_clean' in unexplained_df.columns else [] + + for fio in unexplained_fios: + fio_parts = fio.split() + if not fio_parts: + continue + surname = fio_parts[0].lower() + + for abs_clean, abs_info in absent_dict.items(): + abs_parts = abs_clean.split() + if not abs_parts: + continue + abs_surname = abs_parts[0].lower() + + # Строгая проверка совпадения фамилии + if surname == abs_surname: + ratio = difflib.SequenceMatcher(None, fio.lower(), abs_clean.lower()).ratio() + if ratio >= 0.75: + matches.append({ + "target_fio": fio, + "found_in_1c_raw": abs_info['raw_fio'], + "reason_1c": abs_info['reason'] + }) + break + + return matches + + +def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, raw_absent_df=None, date_str="29.07.2026"): kb = load_knowledge_base() custom_rules = kb.get("rules", []) custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет." - detailed_cases_for_ai = [] - unexplained_fios = absent_unexplained['fio_clean'].unique() if 'fio_clean' in absent_unexplained.columns else [] + # 1. Точный расчет неточных совпадений через Python (Исключает галлюцинации ИИ) + fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df) - for fio_clean in unexplained_fios: - staff_matches = [] - if raw_staff_df is not None and not raw_staff_df.empty and 'fio_clean' in raw_staff_df.columns: - st_m = raw_staff_df[raw_staff_df['fio_clean'] == fio_clean] - cols_staff = [c for c in ['ФИО', 'Подразделение', 'Должность', 'Таб_№'] if c in st_m.columns] - staff_matches = st_m[cols_staff].to_dict(orient='records') - - scud_matches = [] - if raw_scud_df is not None and not raw_scud_df.empty and 'fio_clean' in raw_scud_df.columns: - sc_m = raw_scud_df[raw_scud_df['fio_clean'] == fio_clean] - cols_scud = [c for c in ['Сотрудник', 'Подразделение', 'Должность', 'Начало_дня', 'Статус'] if c in sc_m.columns] - scud_matches = sc_m[cols_scud].to_dict(orient='records') - - detailed_cases_for_ai.append({ - "fio_clean": fio_clean, - "staff_records_1C": staff_matches, - "scud_records_LOGS": scud_matches - }) + # 2. Список истинно неизвестных для ИИ + unexplained_list = [] + if not absent_unexplained.empty: + for idx, r in absent_unexplained.iterrows(): + fio = r.get('Сотрудник', r.get('fio_clean', '')) + dept = r.get('Подразделение', '—') + pos = r.get('Должность', '—') + unexplained_list.append(f"{fio} — {dept}, {pos}") total_staff = len(merged_df) is_working_mask = (merged_df['Пришел'] == True) | ( @@ -41,25 +79,34 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc prompt = f""" Ты — старший аудитор кадровой безопасности и контроллинга СКУД. -Проанализируй аномалии и пересечения данных за {date_str}. +Сформируй итоговую сводку кадрового контроля на {date_str}. -📌 НАКОПЛЕННЫЕ ПРАВИЛА ПРЕДПРИЯТИЯ И ИСКЛЮЧЕНИЯ: +📌 БАЗА ЗНАНИЙ И ПРАВИЛА ПРЕДПРИЯТИЯ: {custom_rules_str} 🚨 ВХОДНЫЕ МЕТРИКИ: - Всего сотрудников: {total_staff} -- На работе / Удаленно / Командировка: {present_cnt} -- Официально отсутствуют (1С/Реестры): {explained_cnt} -- Неизвестно (требуют выяснения): {unexplained_cnt} -- Выявлено аномалий и конфликтов реестров: {anomalies_cnt} +- Работают (офис / удаленка / командировки): {present_cnt} +- Официально отсутствуют: {explained_cnt} +- Неизвестно (истинно неотмеченные): {unexplained_cnt} +- Выявлено аномалий/конфликтов реестров: {anomalies_cnt} --- -🚨 ОБНАРУЖЕННЫЕ АНОМАЛИИ И КОНФЛИКТЫ МЕЖДУ СКУД, 1С, СТАТИЧЕСКИМИ РЕЕСТРАМИ И ИСКЛЮЧЕНИЯМИ ({anomalies_cnt} шт.): +🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт): {json.dumps(anomalies_list, ensure_ascii=False, indent=2)} +--- +⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт): +{json.dumps(fio_mismatches_python, ensure_ascii=False, indent=2)} + --- 📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел): -{json.dumps(detailed_cases_for_ai, ensure_ascii=False, indent=2)} +{json.dumps(unexplained_list, ensure_ascii=False, indent=2)} + +СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ: +1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО придумывать несуществующие совпадения ФИО или приписывать суффиксы "(осн.)". Используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если этот массив пуст, напиши в этом разделе: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено." +2. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше. +3. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы. НЕ ПЕРЕЧИСЛЯЙ конкретные ФИО в тексте рекомендаций. СТРОГИЙ ШАБЛОН ОТВЕТА: @@ -72,19 +119,17 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc - Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт. #### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}): -(Подробно разбери каждый конфликт из `anomalies_list`. Опиши суть нарушения. Если аномалий нет — напиши "Аномалий и конфликтов не обнаружено.") +(Описание аномалий из anomalies_list. Если их нет — "Аномалий не обнаружено.") -#### ⚠️ Подозрения на ложные срабатывания / Совмещения: -(Укажи сотрудников с повторными проходами или несколькими картами, если такие есть) +#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С: +(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива fio_mismatches_python. Если он пуст — "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.") #### Неизвестные случаи: {unexplained_cnt} -(Выведи НУМЕРОВАННЫЙ СПИСОК ВСЕХ {unexplained_cnt} человек в формате: -1. ФИО — Подразделение, Должность -2. ФИО — Подразделение, Должность) +(Выведи нумерованный список всех {unexplained_cnt} человек) -### Рекомендации: -(Сформулируй 2-3 общие системные рекомендации для кадровой службы. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО перечислять конкретные ФИО сотрудников в скобках или тексте рекомендаций — они уже перечислены выше.) +### Точечные рекомендации: +(2-3 системные рекомендации без указания ФИО сотрудников) """ - sys_prompt = "Ты — эксперт кадрового аудита. Пиши сжато, лаконично, без дублирования ФИО сотрудников в блоке рекомендаций." + sys_prompt = "Ты — эксперт кадрового аудита. Будь точен, не придумывай непереданные данные." return ask_ollama(prompt, system_prompt=sys_prompt) \ No newline at end of file