промежуточный положительный результат. Отчеты коррктно формируются. Перед автоматической загрузкой файлов 1С.

This commit is contained in:
2026-07-30 12:42:41 +03:00
parent 2fc417f3a7
commit 9347c42d22
10 changed files with 477 additions and 237 deletions
+67 -118
View File
@@ -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-файлов СКУД оператором.
| Режим запуска | Команда в терминале |
| :--- | :--- |
| **Стандартный запуск** *(Экспорт СКУД из SQL + ИИ Анализ)* | `python main.py` |
| **Режим отладки** *(С детальными логами)* | `python main.py -d` |
| **Без повторного обращения к SQL** *(На сохраненных файлах)* | `python main.py --skip-export` |
| **Отладка только модуля выгрузки СКУД** | `python modules/scud_export.py -d` |
+133
View File
@@ -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-файлов СКУД оператором.
+12 -11
View File
@@ -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():
+5 -1
View File
@@ -2,7 +2,11 @@
"rules": [
"Кондрашова Елена Владимировна имеет 2 пропуска в СКУД. Если один 'Отсутствовал', а второй с временем входа — считать ПРИШЕДШЕЙ.",
"Сотрудники подразделения ОА с должностью 'Инженер-программист' часто являются внутренними совместителями.",
"Сотрудник Незнанова Валерия Игоревна: Незнанова иногда присутствует в офисе, не считать аномалией. Когда в офисе, из удаленщиков в отчете исключать!"
"Сотрудник Незнанова Валерия Игоревна: Незнанова иногда присутствует в офисе, не считать аномалией. Когда в офисе, из удаленщиков в отчете исключать!",
"Сотрудник Ковалев Владимир Владимирович: Все сотрудники на удаленной работе имеют право находится в здании в любой день. При этом в отчет они должны попадать, как на работе и вычитаться из удаленщиков на этот день.",
"Сотрудник Королёва Наталья Александровна: Все сотрудники на удаленной работе имеют право находится в здании в любой день. При этом в отчет они должны попадать, как на работе и вычитаться из удаленщиков на этот день.",
"Сотрудник Кожокарь Татьяна Юрьевна: Нет. Уже указывал, что любые удаленщики имеют право находитться в офисе. Это не является аномалией. Должны исключаться из отчета из удаленщиков, а включаться в список тех, кто на работе.",
"Сотрудник Познякова Татьяна Сергеевна: Нет. Уже указывал, что любые удаленщики имеют право находитться в офисе. Это не является аномалией. Должны исключаться из отчета из удаленщиков, а включаться в список тех, кто на работе."
],
"fio_corrections": {
"кондрашава елена владимировна": "Кондрашова Елена Владимировна"
+2
View File
@@ -8,3 +8,5 @@ fio,reason,note
Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка
Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка
Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка
Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка
Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка
1 fio reason note
8 Пухаренко Юрий Владимирович Удаленная работа Постоянная удаленка
9 Пшеничный Виктор Петрович Удаленная работа Постоянная удаленка
10 Незнанова Валерия Игоревна Удаленная работа Постоянная удаленка
11 Ковалев Владимир Владимирович Удаленная работа Постоянная удаленка
12 Кожокарь Татьяна Юрьевна Удаленная работа Постоянная удаленка
+73 -40
View File
@@ -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("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИС-АУДИТОРА:")
+36 -7
View File
@@ -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
return df_scud_today, df_scud_yesterday, staff_fios_clean, df_absent
+56 -17
View File
@@ -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
# Разграничение заливки для Детального отчета:
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
if not is_present:
row_fill = YELLOW_FILL if has_reason else LIGHT_RED_FILL
val_g_str = str(absence_reason) if has_reason else ""
if len(val_g_str) > chars_per_line_g:
+8 -3
View File
@@ -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,
+83 -38
View File
@@ -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)