промежуточный положительный результат. Отчеты коррктно формируются. Перед автоматической загрузкой файлов 1С.
This commit is contained in:
+67
-118
@@ -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)
|
├── data/ # 📂 Папка входных сырых данных
|
||||||
├── exceptions.json # Фильтры исключений (ОВК, клинеры, конкретные ФИО)
|
│ ├── scud/ # [Автовыгрузка СКУД] Файлы Сотрудники_ДД.ММ.ГГГГ.xlsx из MS SQL
|
||||||
├── PROJECT_SNAPSHOT.md # 📄 Архитектурный снимок состояния проекта
|
│ ├── 1c/ # [Кадровые реестры] Файлы из 1С:ЗУП:
|
||||||
├── main.py # Главный скрипт запуска и оркестрации всего конвейера
|
│ │ # • Штатные сотрудники - ОУП.xlsx
|
||||||
|
│ │ # • Отсутствия сотрудников - выгрузка.xlsx
|
||||||
|
│ └── static_reason_workers.csv# [Справочник] Реестр постоянных причин (Удаленка и т.д.)
|
||||||
│
|
│
|
||||||
├── data/ # 📂 Входные файлы (Выгрузки СКУД и 1С)
|
├── output/ # 📂 Результаты работы (Excel-сводки и ИИ-отчеты в Markdown)
|
||||||
│ ├── knowledge_base.json # Накопленная база знаний и динамические правила компании
|
│ ├── Сводка_контроллинга_ДД.ММ.ГГГГ.md
|
||||||
│ ├── Штатные сотрудники - ОУП.xlsx # Выгрузка штата из 1С
|
│ ├── ДД месяца ГГГГ сводка.xlsx
|
||||||
│ ├── Отсутствия сотрудников - выгрузка.xlsx # Выгрузка докум. отсутствий из 1С
|
│ └── ДД месяца ГГГГ отчет.xlsx
|
||||||
│ ├── Сотрудники_24.07.2026.xlsx # Выгрузка СКУД за ВЧЕРА (для детального отчета)
|
|
||||||
│ └── Сотрудники_27.07.2026.xlsx # Выгрузка СКУД за СЕГОДНЯ (для оперативной сводки)
|
|
||||||
│
|
│
|
||||||
├── modules/ # 📂 Исполняемые модули системы
|
└── modules/ # 📂 Функциональные ядро-модули
|
||||||
│ ├── __init__.py
|
├── scud_export.py # Модуль прямого подключения к MS SQL Server (Орион Pro)
|
||||||
│ ├── data_validator.py # 🛡️ Детерминированный контроль актуальности и свежести входных файлов СКУД/1С
|
├── data_loader.py # Загрузка и первичная предобработка СКУД/1С файлов
|
||||||
│ ├── data_loader.py # Загрузка и объединение Excel-выгрузок СКУД и 1С
|
├── data_validator.py # Валидатор свежести и корректности входных таблиц
|
||||||
│ ├── ai_verifier.py # Интеграция с Ollama (qwen2.5:14b) для исправления опечаток в ФИО из СКУД
|
├── ai_verifier.py # Интеграция с Ollama (Qwen2.5) для нечеткого поиска ФИО и опечаток
|
||||||
│ ├── excel_exporter.py # 📊 Единый модуль генерации Excel-файлов (Сводка + Детальный отчет за вчера)
|
├── text_reporter.py # Формирователь экспертного аналитического отчета ИИ
|
||||||
│ ├── text_reporter.py # 📝 ИИ-аудитор (формирование текстовой сводки с проверкой совмещений)
|
├── excel_exporter.py # Генератор итоговых стилизованных Excel-отчетов
|
||||||
│ ├── knowledge_base.py # Чтение/запись динамических правил компании
|
├── feedback_loop.py # Модуль интерактивного обучения и дообучения ИИ (Human-in-the-Loop)
|
||||||
│ └── feedback_loop.py # Модуль обратной связи для обучения базы знаний
|
└── knowledge_base.py # Менеджер чтения/записи базы знаний knowledge_base.json
|
||||||
│
|
|
||||||
└── output/ # 📂 Результаты работы (генерируются автоматически):
|
|
||||||
├── 26 июля 2026 отчет.xlsx
|
|
||||||
├── 27 июля 2026 сводка.xlsx
|
|
||||||
└── Сводка_контроллинга_27.07.2026.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Подробное описание модулей и их логики
|
## 🛠️ Главные технические наработки и решённые задачи
|
||||||
|
|
||||||
### ⚙️ `config.py`
|
### 1. Автоматический забор данных СКУД из БД MS SQL Server (`scud_export.py`)
|
||||||
**Константы:**
|
* **Прямой коннект:** Запрос к БД СКУД `Orion-14.01.21-1` через `pyodbc`.
|
||||||
- `OLLAMA_URL = "http://localhost:11434/api/chat"`
|
* **Высокая скорость (0.2 сек):** Использование оконной функции `ROW_NUMBER() OVER (PARTITION BY HozOrgan ORDER BY TimeVal DESC)` для мгновенного сбора событий за 24 часа.
|
||||||
- `OLLAMA_MODEL = "qwen2.5:14b"`
|
* **Корректный учет текущего присутствия (`СЕГОДНЯ`):**
|
||||||
- `DATE_TODAY`, `DATE_YESTERDAY` — расчет рабочих дат.
|
* Если день ещё не окончен и последнее событие — **Вход**, в колонке выводится `Нет выхода`.
|
||||||
|
* Ограничено искажение времени для тех, кто ещё находится в здании или уходил на обед.
|
||||||
|
* **Поддержка отладки (`-d` / `--debug`):** Вывод детальной статистики в консоль (сколько в здании, сколько ушло, сколько отсутствовало).
|
||||||
|
|
||||||
**Основные функции:**
|
### 2. Нормализация и ИИ-сборка данных (`config.py` + `data_loader.py` + `ai_verifier.py`)
|
||||||
- `normalize_fio()` — приводит ФИО к нижнему регистру, удаляет скобки `(...)`, меняет латинские визуальные дубликаты букв на кириллицу, заменяет `ё` на `е`.
|
* **Удаление технического мусора:** Автоматическая очистка служебных пометок 1С в скобках (`(осн.)`, `(совм.)`, `(сводная)`), предотвращающая расхождение ФИО.
|
||||||
- `clean_scud_fio_light()` — облегченная очистка ФИО из СКУД от системных символов и артефактов.
|
* **Умное слияние с 1С:** Поиск опечаток и несопоставленных записей в реестре отсутствий через нейросеть `Qwen2.5:14b`.
|
||||||
- `load_exceptions()` — загружает фильтры и исключения из `exceptions.json`.
|
|
||||||
|
### 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`), предотвращая запуск системы на устаревших выгрузках прошлого дня.
|
| :--- | :--- |
|
||||||
- В случае отсутствия или устаревания данных блокирует запуск основного конвейера и предупреждает оператора.
|
| **Стандартный запуск** *(Экспорт СКУД из SQL + ИИ Анализ)* | `python main.py` |
|
||||||
|
| **Режим отладки** *(С детальными логами)* | `python main.py -d` |
|
||||||
---
|
| **Без повторного обращения к SQL** *(На сохраненных файлах)* | `python main.py --skip-export` |
|
||||||
|
| **Отладка только модуля выгрузки СКУД** | `python modules/scud_export.py -d` |
|
||||||
### 🛡️ `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-файлов СКУД оператором.
|
|
||||||
|
|||||||
@@ -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-файлов СКУД оператором.
|
||||||
@@ -1,28 +1,25 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||||
|
|
||||||
# 📂 ПОДПАПКИ DАТА
|
|
||||||
SCUD_DIR = os.path.join(DATA_DIR, "scud")
|
SCUD_DIR = os.path.join(DATA_DIR, "scud")
|
||||||
ZUP_1C_DIR = os.path.join(DATA_DIR, "1c")
|
ZUP_1C_DIR = os.path.join(DATA_DIR, "1c")
|
||||||
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
|
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
|
||||||
|
|
||||||
# Создаем директории при их отсутствии
|
|
||||||
for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]:
|
for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]:
|
||||||
os.makedirs(folder, exist_ok=True)
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
|
||||||
# 📅 Даты
|
|
||||||
NOW = datetime.now()
|
NOW = datetime.now()
|
||||||
DATE_TODAY = NOW.strftime("%d.%m.%Y")
|
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")
|
DATE_YESTERDAY = (NOW - timedelta(days=3)).strftime("%d.%m.%Y")
|
||||||
else:
|
else:
|
||||||
DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y")
|
DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y")
|
||||||
|
|
||||||
# 📄 Пути к файлам
|
|
||||||
SCUD_FILE_TODAY = os.path.join(SCUD_DIR, f"Сотрудники_{DATE_TODAY}.xlsx")
|
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_YESTERDAY = os.path.join(SCUD_DIR, f"Сотрудники_{DATE_YESTERDAY}.xlsx")
|
||||||
SCUD_FILE = os.path.join(SCUD_DIR, "Сотрудники.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")
|
STAFF_FILE_1C = os.path.join(ZUP_1C_DIR, "Штатные сотрудники - ОУП.xlsx")
|
||||||
ABSENT_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_URL = "http://10.121.18.227:11434/api/generate"
|
||||||
OLLAMA_MODEL = "qwen2.5:14b"
|
OLLAMA_MODEL = "qwen2.5:14b"
|
||||||
MODEL_NAME = OLLAMA_MODEL
|
MODEL_NAME = OLLAMA_MODEL
|
||||||
@@ -40,17 +36,22 @@ EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json")
|
|||||||
|
|
||||||
|
|
||||||
def normalize_fio(fio):
|
def normalize_fio(fio):
|
||||||
|
"""
|
||||||
|
Нормализует ФИО:
|
||||||
|
1. Вырезает скобки и их содержимое: (осн.), (совм.), (сводная), (внешн.)
|
||||||
|
2. Заменяет неразрывные пробелы \xa0 на обычные
|
||||||
|
3. Приводит к регистру Заглавных Букв
|
||||||
|
"""
|
||||||
if not fio or not isinstance(fio, str):
|
if not fio or not isinstance(fio, str):
|
||||||
return ""
|
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()
|
return " ".join(parts).title()
|
||||||
|
|
||||||
|
|
||||||
def clean_scud_fio_light(fio_str):
|
def clean_scud_fio_light(fio_str):
|
||||||
if not isinstance(fio_str, str):
|
return normalize_fio(fio_str)
|
||||||
return ""
|
|
||||||
fio_clean = fio_str.replace("(сводная)", "").strip()
|
|
||||||
return normalize_fio(fio_clean)
|
|
||||||
|
|
||||||
|
|
||||||
def load_exceptions():
|
def load_exceptions():
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
"rules": [
|
"rules": [
|
||||||
"Кондрашова Елена Владимировна имеет 2 пропуска в СКУД. Если один 'Отсутствовал', а второй с временем входа — считать ПРИШЕДШЕЙ.",
|
"Кондрашова Елена Владимировна имеет 2 пропуска в СКУД. Если один 'Отсутствовал', а второй с временем входа — считать ПРИШЕДШЕЙ.",
|
||||||
"Сотрудники подразделения ОА с должностью 'Инженер-программист' часто являются внутренними совместителями.",
|
"Сотрудники подразделения ОА с должностью 'Инженер-программист' часто являются внутренними совместителями.",
|
||||||
"Сотрудник Незнанова Валерия Игоревна: Незнанова иногда присутствует в офисе, не считать аномалией. Когда в офисе, из удаленщиков в отчете исключать!"
|
"Сотрудник Незнанова Валерия Игоревна: Незнанова иногда присутствует в офисе, не считать аномалией. Когда в офисе, из удаленщиков в отчете исключать!",
|
||||||
|
"Сотрудник Ковалев Владимир Владимирович: Все сотрудники на удаленной работе имеют право находится в здании в любой день. При этом в отчет они должны попадать, как на работе и вычитаться из удаленщиков на этот день.",
|
||||||
|
"Сотрудник Королёва Наталья Александровна: Все сотрудники на удаленной работе имеют право находится в здании в любой день. При этом в отчет они должны попадать, как на работе и вычитаться из удаленщиков на этот день.",
|
||||||
|
"Сотрудник Кожокарь Татьяна Юрьевна: Нет. Уже указывал, что любые удаленщики имеют право находитться в офисе. Это не является аномалией. Должны исключаться из отчета из удаленщиков, а включаться в список тех, кто на работе.",
|
||||||
|
"Сотрудник Познякова Татьяна Сергеевна: Нет. Уже указывал, что любые удаленщики имеют право находитться в офисе. Это не является аномалией. Должны исключаться из отчета из удаленщиков, а включаться в список тех, кто на работе."
|
||||||
],
|
],
|
||||||
"fio_corrections": {
|
"fio_corrections": {
|
||||||
"кондрашава елена владимировна": "Кондрашова Елена Владимировна"
|
"кондрашава елена владимировна": "Кондрашова Елена Владимировна"
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ fio,reason,note
|
|||||||
Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка
|
Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка
|
||||||
Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка
|
Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка
|
||||||
Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка
|
Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка
|
||||||
|
Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка
|
||||||
|
Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка
|
||||||
|
@@ -44,12 +44,11 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules):
|
|||||||
is_present = row.get('Пришел', False)
|
is_present = row.get('Пришел', False)
|
||||||
reason_1c = str(row.get('Вид_отсутствия', '')).strip()
|
reason_1c = str(row.get('Вид_отсутствия', '')).strip()
|
||||||
has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != ''
|
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
|
has_static_reason = fio_clean in static_reasons_dict
|
||||||
|
|
||||||
is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text
|
is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text
|
||||||
|
|
||||||
# 🚨 АНОМАЛИЯ 1: Пришел по СКУД, но в 1С числится отсутствие
|
# 🚨 АНОМАЛИЯ 1: Пришел по СКУД, но в 1С числится отсутствие (Отпуск/Больничный)
|
||||||
if is_present and has_1c_reason:
|
if is_present and has_1c_reason:
|
||||||
if not is_fio_whitelisted_in_kb:
|
if not is_fio_whitelisted_in_kb:
|
||||||
anomalies.append({
|
anomalies.append({
|
||||||
@@ -119,17 +118,17 @@ def main():
|
|||||||
kb_data = load_knowledge_base()
|
kb_data = load_knowledge_base()
|
||||||
kb_rules = kb_data.get("rules", [])
|
kb_rules = kb_data.get("rules", [])
|
||||||
|
|
||||||
# 0. Выгрузка из БД
|
# 0. Выгрузка из БД СКУД
|
||||||
if not args.skip_export:
|
if not args.skip_export:
|
||||||
print("[0/5] Экспорт свежих данных из базы СКУД 'Орион Pro'...")
|
print("[0/5] Экспорт свежих данных из базы СКУД 'Орион Pro'...")
|
||||||
try:
|
try:
|
||||||
run_export(debug=DEBUG)
|
run_export(debug=DEBUG)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Используем файлы в data/scud.")
|
print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Используем имеющиеся файлы в data/scud.")
|
||||||
else:
|
else:
|
||||||
print("[0/5] Пропуск прямого экспорта из БД (--skip-export)...")
|
print("[0/5] Пропуск прямого экспорта из БД (--skip-export)...")
|
||||||
|
|
||||||
# 1. Валидация
|
# 1. Валидация файлов
|
||||||
print("[1/5] Проверка актуальности и свежести входных файлов...")
|
print("[1/5] Проверка актуальности и свежести входных файлов...")
|
||||||
is_valid, warnings, errors = check_file_freshness()
|
is_valid, warnings, errors = check_file_freshness()
|
||||||
|
|
||||||
@@ -149,22 +148,33 @@ def main():
|
|||||||
|
|
||||||
print("[✓] Проверка актуальности успешно пройдена!\n")
|
print("[✓] Проверка актуальности успешно пройдена!\n")
|
||||||
|
|
||||||
# 2. Загрузка данных
|
# 2. Раздельная загрузка данных (Сегодня и Вчера)
|
||||||
print("[2/5] Загрузка данных из data/scud, data/1c, реестра причин и исключений...")
|
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()
|
static_reasons_dict = load_static_reason_workers()
|
||||||
|
|
||||||
# 3. ИИ-сверка ФИО (СКУД + 1С)
|
# 3. ИИ-сверка ФИО (СКУД + 1С)
|
||||||
print("[3/5] Проверка опечаток и сопоставление ФИО через ИИ...")
|
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:
|
if fio_mapping_scud:
|
||||||
print(f" [ИИ] Найдено совпадений в СКУД: {len(fio_mapping_scud)}")
|
print(f" [ИИ] Найдено совпадений в СКУД (Сегодня): {len(fio_mapping_scud)}")
|
||||||
raw_scud_df['fio_clean'] = raw_scud_df['fio_clean'].apply(
|
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
|
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()
|
absent_unrecognized = df_absent[~df_absent['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist()
|
||||||
if absent_unrecognized:
|
if absent_unrecognized:
|
||||||
fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized, staff_fios_clean)
|
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
|
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. Объединение и детекция
|
# 4. Объединение и детекция
|
||||||
print("[4/5] Объединение реестров 1С, СКУД, статических причин и исключений...")
|
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:
|
if static_reasons_dict:
|
||||||
for fio_clean, reason_val in static_reasons_dict.items():
|
for fio_clean, reason_val in static_reasons_dict.items():
|
||||||
mask_apply_static = (
|
mask_apply_static = (
|
||||||
(merged['Пришел'] == False) &
|
(merged_today['Пришел'] == False) &
|
||||||
(merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) &
|
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
|
||||||
(merged['fio_clean'] == fio_clean)
|
(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 = (
|
mask_apply_exceptions = (
|
||||||
(merged['Пришел'] == False) &
|
(merged_today['Пришел'] == False) &
|
||||||
(merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) &
|
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
|
||||||
(merged.get('is_excluded', False) == True)
|
(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())]
|
mask_apply_exceptions_y = (
|
||||||
absent_unexplained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].isna())]
|
(merged_yesterday['Пришел'] == False) &
|
||||||
scud_present_but_absent_in_1c = merged[(merged['Пришел'] == True) & (merged['Вид_отсутствия'].notna())]
|
(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:
|
if DEBUG:
|
||||||
print("\n--- [DEBUG STATS] ---")
|
print("\n--- [DEBUG STATS] ---")
|
||||||
print(f" Всего по списку: {len(merged)}")
|
print(f" Всего по списку: {len(merged_today)}")
|
||||||
is_working_mask = (merged['Пришел'] == True) | (
|
is_working_mask = (merged_today['Пришел'] == True) | (
|
||||||
merged['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
|
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_explained)}")
|
||||||
print(f" Истинно неизвестные случаи: {len(absent_unexplained)}")
|
print(f" Истинно неизвестные случаи: {len(absent_unexplained)}")
|
||||||
print(f" Обнаружено комплексных аномалий: {len(anomalies_list)}")
|
print(f" Обнаружено аномалий: {len(anomalies_list)}")
|
||||||
print("---------------------\n")
|
print("---------------------\n")
|
||||||
|
|
||||||
# 5. ИИ-аудитор
|
# 5. ИИ-аудитор и сборка финальных Excel-отчетов
|
||||||
print("[5/5] Запуск ИИ-аудитора и глубокий анализ аномалий...")
|
print("[5/5] Запуск ИИ-аудитора и сборка финальных Excel-отчетов...")
|
||||||
report_text = generate_markdown_report(
|
report_text = generate_markdown_report(
|
||||||
merged_df=merged,
|
merged_df=merged_today,
|
||||||
absent_explained=absent_explained,
|
absent_explained=absent_explained,
|
||||||
absent_unexplained=absent_unexplained,
|
absent_unexplained=absent_unexplained,
|
||||||
scud_present_but_absent_in_1c=scud_present_but_absent_in_1c,
|
scud_present_but_absent_in_1c=scud_present_but_absent_in_1c,
|
||||||
anomalies_list=anomalies_list,
|
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
|
date_str=DATE_TODAY
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -241,8 +271,11 @@ def main():
|
|||||||
|
|
||||||
review_ai_decisions(report_text, suspicious_cases)
|
review_ai_decisions(report_text, suspicious_cases)
|
||||||
|
|
||||||
generate_summary_excel(merged_df=merged, date_str=DATE_TODAY)
|
# 1. Ежедневная сводка -> формируется по СЕГОДНЯШНЕМУ датасету
|
||||||
generate_detailed_excel(merged_df=merged, date_str=DATE_YESTERDAY)
|
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("\n" + "=" * 60)
|
||||||
print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИС-АУДИТОРА:")
|
print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИС-АУДИТОРА:")
|
||||||
|
|||||||
+36
-7
@@ -3,12 +3,13 @@ import pandas as pd
|
|||||||
import warnings
|
import warnings
|
||||||
from config import (
|
from config import (
|
||||||
normalize_fio, clean_scud_fio_light, load_exceptions,
|
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
|
STAFF_FILE_1C, ABSENT_FILE_1C
|
||||||
)
|
)
|
||||||
|
|
||||||
warnings.filterwarnings('ignore', category=UserWarning, module='pandas')
|
warnings.filterwarnings('ignore', category=UserWarning, module='pandas')
|
||||||
|
|
||||||
|
|
||||||
def is_excluded(fio, dept, pos, exceptions):
|
def is_excluded(fio, dept, pos, exceptions):
|
||||||
fio_clean = normalize_fio(fio)
|
fio_clean = normalize_fio(fio)
|
||||||
dept_str = str(dept).strip().upper() if pd.notna(dept) else ""
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def load_staff_data():
|
def load_staff_data():
|
||||||
if not os.path.exists(STAFF_FILE_1C):
|
if not os.path.exists(STAFF_FILE_1C):
|
||||||
raise FileNotFoundError(f"Файл штатного расписания не найден по пути: {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)
|
df_staff['fio_clean'] = df_staff['ФИО'].apply(normalize_fio)
|
||||||
return df_staff
|
return df_staff
|
||||||
|
|
||||||
|
|
||||||
def load_absent_data():
|
def load_absent_data():
|
||||||
if not os.path.exists(ABSENT_FILE_1C):
|
if not os.path.exists(ABSENT_FILE_1C):
|
||||||
raise FileNotFoundError(f"Файл отсутствий не найден по пути: {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)
|
df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio)
|
||||||
return df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
|
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):
|
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)
|
df_scud = pd.read_excel(scud_path)
|
||||||
exceptions = load_exceptions()
|
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)
|
start_col = 'Начало_дня' if 'Начало_дня' in df_scud.columns else ('Начало дня' if 'Начало дня' in df_scud.columns else None)
|
||||||
status_col = 'Статус' if 'Статус' in df_scud.columns else None
|
status_col = 'Статус' if 'Статус' in df_scud.columns else None
|
||||||
|
|
||||||
# 🎯 ФИЗИЧЕСКИЙ ФАКТ ПРИСУТСТВИЯ
|
|
||||||
def is_person_present(row):
|
def is_person_present(row):
|
||||||
val_status = str(row.get(status_col, '')).strip().lower() if status_col else ''
|
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 ''
|
val_time = str(row.get(start_col, '')).strip() if start_col else ''
|
||||||
@@ -68,9 +84,22 @@ def load_scud_data():
|
|||||||
)
|
)
|
||||||
return df_scud
|
return df_scud
|
||||||
|
|
||||||
|
|
||||||
def load_all_data():
|
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_staff = load_staff_data()
|
||||||
df_absent = load_absent_data()
|
df_absent = load_absent_data()
|
||||||
staff_fios_clean = df_staff['fio_clean'].dropna().tolist()
|
staff_fios_clean = df_staff['fio_clean'].dropna().tolist()
|
||||||
return df_scud, staff_fios_clean, df_absent
|
|
||||||
|
return df_scud_today, df_scud_yesterday, staff_fios_clean, df_absent
|
||||||
+57
-18
@@ -38,8 +38,43 @@ FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="
|
|||||||
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"]
|
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"]
|
||||||
|
|
||||||
# Палитра для Детального отчета
|
# Палитра для Детального отчета
|
||||||
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", 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")
|
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):
|
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)
|
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):
|
def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||||
if not filename:
|
if not filename:
|
||||||
filename = f"{format_date_ru(date_str)} сводка.xlsx"
|
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
|
ws.row_dimensions[current_row].hidden = True
|
||||||
current_row += 1
|
current_row += 1
|
||||||
|
|
||||||
# 🎯 5. ИТОГО НА РАБОТЕ (Включает: пришли по СКУД + Командировки + Удаленная работа)
|
# 5. ИТОГО НА РАБОТЕ (Включает: пришли по СКУД + Командировки + Удаленная работа)
|
||||||
is_working_mask = (merged_df['Пришел'] == True) | (
|
is_working_mask = (merged_df['Пришел'] == True) | (
|
||||||
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
|
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)
|
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font)
|
||||||
current_row += 1
|
current_row += 1
|
||||||
|
|
||||||
# 🚨 6. АНОМАЛИИ (В самом низу таблицы, раскрывающийся список)
|
# 6. АНОМАЛИИ (В самом низу таблицы, раскрывающийся список)
|
||||||
# Столбец A: f"{idx}. {fio} (На работе)"
|
|
||||||
# Столбец B: f"В 1С: {reason}"
|
|
||||||
# Ширина столбца B = 32.0 (ровно 230px). Автоперенос + динамическая высота строк.
|
|
||||||
anomalies = merged_df[(merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna())]
|
anomalies = merged_df[(merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna())]
|
||||||
|
|
||||||
ws.cell(row=current_row, column=1, value="Аномалии")
|
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_a)
|
||||||
apply_borders_to_cell(cell_b)
|
apply_borders_to_cell(cell_b)
|
||||||
|
|
||||||
# Автоперенос текста в столбце B
|
|
||||||
cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
|
cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
|
||||||
cell_a.alignment = Alignment(horizontal="left", vertical="center")
|
cell_a.alignment = Alignment(horizontal="left", vertical="center")
|
||||||
|
|
||||||
# Адаптивный расчет высоты строки
|
|
||||||
if len(reason_text) > chars_per_line_b:
|
if len(reason_text) > chars_per_line_b:
|
||||||
lines_count = math.ceil(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)
|
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].height = 20
|
||||||
|
|
||||||
ws.row_dimensions[current_row].outlineLevel = 1
|
ws.row_dimensions[current_row].outlineLevel = 1
|
||||||
ws.row_dimensions[current_row].hidden = True # Свернут по умолчанию
|
ws.row_dimensions[current_row].hidden = True
|
||||||
current_row += 1
|
current_row += 1
|
||||||
|
|
||||||
# 📏 Настройка ширины колонок: B = 32.0 (ровно 230 пикселей)
|
|
||||||
ws.column_dimensions['A'].width = 45.0
|
ws.column_dimensions['A'].width = 45.0
|
||||||
ws.column_dimensions['B'].width = 32.0 # Ровно 230px в Excel
|
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}")
|
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
|
||||||
|
|
||||||
|
|
||||||
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА ---
|
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (С ДОРАБОТАННЫМ РАСЧЕТОМ И ЗЕЛЕНОЙ ПОДСВЕТКОЙ АНОМАЛИЙ) ---
|
||||||
def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||||
if not filename:
|
if not filename:
|
||||||
filename = f"{format_date_ru(date_str)} отчет.xlsx"
|
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('Вид_отсутствия', '')
|
absence_reason = row.get('Вид_отсутствия', '')
|
||||||
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
|
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([
|
ws.append([
|
||||||
idx + 1,
|
idx + 1,
|
||||||
row.get('Сотрудник', ''),
|
row.get('Сотрудник', ''),
|
||||||
row.get('Подразделение', ''),
|
row.get('Подразделение', ''),
|
||||||
row.get(start_col, 'Нет входа'),
|
row.get(start_col, 'Нет входа'),
|
||||||
row.get(end_col, 'Нет выхода'),
|
row.get(end_col, 'Нет выхода'),
|
||||||
row.get(hours_col, '0:00'),
|
in_building_str,
|
||||||
absence_reason if has_reason else '',
|
absence_reason if has_reason else '',
|
||||||
8,
|
8,
|
||||||
"-8:00" if not is_present else "0:00"
|
deviation_val
|
||||||
])
|
])
|
||||||
|
|
||||||
row_num = 5 + idx
|
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 ""
|
val_g_str = str(absence_reason) if has_reason else ""
|
||||||
if len(val_g_str) > chars_per_line_g:
|
if len(val_g_str) > chars_per_line_g:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в Excel.
|
Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в Excel.
|
||||||
|
Исправлена логика определения направления проходов (Mode = 2 -> OUT).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -63,15 +64,18 @@ DECLARE @TargetDate DATE = @InputDate;
|
|||||||
DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME);
|
DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME);
|
||||||
DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate));
|
DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate));
|
||||||
|
|
||||||
|
-- 1. Фильтруем логи только за нужные 24 часа с ПРАВИЛЬНЫМ ПРИОРИТЕТОМ ВЫХОДА (Mode = 2)
|
||||||
WITH DailyLogs AS (
|
WITH DailyLogs AS (
|
||||||
SELECT
|
SELECT
|
||||||
log.HozOrgan AS EmployeeID,
|
log.HozOrgan AS EmployeeID,
|
||||||
log.TimeVal,
|
log.TimeVal,
|
||||||
log.Event,
|
log.Event,
|
||||||
log.Mode,
|
log.Mode,
|
||||||
CASE WHEN log.Event IN (28, 26, 32) OR log.Mode = 1 THEN 'IN'
|
CASE
|
||||||
WHEN log.Event IN (29, 27, 33) OR log.Mode = 2 THEN 'OUT'
|
WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT'
|
||||||
ELSE 'OTHER' END AS Direction,
|
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
|
ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc
|
||||||
FROM pLogData log WITH (NOLOCK)
|
FROM pLogData log WITH (NOLOCK)
|
||||||
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
||||||
@@ -79,6 +83,7 @@ WITH DailyLogs AS (
|
|||||||
AND log.HozOrgan > 0
|
AND log.HozOrgan > 0
|
||||||
AND (log.Event IN (28, 29, 26, 27, 32, 33) OR log.Mode IN (1, 2))
|
AND (log.Event IN (28, 29, 26, 27, 32, 33) OR log.Mode IN (1, 2))
|
||||||
),
|
),
|
||||||
|
-- 2. Агрегируем первичное время входа и последнее время выхода
|
||||||
Passages AS (
|
Passages AS (
|
||||||
SELECT
|
SELECT
|
||||||
EmployeeID,
|
EmployeeID,
|
||||||
|
|||||||
+83
-38
@@ -1,34 +1,72 @@
|
|||||||
import json
|
import json
|
||||||
|
import difflib
|
||||||
|
import re
|
||||||
from modules.ai_verifier import ask_ollama
|
from modules.ai_verifier import ask_ollama
|
||||||
from modules.knowledge_base import load_knowledge_base
|
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()
|
kb = load_knowledge_base()
|
||||||
custom_rules = kb.get("rules", [])
|
custom_rules = kb.get("rules", [])
|
||||||
custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет."
|
custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет."
|
||||||
|
|
||||||
detailed_cases_for_ai = []
|
# 1. Точный расчет неточных совпадений через Python (Исключает галлюцинации ИИ)
|
||||||
unexplained_fios = absent_unexplained['fio_clean'].unique() if 'fio_clean' in absent_unexplained.columns else []
|
fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df)
|
||||||
|
|
||||||
for fio_clean in unexplained_fios:
|
# 2. Список истинно неизвестных для ИИ
|
||||||
staff_matches = []
|
unexplained_list = []
|
||||||
if raw_staff_df is not None and not raw_staff_df.empty and 'fio_clean' in raw_staff_df.columns:
|
if not absent_unexplained.empty:
|
||||||
st_m = raw_staff_df[raw_staff_df['fio_clean'] == fio_clean]
|
for idx, r in absent_unexplained.iterrows():
|
||||||
cols_staff = [c for c in ['ФИО', 'Подразделение', 'Должность', 'Таб_№'] if c in st_m.columns]
|
fio = r.get('Сотрудник', r.get('fio_clean', ''))
|
||||||
staff_matches = st_m[cols_staff].to_dict(orient='records')
|
dept = r.get('Подразделение', '—')
|
||||||
|
pos = r.get('Должность', '—')
|
||||||
scud_matches = []
|
unexplained_list.append(f"{fio} — {dept}, {pos}")
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
total_staff = len(merged_df)
|
total_staff = len(merged_df)
|
||||||
is_working_mask = (merged_df['Пришел'] == True) | (
|
is_working_mask = (merged_df['Пришел'] == True) | (
|
||||||
@@ -41,25 +79,34 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc
|
|||||||
|
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
Ты — старший аудитор кадровой безопасности и контроллинга СКУД.
|
Ты — старший аудитор кадровой безопасности и контроллинга СКУД.
|
||||||
Проанализируй аномалии и пересечения данных за {date_str}.
|
Сформируй итоговую сводку кадрового контроля на {date_str}.
|
||||||
|
|
||||||
📌 НАКОПЛЕННЫЕ ПРАВИЛА ПРЕДПРИЯТИЯ И ИСКЛЮЧЕНИЯ:
|
📌 БАЗА ЗНАНИЙ И ПРАВИЛА ПРЕДПРИЯТИЯ:
|
||||||
{custom_rules_str}
|
{custom_rules_str}
|
||||||
|
|
||||||
🚨 ВХОДНЫЕ МЕТРИКИ:
|
🚨 ВХОДНЫЕ МЕТРИКИ:
|
||||||
- Всего сотрудников: {total_staff}
|
- Всего сотрудников: {total_staff}
|
||||||
- На работе / Удаленно / Командировка: {present_cnt}
|
- Работают (офис / удаленка / командировки): {present_cnt}
|
||||||
- Официально отсутствуют (1С/Реестры): {explained_cnt}
|
- Официально отсутствуют: {explained_cnt}
|
||||||
- Неизвестно (требуют выяснения): {unexplained_cnt}
|
- Неизвестно (истинно неотмеченные): {unexplained_cnt}
|
||||||
- Выявлено аномалий и конфликтов реестров: {anomalies_cnt}
|
- Выявлено аномалий/конфликтов реестров: {anomalies_cnt}
|
||||||
|
|
||||||
---
|
---
|
||||||
🚨 ОБНАРУЖЕННЫЕ АНОМАЛИИ И КОНФЛИКТЫ МЕЖДУ СКУД, 1С, СТАТИЧЕСКИМИ РЕЕСТРАМИ И ИСКЛЮЧЕНИЯМИ ({anomalies_cnt} шт.):
|
🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт):
|
||||||
{json.dumps(anomalies_list, ensure_ascii=False, indent=2)}
|
{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} чел):
|
📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({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_cnt}):
|
#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}):
|
||||||
(Подробно разбери каждый конфликт из `anomalies_list`. Опиши суть нарушения. Если аномалий нет — напиши "Аномалий и конфликтов не обнаружено.")
|
(Описание аномалий из anomalies_list. Если их нет — "Аномалий не обнаружено.")
|
||||||
|
|
||||||
#### ⚠️ Подозрения на ложные срабатывания / Совмещения:
|
#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С:
|
||||||
(Укажи сотрудников с повторными проходами или несколькими картами, если такие есть)
|
(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива fio_mismatches_python. Если он пуст — "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.")
|
||||||
|
|
||||||
#### Неизвестные случаи: {unexplained_cnt}
|
#### Неизвестные случаи: {unexplained_cnt}
|
||||||
(Выведи НУМЕРОВАННЫЙ СПИСОК ВСЕХ {unexplained_cnt} человек в формате:
|
(Выведи нумерованный список всех {unexplained_cnt} человек)
|
||||||
1. ФИО — Подразделение, Должность
|
|
||||||
2. ФИО — Подразделение, Должность)
|
|
||||||
|
|
||||||
### Рекомендации:
|
### Точечные рекомендации:
|
||||||
(Сформулируй 2-3 общие системные рекомендации для кадровой службы. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО перечислять конкретные ФИО сотрудников в скобках или тексте рекомендаций — они уже перечислены выше.)
|
(2-3 системные рекомендации без указания ФИО сотрудников)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
sys_prompt = "Ты — эксперт кадрового аудита. Пиши сжато, лаконично, без дублирования ФИО сотрудников в блоке рекомендаций."
|
sys_prompt = "Ты — эксперт кадрового аудита. Будь точен, не придумывай непереданные данные."
|
||||||
return ask_ollama(prompt, system_prompt=sys_prompt)
|
return ask_ollama(prompt, system_prompt=sys_prompt)
|
||||||
Reference in New Issue
Block a user