9 Commits
Author SHA1 Message Date
manoraga 7eea236417 перед внедрением API и веб интерфейса. 2026-08-04 17:43:00 +03:00
manoraga d667b2d846 feat: обновлен config.py с параметрами подключения к MS SQL ACCOUNT-01 2026-08-04 12:55:35 +03:00
manoraga e2a3c4f0a2 перед переедзом на linux 2026-08-03 17:00:57 +03:00
manoraga 0efbe4ff99 проект теперь работает с использованием локальной базы данных SQLite. Все данные аккумулируются и хранятся в ней. Впереди - разработка веб интерфейса. 2026-08-03 12:59:23 +03:00
manoraga d39618c891 перед настройкой базы данных. Выгрузка кадровых файлов разделена по дням. 2026-07-30 17:11:23 +03:00
manoraga 127c979505 Тестовая фиксация для проверки работы Git. 2026-07-30 13:19:28 +03:00
manoraga 9347c42d22 промежуточный положительный результат. Отчеты коррктно формируются. Перед автоматической загрузкой файлов 1С. 2026-07-30 12:42:41 +03:00
manoraga 2fc417f3a7 refactoring: updated .gitignore and cleaned dynamic data files 2026-07-28 16:45:08 +03:00
manoraga 2603627098 промежуточно. Работает все, неверно формируется вчерашний отчет (нет выхода). 2026-07-28 16:41:26 +03:00
26 changed files with 1946 additions and 634 deletions
+39 -6
View File
@@ -1,9 +1,42 @@
# Входные и сгенерированные файлы # --- 1. Python и виртуальное окружение ---
data/*.xlsx
output/*
# Системные и временные файлы Python
__pycache__/ __pycache__/
*.pyc *.py[cod]
*$py.class
venv/
.venv/ .venv/
env/
.env
# --- 2. Настройки IDE и редакторов ---
.vscode/ .vscode/
.idea/
*.swp
*.swo
# --- 3. Логи скриптов и выгрузок ---
logs/
*.log
# --- 4. Временные и выходные файлы отчетов ---
output/
*.tmp
~$*.xlsx
# --- 5. Рабочие данные СКУД и 1С (Выгрузки) ---
data/scud/*
data/1c/*
!data/scud/.gitkeep
!data/1c/.gitkeep
# Исключаем сырые Excel-файлы в корне и папке data
*.xlsx
*.xls
*.csv
# Разрешаем отслеживать только статический справочник причин, если он нужен
!data/static_reason_workers.csv
!data/knowledge_base.json
# --- 6. Системные файлы ОС ---
.DS_Store
Thumbs.db
+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) ├── 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-файлов СКУД оператором.
+115
View File
@@ -0,0 +1,115 @@
# 🔄 ПАСПОРТ АЛГОРИТМА И ETL-КОНВЕЙЕРА: `scud_orion_ai_v2`
> **Архитектурный паттерн:** Гибридный ETL (Extract, Transform, Load) с хранением аналитического следа
> **Хранилище данных:** SQLite (режим WAL)
> **Интеграционный стек:** `MS SQL Server` ⟷ `1С:ЗУП (SMB/Share)` ⟷ `Ollama (Qwen 2.5)` ⟷ `SQLite`
---
## 📐 Сквозной цикл работы проекта (`main.py`)
```plaintext
[БД MS SQL 'Орион Pro'] [Сетевая шара \storage\...]
│ │
(0. run_export) (0.5 share_copier)
▼ ▼
[data/scud/*.xlsx] [data/1c/*.xlsx]
│ │
└────────────────┬───────────────┘
▼
(1. data_validator) ──(Проверка фатальных ошибок)
│
▼
(2. data_loader) ──(Фоллбэки по датам)
│
▼
(3. ai_verifier + Qwen 2.5) ──(Исправление опечаток)
│
▼
(4. Сведение данных)
┌──────────┴──────────┐
▼ ▼
[Данные за ВЧЕРА] [Данные за СЕГОДНЯ]
│ │
▼ ▼
(Detailed Excel) (Summary Excel + Markdown)
│ │
└──────────┬──────────┘
▼
(5. feedback_loop) ──(Обучение ИИ)
│
▼
(database.py / SQLite WAL) ──(Сохранение фактов)
```
---
## 📋 Пошаговый алгоритм по этапам
### Этап 0: Извлечение сырых данных (Extract)
#### 1. БД СКУД «Орион Pro» $
ightarrow$ Excel (`scud_export.py`)
* Скрипт подключается напрямую к **MS SQL Server** системы «Орион Pro».
* Формирует агрегированные файлы `Сотрудники_DD.MM.YYYY.xlsx` в папку `data/scud/` отдельно за **Сегодня** и за **Вчера**.
#### 2. Сетевая шара $
ightarrow$ Папка `data/1c/` (`share_copier.py`)
* Заходит по UNC-пути `\storage\SCUD\Обмен\Штат`.
* Ищет и забирает свежие выгрузки **1С:ЗУП**: `Штат_DD_MM_YYYY.xlsx` и `Отсутствия_DD_MM_YYYY.xlsx` за Сегодня и Вчера.
---
### Этап 1: Валидация и защита от сбоев (`data_validator.py`)
* Проверяет наличие файлов за **Вчера** *(они критически важны для формирования ежедневного табеля)*.
* Если файлы за **Сегодня** ещё не выложены кадровой службой на сетевую шару:
* Выдаёт мягкое предупреждение.
* Отменяет генерацию сегодняшней сводки *(чтобы не плодить ложные аномалии по понедельникам/утрам)*.
* Не останавливает работу системы, мягко переходя к формированию вчерашнего отчёта.
---
### Этап 2: Нормализация и загрузка (`data_loader.py`)
* Очищает и применительно нормализует ФИО через `normalize_fio`: удаление лишних пробелов, точек, приведение к единому регистру.
* Выделяет факты физического присутствия по СКУД *(наличие первого входа / последнего выхода)*.
* Исключает из анализа подрядчиков, охрану и служебные карты *(по реестру исключений)*.
---
### Этап 3: ИИ-аудит опечаток и сопоставление ФИО (`ai_verifier.py`)
* Неопознанные проходы СКУД отправляются в локальную нейросеть **Qwen 2.5** *(через Ollama)*.
* ИИ сверяет опечатки в СКУД с 100% эталонным реестром **Штатного расписания 1С**.
* Сопоставленные опечатки автоматически исправляются в датасете перед сведением.
---
### Этап 4: Бизнес-логика, сведение и детекция аномалий
Система разделяет контекст на два независимых потока:
#### 1. Поток ВЧЕРА (Детальный отчёт)
* Объединяет `СКУД (Вчера)` + `1С:Отсутствия (Вчера)`.
* Считает реальные часы нахождения в здании, обед (30 мин) и отклонение от 8-часовой нормы.
* Формирует Excel `output/[Дата] отчет.xlsx`.
#### 2. Поток СЕГОДНЯ (Ежедневная сводка)
* Объединяет `СКУД (Сегодня)` + `1С:Отсутствия (Сегодня)` + `Реестр удалёнщиков`.
* 🔴 **Детекция аномалий:** Физический приход по СКУД при наличии любого документа отсутствия в 1С *(отпуск, больничный и т.д., кроме командировок)* помечается как **Истинная Аномалия**.
* 🟢 **Удалёнщики:** Удалёнщики, пришедшие в офис, не считаются аномалией *(правило компании)*.
* Формирует Excel `output/[Дата] сводка.xlsx` *(со свернутыми группами без номеров строк)* и текстовую Markdown-сводку.
---
### Этап 5: Обучение Human-in-the-Loop (`feedback_loop.py`)
* Если ИИ находит спорные случаи, он запрашивает подтверждение оператора в консоли.
* Новые правила, введённые пользователем, мгновенно сохраняются и в `knowledge_base.json`, и в таблицу `ai_knowledge_base` в БД SQLite.
---
### Этап 6: Сохранение аналитического следа в БД (`database.py`)
Вся история сохраняется в локальную **SQLite БД** (`data/scud_orion_ai.db`) в режиме высокой производительности (**WAL / executemany**):
| Таблица | Содержимое |
| :--- | :--- |
| `scud_logs` | История всех проходов по дням |
| `zup_staff` | Срезы штатного расписания |
| `zup_absences` | Оформленные документы из 1С |
+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-файлов СКУД оператором.
Binary file not shown.
+64 -57
View File
@@ -1,78 +1,85 @@
import os import os
import re import re
import json
import pandas as pd
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")
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") OUTPUT_DIR = os.path.join(BASE_DIR, "output")
os.makedirs(DATA_DIR, exist_ok=True) # Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат)
os.makedirs(OUTPUT_DIR, exist_ok=True) SHARE_1C_DIR = "/mnt/scud_share"
# 📅 Динамический расчет дат (СЕГОДНЯ и ВЧЕРА) for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]:
def get_controlling_dates(): os.makedirs(folder, exist_ok=True)
now = datetime.now()
if now.weekday() == 0: # Если Понедельник -> Вчера = Пятница
yesterday_dt = now - timedelta(days=3)
else:
yesterday_dt = now - timedelta(days=1)
date_today_str = now.strftime("%d.%m.%Y") NOW = datetime.now()
date_yesterday_str = yesterday_dt.strftime("%d.%m.%Y") DATE_TODAY = NOW.strftime("%d.%m.%Y")
return date_today_str, date_yesterday_str
DATE_TODAY, DATE_YESTERDAY = get_controlling_dates() 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")
# 📄 Пути к файлам # --- Настройки LLM / Ollama ---
SCUD_FILE_TODAY = os.path.join(DATA_DIR, f"Сотрудники_{DATE_TODAY}.xlsx") OLLAMA_URL = "http://192.168.11.3:11434/api/generate"
SCUD_FILE_YESTERDAY = os.path.join(DATA_DIR, f"Сотрудники_{DATE_YESTERDAY}.xlsx")
SCUD_FILE = SCUD_FILE_TODAY if os.path.exists(SCUD_FILE_TODAY) else SCUD_FILE_YESTERDAY
STAFF_FILE = os.path.join(DATA_DIR, "Штатные сотрудники - ОУП.xlsx")
ABSENT_FILE = os.path.join(DATA_DIR, "Отсутствия сотрудников - выгрузка.xlsx")
EXCEPTIONS_FILE = os.path.join(BASE_DIR, "exceptions.json")
# 🤖 Настройки Ollama
OLLAMA_URL = "http://10.121.18.227:11434/api/chat"
OLLAMA_MODEL = "qwen2.5:14b" OLLAMA_MODEL = "qwen2.5:14b"
MODEL_NAME = OLLAMA_MODEL
KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json")
EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json")
# --- Настройки подключения к MS SQL Server (1С:ЗУП 3.1) ---
ZUP_SQL_CONFIG = {
"driver": "{ODBC Driver 18 for SQL Server}", # или "{ODBC Driver 17 for SQL Server}"
"server": os.getenv("ZUP_SQL_SERVER", "ACCOUNT-01"),
"database": os.getenv("ZUP_SQL_DB", "ZUP30"), # Замените на точное имя базы ЗУП на ACCOUNT-01
"user": os.getenv("ZUP_SQL_USER", "scud_reader"), # Логин SQL Server
"password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), # Пароль SQL Server
"trust_server_certificate": "yes", # Доверять сертификату сервера
"encrypt": "no" # "yes", если на MS SQL включено обязательное SSL-шифрование
}
# 🛠 Очистка и нормализация данных def find_dated_file(prefix, date_str, search_dirs=[ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]):
"""
Ищет файлы по префиксу и дате (поддерживает и 30.07.2026, и 30_07_2026).
"""
date_dots = date_str
date_underscores = date_str.replace('.', '_')
def normalize_fio(val): for d in search_dirs:
"""Строгая нормализация ФИО для точности сопоставления 1С и СКУД""" if not os.path.exists(d):
if pd.isna(val): continue
for f in os.listdir(d):
if f.endswith('.xlsx') or f.endswith('.csv'):
if f.lower().startswith(prefix.lower()):
if date_dots in f or date_underscores in f:
return os.path.join(d, f)
return None
def normalize_fio(fio):
if not fio or not isinstance(fio, str):
return "" return ""
val = str(val).strip().lower() fio_clean = re.sub(r'\(.*?\)', '', fio)
val = re.sub(r'\(.*?\)', '', val) fio_clean = fio_clean.replace('\xa0', ' ')
trans = str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ') parts = fio_clean.strip().split()
val = val.translate(trans).replace('ё', 'е') return " ".join(parts).title()
return re.sub(r'\s+', ' ', val).strip()
def clean_scud_fio_light(val): def clean_scud_fio_light(fio_str):
"""Легкая очистка ФИО для СКУД""" return normalize_fio(fio_str)
if pd.isna(val):
return ""
val = str(val).strip().lower()
val = re.sub(r'\(.*?\)', '', val).replace('ё', 'е')
return re.sub(r'\s+', ' ', val).strip()
def load_exceptions(file_path=EXCEPTIONS_FILE): def load_exceptions():
"""Загрузка списка исключений из JSON""" import json
if not os.path.exists(file_path): if os.path.exists(EXCEPTIONS_PATH):
return {"departments": [], "positions": [], "fio": [], "position_keywords": []} try:
with open(file_path, "r", encoding="utf-8") as f: with open(EXCEPTIONS_PATH, "r", encoding="utf-8") as f:
data = json.load(f) return json.load(f)
return { except Exception:
"departments": [str(d).strip().upper() for d in data.get("departments", [])], pass
"positions": [str(p).strip().lower() for p in data.get("positions", [])], return {"fio": [], "departments": [], "positions": [], "position_keywords": []}
"fio": [normalize_fio(f) for f in data.get("fio", [])],
"position_keywords": [str(k).strip().lower() for k in data.get("position_keywords", [])]
}
# тестовый комментарий
+2 -1
View File
@@ -1,7 +1,8 @@
{ {
"rules": [ "rules": [
"Кондрашова Елена Владимировна имеет 2 пропуска в СКУД. Если один 'Отсутствовал', а второй с временем входа — считать ПРИШЕДШЕЙ.", "Кондрашова Елена Владимировна имеет 2 пропуска в СКУД. Если один 'Отсутствовал', а второй с временем входа — считать ПРИШЕДШЕЙ.",
"Сотрудники подразделения ОА с должностью 'Инженер-программист' часто являются внутренними совместителями." "ГЛОБАЛЬНОЕ ПРАВИЛО 1: Документы отсутствий из 1С:ЗУП (отпуск, больничный, командировка) имеют 100% высший приоритет над статическим реестром удалёнщиков.",
"ГЛОБАЛЬНОЕ ПРАВИЛО 2: Удалённые сотрудники имеют право беспрепятственно работать в офисе по СКУД в любое время (это не является аномалией)."
], ],
"fio_corrections": { "fio_corrections": {
"кондрашава елена владимировна": "Кондрашова Елена Владимировна" "кондрашава елена владимировна": "Кондрашова Елена Владимировна"
Binary file not shown.
+2
View File
@@ -8,3 +8,5 @@ fio,reason,note
Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка
Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка
Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка
Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка
Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка
1 fio reason note
8 Пухаренко Юрий Владимирович Удаленная работа Постоянная удаленка
9 Пшеничный Виктор Петрович Удаленная работа Постоянная удаленка
10 Незнанова Валерия Игоревна Удаленная работа Постоянная удаленка
11 Ковалев Владимир Владимирович Удаленная работа Постоянная удаленка
12 Кожокарь Татьяна Юрьевна Удаленная работа Постоянная удаленка
+214 -98
View File
@@ -6,20 +6,26 @@ import pandas as pd
sys.path.append(os.path.dirname(os.path.abspath(__file__))) sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio
from modules.scud_export import run_export
from modules.share_copier import copy_1c_files_from_share
from modules.data_validator import check_file_freshness from modules.data_validator import check_file_freshness
from modules.data_loader import load_all_data from modules.data_loader import load_all_data
from modules.excel_exporter import generate_summary_excel, generate_detailed_excel from modules.excel_exporter import generate_summary_excel, generate_detailed_excel
from modules.ai_verifier import ai_verify_scud_against_staff from modules.ai_verifier import ai_verify_scud_against_staff
from modules.text_reporter import generate_markdown_report from modules.text_reporter import generate_markdown_report
from modules.feedback_loop import review_ai_decisions from modules.feedback_loop import review_ai_decisions
from modules.knowledge_base import load_knowledge_base
from modules.database import (
init_db,
save_scud_to_db,
save_staff_to_db,
save_absences_to_db,
save_anomalies_to_db
)
def load_static_reason_workers(): def load_static_reason_workers():
""" """Загружает реестр удалёнщиков и статических причин из CSV."""
Загружает реестр сотрудников со статическими/особыми причинами
из static_reason_workers.csv.
Возвращает словарь: {fio_clean: reason}
"""
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if not os.path.exists(static_path): if not os.path.exists(static_path):
return {} return {}
@@ -33,13 +39,47 @@ def load_static_reason_workers():
return {} return {}
def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules):
"""
Автоматически находит ИСТИННЫЕ аномалии.
ПРАВИЛА КОМПАНИИ:
1. Удалённые сотрудники имеют право беспрепятственно работать в офисе (НЕ аномалия).
2. Документы отсутствий из 1С имеют 100% приоритет над стат. реестром (НЕ аномалия).
3. АНОМАЛИЯ — Физический выход по СКУД при наличии в 1С ЛЮБЫХ причин отсутствия
(отпуск, больничный, уход за ребенком и т.д.), КРОМЕ командировок и служебных поездок.
"""
anomalies = []
kb_rules_text = " ".join(kb_rules).lower() if kb_rules else ""
ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк']
for idx, row in merged_df.iterrows():
fio = row.get('Сотрудник', row.get('fio_clean', ''))
fio_clean = row.get('fio_clean', '')
is_present = row.get('Пришел', False)
reason_1c = str(row.get('Вид_отсутствия', '')).strip()
has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != ''
is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text
if is_present and has_1c_reason:
reason_lower = reason_1c.lower()
is_allowed_trip = any(kw in reason_lower for kw in ALLOWED_WORK_TRIP_KEYWORDS)
if not is_allowed_trip and not is_fio_whitelisted_in_kb:
anomalies.append({
"type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ",
"fio": fio,
"details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'"
})
return anomalies
def aggregate_scud_by_employee(df, debug=False): def aggregate_scud_by_employee(df, debug=False):
""" """Агрегирует проходы СКУД по уникальным сотрудникам."""
Агрегирует записи СКУД по сотруднику (fio_clean). if df is None or df.empty or 'fio_clean' not in df.columns:
Устраняет дубликаты карт/табельных номеров (например, Кондрашова Е.В.):
Если человек пришел хотя бы по одной карте — Пришел = True.
"""
if df.empty or 'fio_clean' not in df.columns:
return df return df
aggregated = [] aggregated = []
@@ -53,10 +93,6 @@ def aggregate_scud_by_employee(df, debug=False):
best_row = group.iloc[0].to_dict() best_row = group.iloc[0].to_dict()
best_row['Пришел'] = is_present best_row['Пришел'] = is_present
if debug and len(group) > 1:
print(f" [DEBUG] Найдено дубликатов карт/записей для {fio_clean}: {len(group)} шт. Статус присутствия: {is_present}")
aggregated.append(best_row) aggregated.append(best_row)
return pd.DataFrame(aggregated) return pd.DataFrame(aggregated)
@@ -64,7 +100,9 @@ def aggregate_scud_by_employee(df, debug=False):
def main(): def main():
parser = argparse.ArgumentParser(description="Система модульного контроллинга СКУД ⟷ 1С") parser = argparse.ArgumentParser(description="Система модульного контроллинга СКУД ⟷ 1С")
parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки (подробный вывод)") parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки")
parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL")
parser.add_argument('--with-xlsx', action='store_true', help="Сохранять дублирующие сырые XLSX-файлы в папке data/")
args = parser.parse_args() args = parser.parse_args()
DEBUG = args.debug DEBUG = args.debug
@@ -73,131 +111,209 @@ def main():
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}") print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}")
print("=" * 60) print("=" * 60)
# 0. Валидация файлов # 0. Инициализация локальной базы данных SQLite и Базы Знаний
print("[0/5] Проверка актуальности и свежести входных файлов...") init_db()
is_valid, warnings, errors = check_file_freshness() kb_data = load_knowledge_base()
kb_rules = kb_data.get("rules", [])
# 0.1 Выгрузка из БД СКУД напрямую в SQLite и Excel (save_xlsx=True требуется для data_loader)
if not args.skip_export:
try:
run_export(save_xlsx=True, debug=DEBUG)
except Exception as e:
print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Используем имеющиеся записи в SQLite.")
else:
print("[0/5] Пропуск прямого экспорта из БД (--skip-export)...")
# 0.5 Копирование файлов 1С с сетевой шары /mnt/scud_share
copy_1c_files_from_share()
# 1. Валидация наличия файлов и данных
print("[1/5] Проверка актуальности и свежести входных данных...")
is_valid, warnings, errors, has_today_1c = check_file_freshness()
if warnings: if warnings:
print("\n--- ⚠️ ПРЕДУПРЕЖДЕНИЯ ОБ АКТУАЛЬНОСТИ ---") print("\n--- ⚠️ ПРЕДУПРЕЖДЕНИЯ ОБ АКТУАЛЬНОСТИ ---")
for w in warnings: for w in warnings:
print(f" {w}") print(f" • {w}")
print("-" * 45) print("-" * 45)
if not is_valid: if not is_valid:
print("\n" + "!" * 60) print("\n" + "!" * 60)
print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Обнаружены критические ошибки данных!") print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Отсутствуют критически важные файлы за вчерашний день!")
for e in errors: for e in errors:
print(f" {e}") print(f" {e}")
print("!" * 60) print("!" * 60)
sys.exit(1) sys.exit(1)
print("[✓] Проверка актуальности успешно пройдена!\n") print("[✓] Проверка доступности данных успешно пройдена!\n")
# 1. Загрузка данных # 2. Раздельная загрузка данных за Сегодня и за Вчера
print("[1/5] Загрузка данных СКУД, 1С, реестра статических причин и исключений...") print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...")
raw_scud_df, staff_fios_clean, df_absent = load_all_data() (
raw_scud_today_df,
raw_scud_yesterday_df,
df_staff_today,
df_absent_today,
df_staff_yesterday,
df_absent_yesterday
) = load_all_data()
# 💾 СОХРАНЕНИЕ КАДРОВЫХ СРЕЗОВ 1С В БАЗУ ДАННЫХ SQLite
if df_staff_yesterday is not None:
save_staff_to_db(df_staff_yesterday, DATE_YESTERDAY)
if df_absent_yesterday is not None:
save_absences_to_db(df_absent_yesterday, DATE_YESTERDAY)
if df_staff_today is not None:
save_staff_to_db(df_staff_today, DATE_TODAY)
if df_absent_today is not None:
save_absences_to_db(df_absent_today, DATE_TODAY)
staff_fios_yesterday_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else []
static_reasons_dict = load_static_reason_workers() static_reasons_dict = load_static_reason_workers()
if DEBUG: # ============================================================
print(f" [DEBUG] Загружено записей СКУД: {len(raw_scud_df)}") # 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (Строится Всегда)
print(f" [DEBUG] Загружено сотрудников из 1С: {len(staff_fios_clean)}") # ============================================================
print(f" [DEBUG] Загружено докум. отсутствий 1С: {len(df_absent)}") print("\n[3/5] Обработка данных за ВЧЕРА...")
print(f" [DEBUG] Загружено статических причин: {len(static_reasons_dict)}") scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() if not raw_scud_yesterday_df.empty else []
# 2. ИИ-сверка ФИО fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean)
print("[2/5] Проверка опечаток и сопоставление ФИО через ИИ...") if fio_mapping_scud_y:
scud_unrecognized = raw_scud_df[~raw_scud_df['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist() raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply(
fio_mapping = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_clean) lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x
if fio_mapping:
print(f" [ИИ] Найдено и исправлено совпадений/опечаток: {len(fio_mapping)}")
raw_scud_df['fio_clean'] = raw_scud_df['fio_clean'].apply(
lambda x: fio_mapping[x]['staff_fio'] if x in fio_mapping else x
) )
# 2.1 Агрегация нескольких карт/дубликатов if df_absent_yesterday is not None and not df_absent_yesterday.empty:
raw_scud_df = aggregate_scud_by_employee(raw_scud_df, debug=DEBUG) absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist()
if absent_unrecognized_yesterday:
fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean)
if fio_mapping_absent_y:
df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply(
lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x
)
# 3. Объединение данных и иерархия приоритетов raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG)
print("[3/5] Объединение реестров 1С, СКУД, статических причин и исключений...")
merged = raw_scud_df[raw_scud_df['fio_clean'].isin(staff_fios_clean)].copy()
# 🎯 ПРИОРИТЕТ 1: Выгрузка докум. отсутствий 1С:ЗУП (Отсутствия сотрудников - выгрузка.xlsx) merged_yesterday = raw_scud_yesterday_df[raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)].copy()
merged = merged.merge(df_absent, on='fio_clean', how='left') if df_absent_yesterday is not None:
merged_yesterday = merged_yesterday.merge(df_absent_yesterday, on='fio_clean', how='left')
# 🎯 ПРИОРИТЕТ 2: Статические причины из static_reason_workers.csv
# Применяются ТОЛЬКО если в 1С:ЗУП документа нет и сотрудник отсутствует в СКУД
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_yesterday = (
(merged['Пришел'] == False) & (merged_yesterday['Пришел'] == False) &
(merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) & (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged['fio_clean'] == fio_clean) (merged_yesterday['fio_clean'] == fio_clean)
) )
merged.loc[mask_apply_static, 'Вид_отсутствия'] = reason_val merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val
# 🎯 ПРИОРИТЕТ 3: Исключения по должности/департаменту/ФИО (exceptions.json) mask_exc_yesterday = (
# Если сотрудник попал в исключения (is_excluded = True), НЕ пришел в СКУД и НЕ имеет документа в 1С: (merged_yesterday['Пришел'] == False) &
# Присваиваем ему статус "Исключение (ОВК/Подрядчики)", чтобы он НЕ ПОПАДАЛ в неизвестные! (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
mask_apply_exceptions = ( (merged_yesterday.get('is_excluded', False) == True)
(merged['Пришел'] == False) &
(merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged.get('is_excluded', False) == True)
) )
merged.loc[mask_apply_exceptions, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
absent_explained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].notna())] generate_detailed_excel(merged_df=merged_yesterday, date_str=DATE_YESTERDAY)
absent_unexplained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].isna())]
scud_present_but_absent_in_1c = merged[(merged['Пришел'] == True) & (merged['Вид_отсутствия'].notna())]
if DEBUG: # 💾 СОХРАНЕНИЕ ВЧЕРАШНИХ ДАННЫХ В SQLite:
print("\n--- [DEBUG STATS] ---") save_scud_to_db(merged_yesterday, DATE_YESTERDAY)
print(f" Всего по списку: {len(merged)}")
print(f" Присутствуют на рабочем месте: {len(merged[merged['Пришел'] == True])}")
print(f" Официально отсутствуют (1С + стат. причины + исключения): {len(absent_explained)}")
print(f" Истинно неизвестные случаи: {len(absent_unexplained)}")
if not absent_unexplained.empty:
print(" Список неизвестных:")
for fio in absent_unexplained['Сотрудник'].dropna().tolist():
print(f" - {fio}")
print("---------------------\n")
# 4. ИИ-анализ и интерактивная проверка (HITL) # ============================================================
print("[4/5] Запуск ИИ-аудитора и проверка аномалий...") # 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА ЗА СЕГОДНЯ
# ============================================================
# 💾 СОХРАНЯЕМ СЕГОДНЯШНИЙ СКУД В SQLite (Даже если 1С еще не пришла):
save_scud_to_db(raw_scud_today_df, DATE_TODAY)
if has_today_1c and df_staff_today is not None and df_absent_today is not None:
print("\n[4/5] Обработка данных за СЕГОДНЯ...")
staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist()
scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist()
fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_today_clean)
if fio_mapping_scud:
raw_scud_today_df['fio_clean'] = raw_scud_today_df['fio_clean'].apply(
lambda x: fio_mapping_scud[x]['staff_fio'] if x in fio_mapping_scud else x
)
absent_unrecognized_today = df_absent_today[~df_absent_today['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist()
if absent_unrecognized_today:
fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized_today, staff_fios_today_clean)
if fio_mapping_absent:
df_absent_today['fio_clean'] = df_absent_today['fio_clean'].apply(
lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x
)
raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df, debug=DEBUG)
merged_today = raw_scud_today_df[raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)].copy()
merged_today = merged_today.merge(df_absent_today, on='fio_clean', how='left')
if static_reasons_dict:
for fio_clean, reason_val in static_reasons_dict.items():
mask_today = (
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today['fio_clean'] == fio_clean)
)
merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val
mask_exc_today = (
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today.get('is_excluded', False) == True)
)
merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules)
# 💾 СОХРАНЕНИЕ СЕГОДНЯШНИХ АНОМАЛИЙ В БАЗУ ДАННЫХ SQLite
save_anomalies_to_db(anomalies_list, DATE_TODAY)
absent_explained = merged_today[(merged_today['Пришел'] == False) & (merged_today['Вид_отсутствия'].notna())]
absent_unexplained = merged_today[(merged_today['Пришел'] == False) & (merged_today['Вид_отсутствия'].isna())]
scud_present_but_absent_in_1c = merged_today[(merged_today['Пришел'] == True) & (merged_today['Вид_отсутствия'].notna())]
print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...")
report_text = generate_markdown_report( 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,
raw_scud_df=raw_scud_df, anomalies_list=anomalies_list,
raw_scud_df=raw_scud_today_df,
raw_absent_df=df_absent_today,
date_str=DATE_TODAY date_str=DATE_TODAY
) )
suspicious_cases = [] generate_summary_excel(merged_df=merged_today, date_str=DATE_TODAY)
if not scud_present_but_absent_in_1c.empty:
for _, row in scud_present_but_absent_in_1c.iterrows():
suspicious_cases.append({
'fio_target': row.get('Сотрудник', ''),
'reason': f"Пришел по СКУД, но в 1С зафиксировано: {row.get('Вид_отсутствия', '')}"
})
review_ai_decisions(report_text, suspicious_cases)
# 5. Формирование Excel и Markdown отчетов
print("[5/5] Формирование итоговых Excel и Markdown отчетов...")
generate_summary_excel(merged_df=merged, date_str=DATE_TODAY)
generate_detailed_excel(merged_df=merged, date_str=DATE_YESTERDAY)
print("\n" + "=" * 60)
print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ:")
print("=" * 60)
print(report_text)
md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{DATE_TODAY}.md") md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{DATE_TODAY}.md")
with open(md_report_path, "w", encoding="utf-8") as f: with open(md_report_path, "w", encoding="utf-8") as f:
f.write(report_text) f.write(report_text)
print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}") print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}")
suspicious_cases = []
for a in anomalies_list:
if "ОФИЦИАЛЬНОМ ОТСУТСТВИИ" not in a.get('type', ''):
suspicious_cases.append({
'fio_target': a['fio'],
'reason': f"{a['type']}: {a['details']}"
})
if suspicious_cases:
review_ai_decisions(report_text, suspicious_cases)
print("\n" + "=" * 60)
print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:")
print("=" * 60)
print(report_text)
else:
print(f"\n[ℹ️] Формирование Ежедневной сводки за {DATE_TODAY} ПРОПУЩЕНО.")
print(" Ждём появления файлов 1С за текущую дату на сетевой шаре.")
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+43 -16
View File
@@ -2,44 +2,70 @@ import json
import re import re
import requests import requests
from config import OLLAMA_URL, OLLAMA_MODEL from config import OLLAMA_URL, OLLAMA_MODEL
from modules.database import get_all_rules_from_db
def ask_ollama(prompt, model=OLLAMA_MODEL, system_prompt=None):
"""Базовая функция отправки запросов к Ollama"""
if system_prompt is None:
system_prompt = "Ты — офисный аудитор безопасности и кадрового учета."
def ask_ollama(prompt, system_prompt=None):
"""
Универсальная функция отправки запросов к локальной модели Ollama (Qwen 2.5).
"""
payload = { payload = {
"model": model, "model": OLLAMA_MODEL,
"messages": [ "prompt": f"{system_prompt}\n\n{prompt}" if system_prompt else prompt,
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
"stream": False "stream": False
} }
try: try:
response = requests.post(OLLAMA_URL, json=payload, timeout=60) response = requests.post(OLLAMA_URL, json=payload, timeout=120)
if response.status_code == 200: if response.status_code == 200:
return response.json()['message']['content'] data = response.json()
if "response" in data:
return data["response"].strip()
elif "message" in data and "content" in data["message"]:
return data["message"]["content"].strip()
else: else:
return f"Ошибка API Ollama: {response.status_code}" return f"⚠️ Неизвестная структура ответа Ollama: {data}"
else:
return f"⚠️ Ошибка Ollama (Код {response.status_code}): {response.text}"
except requests.exceptions.ConnectionError:
return "⚠️ Не удалось подключиться к Ollama. Проверьте, запущен ли сервис (ollama run qwen2.5:14b)."
except Exception as e: except Exception as e:
return f"Ошибка подключения к Ollama: {e}" return f"⚠️ Ошибка при обращении к Ollama: {e}"
def get_system_rules_context():
"""
Загружает глобальные системные правила компании прямо из Базы Данных.
"""
rules = get_all_rules_from_db()
if not rules:
return ""
rules_text = "\n".join([f"- {r}" for r in rules])
return f"\nОБЯЗАТЕЛЬНЫЕ ГЛОБАЛЬНЫЕ ПРАВИЛА И ПРИОРИТЕТЫ КОМПАНИИ:\n{rules_text}\n"
def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
"""ИИ-сверка опечаток и несовпадений ФИО между СКУД и 1С:ЗУП""" """
ИИ-сверка опечаток и несовпадений ФИО между СКУД и 1С:ЗУП.
Записи из 1С считаются 100% эталоном.
"""
if not unrecognized_scud_fios or not staff_fios: if not unrecognized_scud_fios or not staff_fios:
return {} return {}
rules_context = get_system_rules_context()
prompt = f""" prompt = f"""
Ты — кадровый аудитор безопасности СКУД. Ты — кадровый аудитор безопасности СКУД.
{rules_context}
В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)} В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)}
В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)} В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
СТРОГИЕ ПРАВИЛА: СТРОГИЕ ПРАВИЛА:
1. Запись из Штат 1С — это 100% ПРАВИЛЬНЫЙ эталон. 1. Запись из Штат 1С — это 100% ПРАВИЛЬНЫЙ эталон.
2. В СКУД допущена опечатка. 2. В СКУД допущена опечатка.
3. В поле "warning" описать опечатку. 3. Любое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (кроме командировок) является гарантированной аномалией.
4. Запрещено выдумывать опечатки! 4. В поле "warning" опиши обнаруженную опечатку.
5. Запрещено выдумывать опечатки!
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON! ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON!
@@ -58,6 +84,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
prompt, prompt,
system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки." system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки."
) )
mapping = {} mapping = {}
try: try:
match = re.search(r'\{.*\}', raw_response, re.DOTALL) match = re.search(r'\{.*\}', raw_response, re.DOTALL)
+84 -25
View File
@@ -3,12 +3,16 @@ 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 DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR
) )
# Импортируем прямую SQL-выгрузку отсутствий из 1С
from modules.zup_extractor import fetch_zup_absences_from_sql
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 ""
pos_str = str(pos).strip().lower() if pd.notna(pos) else "" pos_str = str(pos).strip().lower() if pd.notna(pos) else ""
@@ -19,29 +23,64 @@ def is_excluded(fio, dept, pos, exceptions):
return True return True
return False return False
def load_staff_data():
staff_path = os.path.join(DATA_DIR, "Штатные сотрудники - ОУП.xlsx") def load_staff_data(date_str):
if not os.path.exists(staff_path): """
raise FileNotFoundError(f"Файл штатного расписания не найден: {staff_path}") Загружает файл Штатного расписания 1С за конкретную дату из папки 1c.
df_staff = pd.read_excel(staff_path, skiprows=7) """
df_staff = df_staff.rename(columns={df_staff.columns[1]: 'ФИО'}) filepath = find_dated_file("Штат", date_str)
if not filepath:
fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx")
if os.path.exists(fallback_path):
filepath = fallback_path
else:
return None
try:
df_raw = pd.read_excel(filepath, skiprows=8)
# Индексы столбцов в файле 1С: 1 — ФИО, 5 — Подразделение, 12 — Должность
df_staff = df_raw.iloc[:, [1, 5, 12]].copy()
df_staff.columns = ['ФИО', 'Подразделение', 'Должность']
df_staff = df_staff.dropna(subset=['ФИО']).reset_index(drop=True)
df_staff = df_staff[~df_staff['ФИО'].astype(str).str.contains('Всего|Организация|Сотрудник|ФИО', case=False, na=False)]
df_staff['fio_clean'] = df_staff['ФИО'].apply(normalize_fio) df_staff['fio_clean'] = df_staff['ФИО'].apply(normalize_fio)
return df_staff return df_staff
except Exception as e:
print(f"[❌] Ошибка загрузки штата из {filepath}: {e}")
return None
def load_absent_data():
absent_path = os.path.join(DATA_DIR, "Отсутствия сотрудников - выгрузка.xlsx") def load_absent_data(date_str):
if not os.path.exists(absent_path): """
raise FileNotFoundError(f"Файл отсутствий не найден: {absent_path}") Загружает документально подтвержденные отсутствия сотрудников
df_absent_raw = pd.read_excel(absent_path, skiprows=3) НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату.
df_absent = df_absent_raw[1:].copy().reset_index(drop=True) """
df_absent = df_absent.rename(columns={df_absent.columns[0]: 'ФИО', df_absent.columns[2]: 'Вид_отсутствия'}) try:
df_absent = fetch_zup_absences_from_sql(date_str)
if df_absent is not None and not df_absent.empty:
df_absent = df_absent.dropna(subset=['ФИО', 'Вид_отсутствия'])
df_absent = df_absent[~df_absent['ФИО'].astype(str).str.contains('АО "ЛЕНМОРНИИПРОЕКТ"|Сотрудник', case=False, na=False)]
df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio) 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'])
else:
return None
except Exception as e:
print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}")
return None
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"):
if not os.path.exists(scud_path): """Загружает файл СКУД за 'today' или 'yesterday'."""
raise FileNotFoundError(f"Файл СКУД не найден в папке {DATA_DIR}") target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY
scud_path = find_dated_file("Сотрудники", target_date, search_dirs=[SCUD_DIR])
if not scud_path:
scud_path = find_dated_file("Сотрудники", DATE_TODAY, search_dirs=[SCUD_DIR])
if not scud_path:
raise FileNotFoundError(f"Файл СКУД [{target_date_type}] за {target_date} не найден в {SCUD_DIR}!")
df_scud = pd.read_excel(scud_path) df_scud = pd.read_excel(scud_path)
exceptions = load_exceptions() exceptions = load_exceptions()
@@ -52,7 +91,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 ''
@@ -63,16 +101,37 @@ def load_scud_data():
df_scud['Пришел'] = df_scud.apply(is_person_present, axis=1) df_scud['Пришел'] = df_scud.apply(is_person_present, axis=1)
# Отмечаем флаг исключений, но НЕ удаляем сразу
df_scud['is_excluded'] = df_scud.apply( df_scud['is_excluded'] = df_scud.apply(
lambda r: is_excluded(r.get('Сотрудник', ''), r.get('Подразделение', ''), r.get('Должность', ''), exceptions), lambda r: is_excluded(r.get('Сотрудник', ''), r.get('Подразделение', ''), r.get('Должность', ''), exceptions),
axis=1 axis=1
) )
return df_scud return df_scud
def load_all_data(): def load_all_data():
df_scud = load_scud_data() """
df_staff = load_staff_data() Загружает слитные датасеты за Сегодня и за Вчера.
df_absent = load_absent_data() Если данных за Сегодня нет, возвращает для них None.
staff_fios_clean = df_staff['fio_clean'].dropna().tolist() """
return df_scud, 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()
# Загружаем 1С за Сегодня (может быть None)
df_staff_today = load_staff_data(DATE_TODAY)
df_absent_today = load_absent_data(DATE_TODAY)
# Загружаем 1С за Вчера (гарантированно есть)
df_staff_yesterday = load_staff_data(DATE_YESTERDAY)
df_absent_yesterday = load_absent_data(DATE_YESTERDAY)
return (
df_scud_today,
df_scud_yesterday,
df_staff_today,
df_absent_today,
df_staff_yesterday,
df_absent_yesterday
)
+30 -34
View File
@@ -1,46 +1,42 @@
import os import os
import pandas as pd import time
from datetime import datetime, timedelta from datetime import datetime
from config import DATA_DIR, DATE_TODAY, DATE_YESTERDAY from config import DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR
def check_file_freshness():
def check_file_freshness(max_age_hours=24):
""" """
Проверяет наличие и актуальность файлов СКУД и 1С. Проверяет существование входных файлов 1С и СКУД.
Возвращает (is_valid, warnings_list, errors_list) Возвращает флаги доступности файлов за Сегодня и Вчера.
""" """
errors = []
warnings = [] warnings = []
errors = []
# Ожидаемые имена файлов # 1. Проверка файлов 1С за СЕГОДНЯ
scud_today_file = os.path.join(DATA_DIR, f"Сотрудники_{DATE_TODAY}.xlsx") staff_today = find_dated_file("Штат", DATE_TODAY)
scud_yesterday_file = os.path.join(DATA_DIR, f"Сотрудники_{DATE_YESTERDAY}.xlsx") absent_today = find_dated_file("Отсутствия", DATE_TODAY)
staff_file = os.path.join(DATA_DIR, "Штатные сотрудники - ОУП.xlsx") has_today_1c = bool(staff_today and absent_today)
absent_file = os.path.join(DATA_DIR, "Отсутствия сотрудников - выгрузка.xlsx")
now = datetime.now() if not has_today_1c:
warnings.append(
f"Файлы 1С за СЕГОДНЯ ({DATE_TODAY}) еще не выложены на сетевую шару. "
f"Формирование Ежедневной сводки за сегодня будет временно пропущено."
)
# 1. Проверка файла СКУД за СЕГОДНЯ # 2. Проверка файлов 1С за ВЧЕРА (критически важны для детального отчета)
if not os.path.exists(scud_today_file): staff_yesterday = find_dated_file("Штат", DATE_YESTERDAY)
errors.append(f"❌ КРИТИЧЕСКАЯ ОШИБКА: Отсутствует актуальный файл СКУД за сегодня: 'Сотрудники_{DATE_TODAY}.xlsx'!") absent_yesterday = find_dated_file("Отсутствия", DATE_YESTERDAY)
else:
# Проверяем дату изменения файла (mtime)
file_mtime = datetime.fromtimestamp(os.path.getmtime(scud_today_file))
if file_mtime.date() < now.date():
errors.append(f"⚠️ ОШИБКА АКТУАЛЬНОСТИ: Файл 'Сотрудники_{DATE_TODAY}.xlsx' существует, но изменялся прошлым днем ({file_mtime.strftime('%d.%m.%Y %H:%M')})!")
# 2. Проверка файла СКУД за ВЧЕРА if not staff_yesterday or not absent_yesterday:
if not os.path.exists(scud_yesterday_file): errors.append(
warnings.append(f"⚠️ ПРЕДУПРЕЖДЕНИЕ: Файл СКУД за вчера ({DATE_YESTERDAY}) не найден. Детальный отчет за вчера может быть неполным.") f"Отсутствуют файлы 1С за ВЧЕРА ({DATE_YESTERDAY}) в папке {ZUP_1C_DIR}. "
f"Невозможно построить детальный отчет за прошлую смену."
)
# 3. Проверка файлов 1С (Штат и Отсутствия) # 3. Проверка файлов СКУД
for f_path, f_name in [(staff_file, "Штатные сотрудники"), (absent_file, "Выгрузка отсутствий")]: scud_today = find_dated_file("Сотрудники", DATE_TODAY, search_dirs=[SCUD_DIR])
if not os.path.exists(f_path): if not scud_today:
errors.append(f"❌ КРИТИЧЕСКАЯ ОШИБКА: Файл 1С '{f_name}' не найден в папке data/!") warnings.append(f"Сегодняшний файл СКУД за {DATE_TODAY} еще не сформирован в {SCUD_DIR}")
else:
file_mtime = datetime.fromtimestamp(os.path.getmtime(f_path))
days_old = (now - file_mtime).days
if days_old >= 2:
warnings.append(f"⚠️ УСТАРЕВШИЕ ДАННЫЕ 1С: Файл '{os.path.basename(f_path)}' не обновлялся {days_old} дн. (Дата: {file_mtime.strftime('%d.%m.%Y')})")
is_valid = len(errors) == 0 is_valid = len(errors) == 0
return is_valid, warnings, errors return is_valid, warnings, errors, has_today_1c
+271
View File
@@ -0,0 +1,271 @@
import os
import sqlite3
import json
import pandas as pd
from datetime import datetime
from config import DATA_DIR
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
def get_connection():
"""Создает подключение к базе данных SQLite с оптимизированными настройками."""
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA foreign_keys = ON;")
conn.execute("PRAGMA journal_mode = WAL;") # Режим быстрого чтения/записи
return conn
def init_db():
"""Создает структуру таблиц и индексов в базе данных."""
with get_connection() as conn:
cursor = conn.cursor()
# 1. Таблица СКУД
cursor.execute("""
CREATE TABLE IF NOT EXISTS scud_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
log_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
position TEXT,
time_in TEXT,
time_out TEXT,
time_in_building TEXT,
is_present INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 2. Таблица Штатного расписания 1С
cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_staff (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
position TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 3. Таблица Отсутствий из 1С
cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_absences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
absence_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
absence_type TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 4. Реестр аномалий
cursor.execute("""
CREATE TABLE IF NOT EXISTS anomalies_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anomaly_date TEXT NOT NULL,
fio TEXT NOT NULL,
anomaly_type TEXT NOT NULL,
details TEXT NOT NULL,
human_status TEXT DEFAULT 'Pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 5. База знаний ИИ
cursor.execute("""
CREATE TABLE IF NOT EXISTS ai_knowledge_base (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_text TEXT UNIQUE NOT NULL,
added_by TEXT DEFAULT 'Human',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# ⚡ ИНДЕКСЫ ДЛЯ УСКОРЕНИЯ ЗАПРОСОВ
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_absences_date ON zup_absences(absence_date);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_staff_date ON zup_staff(snapshot_date);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_anomalies_date ON anomalies_history(anomaly_date);")
conn.commit()
# Автоматически загружаем базовые правила и системный промпт
sync_knowledge_base_to_db()
def sync_knowledge_base_to_db():
"""
Автоматически мигрирует правила из knowledge_base.json и системный промпт в таблицу ai_knowledge_base SQLite.
"""
system_ai_prompt = (
"СИСТЕМНЫЙ ПРОМПТ ИИ (MATCHING & VERIFICATION):\n"
"Ты — экспертный ИИ-аудитор СКУД и кадрового учета 1С. Твоя задача — проводить интеллектуальный анализ аномалий "
"и сопоставление (Fuzzy Matching) нераспознанных ФИО из СКУД с эталонным Штатным расписанием 1С.\n\n"
"1. ЗАЩИТА ОТ ПОЛНЫХ ТЁЗОК (FULL NAME COLLISION):\n"
" - Если в эталонном штате 1С присутствуют два или более сотрудников с ОДИНАКОВЫМИ ФИО (полные тёзки), "
"ты ОБЯЗАН учитывать контекст: Подразделение и Должность.\n"
" - Ни в коем случае не объединяй записи разных людей! Если контекст не позволяет однозначно определить, кто именно из тёзок совершил проход, ПОМЕТЬ СЛУЧАЙ КАК НЕОДНОЗНАЧНЫЙ и НЕ делай автоматическую подмену.\n\n"
"2. ПРАВИЛА СОПОСТАВЛЕНИЯ (MATCHING RULES):\n"
" - Допускаются исправления мелких опечаток, перестановки порядка (Имя Фамилия -> Фамилия Имя), замена букв Е/Ё, И/Й.\n"
" - Учитывай сокращения инициалов (например, 'Иванов И.В.' -> 'Иванов Иван Васильевич').\n"
" - Если уверенность в совпадении меньше 85% — НЕ сопоставляй, оставляй запись как нераспознанную.\n\n"
"3. ФОРМАТ ВЫХОДНЫХ ДАННЫХ:\n"
" Верни строго JSON-объект без вводных слов и Markdown-разметки: {\"mappings\": [...], \"unmatched\": [...]}"
)
rules_to_insert = [
system_ai_prompt,
"ГЛОБАЛЬНОЕ ПРАВИЛО 1: Любое физическое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (отпуск, больничный, уход за ребенком и др.), КРОМЕ командировок и служебных поездок — ЯВЛЯЕТСЯ СТРОГОЙ ИСТИННОЙ АНОМАЛИЕЙ.",
"ГЛОБАЛЬНОЕ ПРАВИЛО 2: Документы отсутствий из 1С:ЗУП имеют 100% высший приоритет над статическим реестром удалёнщиков.",
"ГЛОБАЛЬНОЕ ПРАВИЛО 3: Удалённые сотрудники имеют право беспрепятственно работать в офисе по СКУД в любое время (это не является аномалией)."
]
kb_path = os.path.join(os.path.dirname(DATA_DIR), "knowledge_base.json")
if os.path.exists(kb_path):
try:
with open(kb_path, "r", encoding="utf-8") as f:
kb_data = json.load(f)
file_rules = kb_data.get("rules", [])
for r in file_rules:
if r not in rules_to_insert:
rules_to_insert.append(r)
except Exception as e:
print(f"[⚠️] Ошибка чтения knowledge_base.json: {e}")
with get_connection() as conn:
cursor = conn.cursor()
for rule in rules_to_insert:
cursor.execute(
"INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)",
(rule.strip(), "System_AI")
)
conn.commit()
def save_scud_to_db(df_scud, date_str):
"""Оптимизированное сохранение логов СКУД за дату."""
if df_scud.empty:
return
data_to_insert = []
for _, r in df_scud.iterrows():
data_to_insert.append((
date_str,
r.get('Сотрудник', r.get('fio_raw', '')),
r.get('fio_clean', ''),
r.get('Подразделение', ''),
r.get('Должность', ''),
r.get('Начало_дня', 'Нет входа'),
r.get('Конец_дня', 'Нет выхода'),
r.get('Находился_в_здании', '00:00'),
1 if r.get('Пришел', False) else 0
))
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE log_date = ?", (date_str,))
cursor.executemany("""
INSERT INTO scud_logs (log_date, fio, fio_clean, department, position, time_in, time_out, time_in_building, is_present)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", data_to_insert)
conn.commit()
def save_staff_to_db(df_staff, date_str):
"""Сохраняет выгрузку Штатного расписания 1С в БД."""
if df_staff is None or df_staff.empty:
return
data_to_insert = []
for _, r in df_staff.iterrows():
data_to_insert.append((
date_str,
r.get('ФИО', ''),
r.get('fio_clean', ''),
r.get('Подразделение', ''),
r.get('Должность', '')
))
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,))
cursor.executemany("""
INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position)
VALUES (?, ?, ?, ?, ?)
""", data_to_insert)
conn.commit()
def save_absences_to_db(df_absent, date_str):
"""Оптимизированное сохранение отсутствий из 1С в БД."""
if df_absent is None or df_absent.empty:
return
data_to_insert = []
for _, r in df_absent.iterrows():
data_to_insert.append((
date_str,
r.get('ФИО', r.get('fio_clean', '')),
r.get('fio_clean', ''),
r.get('Вид_отсутствия', '')
))
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,))
cursor.executemany("""
INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type)
VALUES (?, ?, ?, ?)
""", data_to_insert)
conn.commit()
def save_anomalies_to_db(anomalies_list, date_str):
"""Записывает обнаруженные аномалии за дату."""
if not anomalies_list:
return
data_to_insert = [
(date_str, a.get('fio', ''), a.get('type', ''), a.get('details', ''))
for a in anomalies_list
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,))
cursor.executemany("""
INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details)
VALUES (?, ?, ?, ?)
""", data_to_insert)
conn.commit()
def get_all_rules_from_db():
"""Возвращает список всех правил для ИИ из базы данных."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT rule_text FROM ai_knowledge_base")
rows = cursor.fetchall()
return [r[0] for r in rows]
def add_rule_to_db(rule_text, added_by="Human"):
"""Добавляет новое динамическое правило в базу данных."""
if not rule_text or not rule_text.strip():
return
with get_connection() as conn:
cursor = conn.cursor()
try:
cursor.execute(
"INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)",
(rule_text.strip(), added_by)
)
conn.commit()
except Exception as e:
print(f"[⚠️] Ошибка записи правила в БД: {e}")
+75 -26
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"] 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):
@@ -93,22 +128,26 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
current_row = 4 current_row = 4
# 3. НЕИЗВЕСТНО (По умолчанию РАСКРЫТО, нумерация 1. ФИО) # 3. НЕИЗВЕСТНО (По умолчанию РАСКРЫТО, чистые ФИО без цифр)
unexplained = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna())] unexplained = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna())]
ws.cell(row=current_row, column=1, value="неизвестно") ws.cell(row=current_row, column=1, value="неизвестно")
ws.cell(row=current_row, column=2, value=len(unexplained)) ws.cell(row=current_row, column=2, value=len(unexplained))
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font) format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font)
current_row += 1 current_row += 1
for idx, fio in enumerate(sorted(unexplained['Сотрудник'].dropna().unique()), 1): for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=f"{idx}. {fio}") ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False) format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = False ws.row_dimensions[current_row].hidden = False
current_row += 1 current_row += 1
# 4. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True) # 4. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True, ИСКЛЮЧЕНИЯ ИСКЛЮЧЕНЫ)
absent_only = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna())] absent_only = merged_df[
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
absent_groups = absent_only.groupby('Вид_отсутствия') absent_groups = absent_only.groupby('Вид_отсутствия')
for idx_cat, (cat_name, group) in enumerate(absent_groups): for idx_cat, (cat_name, group) in enumerate(absent_groups):
@@ -120,14 +159,14 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font) format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font)
current_row += 1 current_row += 1
for idx, fio in enumerate(sorted(group['Сотрудник'].dropna().unique()), 1): for fio in sorted(group['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=f"{idx}. {fio}") ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, cat_fill, is_bold=False) format_row_cells(ws, current_row, cat_fill, is_bold=False)
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
# 🎯 5. ИТОГО НА РАБОТЕ (Включает: пришли по СКУД + Командировки + Удаленная работа) # 5. ИТОГО НА РАБОТЕ (Свернут hidden=True)
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 +177,15 @@ 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. АНОМАЛИИ (В самом низу таблицы, раскрывающийся список) if not present.empty:
# Столбец A: f"{idx}. {fio} (На работе)" for fio in sorted(present['Сотрудник'].dropna().unique()):
# Столбец B: f"В 1С: {reason}" ws.cell(row=current_row, column=1, value=fio)
# Ширина столбца B = 32.0 (ровно 230px). Автоперенос + динамическая высота строк. format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
# 6. АНОМАЛИИ (Свернут hidden=True)
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="Аномалии")
@@ -149,15 +193,15 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font) format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font)
current_row += 1 current_row += 1
chars_per_line_b = 30 # Влезает символов в столбец B шириной 230px (32.0 units) chars_per_line_b = 30
if not anomalies.empty: if not anomalies.empty:
for idx, (_, row) in enumerate(anomalies.iterrows(), 1): for _, row in anomalies.iterrows():
fio = row.get('Сотрудник', '') fio = row.get('Сотрудник', '')
reason = row.get('Вид_отсутствия', 'Неизвестная причина') reason = row.get('Вид_отсутствия', 'Неизвестная причина')
reason_text = f"В 1С: {reason}" reason_text = f"В 1С: {reason}"
cell_a = ws.cell(row=current_row, column=1, value=f"{idx}. {fio} (На работе)") cell_a = ws.cell(row=current_row, column=1, value=f"{fio} (На работе)")
cell_b = ws.cell(row=current_row, column=2, value=reason_text) cell_b = ws.cell(row=current_row, column=2, value=reason_text)
cell_a.fill = FILL_ANOMALY cell_a.fill = FILL_ANOMALY
@@ -165,11 +209,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,12 +219,11 @@ 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
try: try:
wb.save(output_path) wb.save(output_path)
@@ -235,22 +276,30 @@ 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'))
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: 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 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:
+48 -24
View File
@@ -1,33 +1,57 @@
from modules.knowledge_base import add_rule_to_kb import os
import json
from modules.database import add_rule_to_db
def review_ai_decisions(ai_report, suspicious_cases): try:
""" from modules.knowledge_base import save_rule
Интерактивный модуль: показывает спорные моменты человеку except ImportError:
и записывает его вердикт в память ИИ. try:
""" from modules.knowledge_base import add_rule as save_rule
print("\n" + "="*60) except ImportError:
print("РЕЖИМ ОБУЧЕНИЯ И ПРОВЕРКИ РЕШЕНИЙ ИИ (HUMAN-IN-THE-LOOP)") save_rule = None
print("="*60)
def review_ai_decisions(report_text, suspicious_cases):
"""
Интерактивный консольный модуль обучения (Human-in-the-Loop).
Запрашивает подтверждение оператора ТОЛЬКО по нетипичным/сложным случаям.
"""
if not suspicious_cases: if not suspicious_cases:
print("Спорных случаев для обучения сегодня не обнаружено.") print("\n[✓] Все аномалии валидированы автоматически. Спорных вопросов для оператора нет.")
return return
for case in suspicious_cases: print("\n" + "=" * 60)
fio = case.get('fio_target') print("РЕЖИМ ОБУЧЕНИЯ И ПРОВЕРКИ РЕШЕНИЙ ИИ (HUMAN-IN-THE-LOOP)")
reason = case.get('reason') print("=" * 60)
print(f"\n[?] Спорный случай: {fio}")
print(f" Причина аномалии: {reason}")
user_input = input("Согласны с решением ИИ? (д - да / н - нет / введите новое правило): ").strip() for idx, case in enumerate(suspicious_cases, 1):
fio_target = case.get('fio_target', 'Неизвестно')
reason = case.get('reason', 'Нет описания')
if user_input.lower() == 'н' or len(user_input) > 5: print(f"\n[?] Спорный случай {idx}/{len(suspicious_cases)}: {fio_target}")
if user_input.lower() == 'н': print(f" Детали: {reason}")
rule_text = input(f"Опишите правило для {fio} (например: 'Считать совместителем'): ")
user_input = input("Подтверждаете аномалию? (д / да / н / нет / или введите текст правила): ").strip()
user_input_clean = user_input.lower()
# Расширенный список вариантов "ДА"
if user_input_clean in ['д', 'да', 'da', 'yes', 'y', 'ок', 'ага', '+']:
print(" [✓] Решение подтверждено оператором.")
elif user_input_clean in ['н', 'нет', 'no', 'n', '-']:
print(" [ℹ️] Решение отклонено оператором.")
elif user_input:
# Если оператор ввел действительно новое текстовое правило
new_rule = f"Правило по {fio_target}: {user_input}"
add_rule_to_db(new_rule, added_by="Human_Admin")
if save_rule:
try:
save_rule(new_rule)
except Exception:
pass
print(f" [✓] Новое правило записано в Базу Знаний: {new_rule}")
else: else:
rule_text = user_input print(" [ℹ️] Запись пропущена.")
# Записываем правило в Базу Знаний print("\n[✓] ЭТАП ОБУЧЕНИЯ ЗАВЕРШЕН.")
full_rule = f"Сотрудник {fio}: {rule_text}"
add_rule_to_kb(full_rule)
print(f"[+] ИИ 'запомнил' это правило и учитывает его со следующего запуска!")
-62
View File
@@ -1,62 +0,0 @@
[2026-07-28 14:45:26] [INFO] === Запуск скрипта экспорта СКУД ===
[2026-07-28 14:45:26] [INFO] --- Обработка периода: Вчера (27.07.2026) ---
[2026-07-28 14:45:26] [INFO] Файл Сотрудники_27.07.2026.xlsx уже существует. Удаляем для перезаписи...
[2026-07-28 14:45:26] [ERROR] ОШИБКА при обработке даты 27.07.2026: ('IM002', '[IM002] [Microsoft][Диспетчер драйверов ODBC] Источник данных не найден и не указан драйвер, используемый по умолчанию (0) (SQLDriverConnect)')
[2026-07-28 14:45:26] [INFO] --- Обработка периода: Сегодня (28.07.2026) ---
[2026-07-28 14:45:26] [INFO] Файл Сотрудники_28.07.2026.xlsx уже существует. Удаляем для перезаписи...
[2026-07-28 14:45:26] [ERROR] ОШИБКА при обработке даты 28.07.2026: ('IM002', '[IM002] [Microsoft][Диспетчер драйверов ODBC] Источник данных не найден и не указан драйвер, используемый по умолчанию (0) (SQLDriverConnect)')
[2026-07-28 14:45:26] [INFO] === Завершение работы скрипта ===
[2026-07-28 14:47:46] [INFO] === Запуск скрипта экспорта СКУД ===
[2026-07-28 14:47:46] [INFO] --- Обработка периода: Вчера (27.07.2026) ---
[2026-07-28 14:48:17] [INFO] База вернула 272 строк за 27.07.2026
[2026-07-28 14:48:17] [INFO] [SUCCESS] Успешно создан файл C:\scud_exchsnge\Сотрудники_27.07.2026.xlsx
[2026-07-28 14:48:17] [INFO] --- Обработка периода: Сегодня (28.07.2026) ---
[2026-07-28 14:48:44] [INFO] База вернула 272 строк за 28.07.2026
[2026-07-28 14:48:44] [INFO] [SUCCESS] Успешно создан файл C:\scud_exchsnge\Сотрудники_28.07.2026.xlsx
[2026-07-28 14:48:44] [INFO] === Завершение работы скрипта ===
[2026-07-28 14:49:55] [INFO] === Запуск скрипта экспорта СКУД ===
[2026-07-28 14:49:55] [INFO] --- Обработка периода: Вчера (27.07.2026) ---
[2026-07-28 14:49:55] [INFO] Файл Сотрудники_27.07.2026.xlsx уже существует. Удаляем для перезаписи...
[2026-07-28 14:50:17] [INFO] База вернула 272 строк за 27.07.2026
[2026-07-28 14:50:18] [INFO] [SUCCESS] Успешно создан файл C:\scud_exchsnge\Сотрудники_27.07.2026.xlsx
[2026-07-28 14:50:18] [INFO] --- Обработка периода: Сегодня (28.07.2026) ---
[2026-07-28 14:50:18] [INFO] Файл Сотрудники_28.07.2026.xlsx уже существует. Удаляем для перезаписи...
[2026-07-28 14:50:50] [INFO] База вернула 272 строк за 28.07.2026
[2026-07-28 14:50:50] [INFO] [SUCCESS] Успешно создан файл C:\scud_exchsnge\Сотрудники_28.07.2026.xlsx
[2026-07-28 14:50:50] [INFO] === Завершение работы скрипта ===
[2026-07-28 14:53:08] [INFO] === Запуск скрипта экспорта СКУД ===
[2026-07-28 14:53:08] [INFO] --- Обработка периода: Вчера (27.07.2026) ---
[2026-07-28 14:53:08] [INFO] Файл Сотрудники_27.07.2026.xlsx уже существует. Удаляем для перезаписи...
[2026-07-28 14:53:30] [INFO] База вернула 272 строк за 27.07.2026
[2026-07-28 14:53:30] [INFO] [SUCCESS] Успешно создан файл C:\scud_exchsnge\Сотрудники_27.07.2026.xlsx
[2026-07-28 14:53:30] [INFO] --- Обработка периода: Сегодня (28.07.2026) ---
[2026-07-28 14:53:30] [INFO] Файл Сотрудники_28.07.2026.xlsx уже существует. Удаляем для перезаписи...
[2026-07-28 14:53:52] [INFO] База вернула 272 строк за 28.07.2026
[2026-07-28 14:53:52] [INFO] [SUCCESS] Успешно создан файл C:\scud_exchsnge\Сотрудники_28.07.2026.xlsx
[2026-07-28 14:53:52] [INFO] === Завершение работы скрипта ===
[2026-07-28 15:12:05] [INFO] === Запуск скрипта экспорта СКУД ===
[2026-07-28 15:12:05] [INFO] --- Обработка периода: Вчера (27.07.2026) ---
[2026-07-28 15:12:05] [INFO] Файл Сотрудники_27.07.2026.xlsx существует. Перезаписываем...
[2026-07-28 15:12:05] [ERROR] ОШИБКА при обработке даты 27.07.2026: ('42000', '[42000] [Microsoft][SQL Server Native Client 10.0][SQL Server]Неправильный синтаксис около конструкции "me". (102) (SQLExecDirectW)')
[2026-07-28 15:12:05] [INFO] --- Обработка периода: Сегодня (28.07.2026) ---
[2026-07-28 15:12:05] [INFO] Файл Сотрудники_28.07.2026.xlsx существует. Перезаписываем...
[2026-07-28 15:12:05] [ERROR] ОШИБКА при обработке даты 28.07.2026: ('42000', '[42000] [Microsoft][SQL Server Native Client 10.0][SQL Server]Неправильный синтаксис около конструкции "me". (102) (SQLExecDirectW)')
[2026-07-28 15:12:05] [INFO] === Завершение работы скрипта ===
[2026-07-28 15:13:31] [INFO] === Запуск скрипта экспорта СКУД ===
[2026-07-28 15:13:31] [INFO] --- Обработка периода: Вчера (27.07.2026) ---
[2026-07-28 15:15:02] [INFO] База вернула 272 строк за 27.07.2026
[2026-07-28 15:15:03] [INFO] [SUCCESS] Успешно создан файл C:\scud_exchsnge\Сотрудники_27.07.2026.xlsx
[2026-07-28 15:15:03] [INFO] --- Обработка периода: Сегодня (28.07.2026) ---
[2026-07-28 15:16:20] [INFO] База вернула 272 строк за 28.07.2026
[2026-07-28 15:16:21] [INFO] [SUCCESS] Успешно создан файл C:\scud_exchsnge\Сотрудники_28.07.2026.xlsx
[2026-07-28 15:16:21] [INFO] === Завершение работы скрипта ===
[2026-07-28 15:17:22] [INFO] === Запуск скрипта экспорта СКУД ===
[2026-07-28 15:17:22] [INFO] --- Обработка периода: Вчера (27.07.2026) ---
[2026-07-28 15:17:22] [INFO] Файл Сотрудники_27.07.2026.xlsx существует. Перезаписываем...
[2026-07-28 15:17:46] [INFO] База вернула 272 строк за 27.07.2026
[2026-07-28 15:17:47] [INFO] [SUCCESS] Успешно создан файл C:\scud_exchsnge\Сотрудники_27.07.2026.xlsx
[2026-07-28 15:17:47] [INFO] --- Обработка периода: Сегодня (28.07.2026) ---
[2026-07-28 15:17:47] [INFO] Файл Сотрудники_28.07.2026.xlsx существует. Перезаписываем...
[2026-07-28 15:18:11] [INFO] База вернула 272 строк за 28.07.2026
[2026-07-28 15:18:12] [INFO] [SUCCESS] Успешно создан файл C:\scud_exchsnge\Сотрудники_28.07.2026.xlsx
[2026-07-28 15:18:12] [INFO] === Завершение работы скрипта ===
+57 -89
View File
@@ -1,11 +1,7 @@
""" """
Скрипт экспорта данных СКУД (Orion) в Excel. Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel.
Оптимизированная супер-быстрая версия (использует ROW_NUMBER() вместо подзапросов). Исправлена логика определения направления проходов (Mode = 2 -> OUT).
Адаптирован для Linux (Ubuntu 24.04).
Запуск:
python scud_export.py
python scud_export.py -d
python scud_export.py --date 28.07.2026 -d
""" """
import argparse import argparse
@@ -19,13 +15,13 @@ import pandas as pd
import pyodbc import pyodbc
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
from config import SCUD_DIR, clean_scud_fio_light, load_exceptions
from modules.database import save_scud_to_db
warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable") warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable")
# ==============================================================================
# 1. Настройки путей и логов
# ==============================================================================
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
LOG_DIR = os.path.join(SCRIPT_DIR, "logs") LOG_DIR = os.path.join(SCRIPT_DIR, "..", "logs")
os.makedirs(LOG_DIR, exist_ok=True) os.makedirs(LOG_DIR, exist_ok=True)
TODAY_DATE_STR = datetime.now().strftime("%d.%m.%Y") TODAY_DATE_STR = datetime.now().strftime("%d.%m.%Y")
@@ -34,13 +30,14 @@ LOG_FILE = os.path.join(LOG_DIR, f"export_{TODAY_DATE_STR}.log")
logger = logging.getLogger("scud_export") logger = logging.getLogger("scud_export")
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") if not logger.handlers:
_console_handler = logging.StreamHandler(sys.stdout) _file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S") _console_handler = logging.StreamHandler(sys.stdout)
_file_handler.setFormatter(_formatter) _formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
_console_handler.setFormatter(_formatter) _file_handler.setFormatter(_formatter)
logger.addHandler(_file_handler) _console_handler.setFormatter(_formatter)
logger.addHandler(_console_handler) logger.addHandler(_file_handler)
logger.addHandler(_console_handler)
def log(message: str, level: str = "INFO"): def log(message: str, level: str = "INFO"):
@@ -56,21 +53,12 @@ def log(message: str, level: str = "INFO"):
logger.log(level_map.get(level, logging.INFO), message) logger.log(level_map.get(level, logging.INFO), message)
# ============================================================================== SERVER_NAME = r"172.16.31.221\SQL"
# 2. Настройки базы данных и папки
# ==============================================================================
SERVER_NAME = r"192.168.1.253\SQL"
DATABASE_NAME = "Orion-14.01.21-1" DATABASE_NAME = "Orion-14.01.21-1"
SQL_USER = "sa" SQL_USER = "sa"
SQL_PASSWORD = "123456" SQL_PASSWORD = "123456"
ODBC_DRIVER = "ODBC Driver 18 for SQL Server"
FOLDER_PATH = r"C:\scud_exchsnge"
ODBC_DRIVER = "SQL Server Native Client 10.0"
# ==============================================================================
# 3. СУПЕР-ОПТИМИЗИРОВАННЫЙ SQL-ЗАПРОС (Мгновенное выполнение)
# ==============================================================================
SQL_QUERY_TEMPLATE = r""" SQL_QUERY_TEMPLATE = r"""
DECLARE @InputDate DATE = '{target_date}'; DECLARE @InputDate DATE = '{target_date}';
DECLARE @TargetDate DATE = @InputDate; DECLARE @TargetDate DATE = @InputDate;
@@ -78,16 +66,17 @@ 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 часа за один проход
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
@@ -95,7 +84,6 @@ 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,
@@ -167,9 +155,6 @@ ORDER BY p.Name ASC;
""" """
# ==============================================================================
# 4. Вспомогательные функции
# ==============================================================================
def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0): def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0):
from openpyxl import load_workbook from openpyxl import load_workbook
@@ -196,7 +181,6 @@ def get_targets(input_date: str | None):
try: try:
parsed = datetime.strptime(input_date, "%d.%m.%Y").date() parsed = datetime.strptime(input_date, "%d.%m.%Y").date()
targets.append({"name": "Указанная дата", "date": parsed}) targets.append({"name": "Указанная дата", "date": parsed})
log(f"Запущен ручной режим для даты: {input_date}")
except ValueError: except ValueError:
log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR") log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR")
sys.exit(1) sys.exit(1)
@@ -211,16 +195,13 @@ def get_targets(input_date: str | None):
return targets return targets
# ============================================================================== def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: bool = False):
# 5. Основная функция выгрузки
# ==============================================================================
def run_export(input_date: str | None, debug: bool = False):
if debug: if debug:
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING") log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING")
log("=== Запуск скрипта экспорта СКУД ===") log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===")
os.makedirs(FOLDER_PATH, exist_ok=True) os.makedirs(SCUD_DIR, exist_ok=True)
targets = get_targets(input_date) targets = get_targets(input_date)
@@ -234,6 +215,7 @@ def run_export(input_date: str | None, debug: bool = False):
f"Encrypt=no;" f"Encrypt=no;"
) )
success = True
for target in targets: for target in targets:
processing_date = target["date"] processing_date = target["date"]
processing_date_str = processing_date.strftime("%d.%m.%Y") processing_date_str = processing_date.strftime("%d.%m.%Y")
@@ -241,17 +223,6 @@ def run_export(input_date: str | None, debug: bool = False):
log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---") log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---")
file_name = f"Сотрудники_{processing_date_str}.xlsx"
file_path = os.path.join(FOLDER_PATH, file_name)
if os.path.exists(file_path):
log(f"Файл {file_name} существует. Перезаписываем...")
try:
os.remove(file_path)
except OSError as e:
log(f"ОШИБКА при удалении файла {file_name}: {e}", "ERROR")
continue
sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d")) sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d"))
connection = None connection = None
@@ -261,51 +232,48 @@ def run_export(input_date: str | None, debug: bool = False):
log(f"База вернула {len(df)} строк за {processing_date_str}") log(f"База вернула {len(df)} строк за {processing_date_str}")
if debug:
present_df = df[df["Начало_дня"] != "Нет входа"]
still_inside_df = df[(df["Начало_дня"] != "Нет входа") & (df["Конец_дня"] == "Нет выхода")]
left_df = df[(df["Начало_дня"] != "Нет входа") & (df["Конец_дня"] != "Нет выхода")]
print("\n" + "=" * 50)
print(f"[DEBUG STATS] Дата: {processing_date_str}")
print(f" Всего активных сотрудников в БД: {len(df)}")
print(f" Присутствуют / были сегодня: {len(present_df)}")
print(f" Физически находятся в здании: {len(still_inside_df)} (Конец_дня = 'Нет выхода')")
print(f" Завершили рабочий день (Ушли): {len(left_df)}")
if not still_inside_df.empty:
print("\n Первые 5 сотрудников, находящихся сейчас в здании:")
for _, r in still_inside_df.head(5).iterrows():
print(f" • {r['Сотрудник']} | Вход: {r['Начало_дня']} | В здании: {r['Находился_в_здании']}")
print("=" * 50 + "\n")
if len(df) > 0: if len(df) > 0:
# 1. Готовим колонки для локальной БД
df['fio_clean'] = df['Сотрудник'].apply(clean_scud_fio_light)
df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False)
# 2. ПРЯМОЕ СОХРАНЕНИЕ В SQLite (scud_orion_ai.db)
save_scud_to_db(df, processing_date_str)
log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS")
# 3. Сохранение в Excel (если включен флаг save_xlsx)
if save_xlsx:
file_name = f"Сотрудники_{processing_date_str}.xlsx"
file_path = os.path.join(SCUD_DIR, file_name)
if os.path.exists(file_path):
try:
os.remove(file_path)
except OSError as e:
log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR")
df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl") df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl")
auto_fit_columns(file_path, sheet_name="Отчет") auto_fit_columns(file_path, sheet_name="Отчет")
log(f"Успешно создан файл {file_path}", "SUCCESS") log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS")
else:
log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING")
except Exception as e: except Exception as e:
log(f"ОШИБКА при обработке даты {processing_date_str}: {e}", "ERROR") log(f"🛑 ОШИБКА выгрузки СКУД за {processing_date_str}: {e}", "ERROR")
success = False
finally: finally:
if connection is not None: if connection is not None:
connection.close() connection.close()
log("=== Завершение работы скрипта ===") log("=== Выгрузка СКУД завершена ===\n")
return success
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Экспорт данных СКУД в Excel") parser = argparse.ArgumentParser(description="Экспорт данных СКУД в SQLite и Excel")
parser.add_argument( parser.add_argument("--date", dest="input_date", default=None)
"--date", parser.add_argument("-d", "--debug", action="store_true")
dest="input_date", parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить сохранение в XLSX")
default=None,
help='Дата в формате ДД.ММ.ГГГГ (например, "28.07.2026")',
)
parser.add_argument(
"-d", "--debug",
action="store_true",
help="Запуск в режиме отладки (вывод детальной статистики)",
)
args = parser.parse_args() args = parser.parse_args()
run_export(args.input_date, debug=args.debug) run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug)
+69
View File
@@ -0,0 +1,69 @@
import os
import shutil
from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR
def find_file_strictly_on_share(prefix, date_str):
"""
Ищет файл со строгой привязкой ТОЛЬКО к сетевой шаре SHARE_1C_DIR,
не обращаясь к локальным папкам.
"""
if not os.path.exists(SHARE_1C_DIR):
return None
date_dots = date_str
date_underscores = date_str.replace('.', '_')
try:
for f in os.listdir(SHARE_1C_DIR):
if f.endswith('.xlsx') or f.endswith('.csv'):
if f.lower().startswith(prefix.lower()):
if date_dots in f or date_underscores in f:
return os.path.join(SHARE_1C_DIR, f)
except Exception as e:
print(f" [⚠️] Ошибка чтения сетевой шары: {e}")
return None
def copy_1c_files_from_share():
r"""
Копирует свежие файлы 1С (Штат и Отсутствия) за Сегодня и Вчера
из сетевой шары \\storage\SCUD\Обмен\Штат в локальную папку data/1c/
"""
print(f"[0.5/5] Проверка и копирование файлов 1С с шары: {SHARE_1C_DIR}...")
if not os.path.exists(SHARE_1C_DIR):
print(f"[⚠️] Сетевой шар недоступен или путь не найден: {SHARE_1C_DIR}")
print(" Используем ранее сохраненные локальные файлы из data/1c/\n")
return False
dates_to_copy = [DATE_TODAY, DATE_YESTERDAY]
prefixes = ["Штат", "Отсутствия"]
copied_count = 0
os.makedirs(ZUP_1C_DIR, exist_ok=True)
for d_str in dates_to_copy:
for prefix in prefixes:
remote_file = find_file_strictly_on_share(prefix, d_str)
if remote_file and os.path.exists(remote_file):
filename = os.path.basename(remote_file)
local_target_path = os.path.join(ZUP_1C_DIR, filename)
try:
shutil.copy2(remote_file, local_target_path)
print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/")
copied_count += 1
except Exception as e:
print(f" [⚠️] Ошибка копирования {filename}: {e}")
else:
d_fmt = d_str.replace('.', '_')
print(f" [ℹ️] На сетевой шаре отсутствует {prefix} за {d_str} ({prefix}_{d_fmt}.xlsx)")
if copied_count > 0:
print(f"[✓] Успешно скопировано файлов с сетевой шары: {copied_count} шт.\n")
else:
print("[ℹ️] Новых файлов за указанные даты на сетевой шаре не обнаружено.\n")
return True
+100 -56
View File
@@ -1,91 +1,135 @@
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, raw_scud_df=None, raw_staff_df=None, date_str="27.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('Должность', '—')
unexplained_list.append(f"{fio} — {dept}, {pos}")
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_count": len(staff_matches),
"staff_records_1C": staff_matches,
"scud_records_LOGS": scud_matches
})
cols_crit = [c for c in ['Сотрудник', 'Подразделение', 'Должность', 'Вид_отсутствия'] if c in scud_present_but_absent_in_1c.columns]
critical_list = scud_present_but_absent_in_1c[cols_crit].to_dict(orient='records')
# Исходные точные цифры Python
total_staff = len(merged_df) total_staff = len(merged_df)
present_cnt = len(merged_df[merged_df['Пришел'] == True]) is_working_mask = (merged_df['Пришел'] == True) | (
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
)
present_cnt = len(merged_df[is_working_mask])
explained_cnt = len(absent_explained) explained_cnt = len(absent_explained)
unexplained_cnt = len(absent_unexplained) unexplained_cnt = len(absent_unexplained)
anomalies_cnt = len(anomalies_list) if anomalies_list else 0
prompt = f""" prompt = f"""
Ты — старший аудитор кадровой безопасности и систем СКУД. Проанализируй данные за {date_str} и составь итоговый отчет. Ты — старший аудитор кадровой безопасности и контроллинга СКУД.
Сформируй итоговую сводку кадрового контроля на {date_str}.
📌 ДИНАМИЧЕСКИЕ ПРАВИЛА И НАКОПЛЕННЫЙ ОПЫТ ПРЕДПРИЯТИЯ: 📌 БАЗА ЗНАНИЙ И ПРАВИЛА ПРЕДПРИЯТИЯ:
{custom_rules_str} {custom_rules_str}
🚨 СТРОГИЕ ПРАВИЛА МАТЕМАТИКИ И КАТЕГОРИЙ: 🚨 ВХОДНЫЕ МЕТРИКИ:
1. Цифры сводки ДОЛЖНЫ СТРОГО СОВПАДАТЬ с первично посчитанным массивом: - Всего сотрудников: {total_staff}
- Всего офисных сотрудников: {total_staff} - Работают (офис / удаленка / командировки): {present_cnt}
- Присутствуют на рабочем месте: {present_cnt} - Официально отсутствуют: {explained_cnt}
- Официально отсутствуют: {explained_cnt} - Неизвестно (истинно неотмеченные): {unexplained_cnt}
- Неизвестно (истинно неотмеченные): {unexplained_cnt} чел. - Выявлено аномалий/конфликтов реестров: {anomalies_cnt}
2. Заголовок "#### Неизвестные случаи: {unexplained_cnt}" должен иметь ровно ту же цифру {unexplained_cnt}.
3. Нумерованный список ниже ДОЛЖЕН содержать ровно {unexplained_cnt} сотрудников из массива `detailed_cases_for_ai` без пропусков.
--- ---
📊 ВХОДНЫЕ МИКРО-ДАННЫЕ НЕИЗВЕСТНЫХ ({unexplained_cnt} чел): 🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт):
{json.dumps(detailed_cases_for_ai, ensure_ascii=False, indent=2)} {json.dumps(anomalies_list, ensure_ascii=False, indent=2)}
🚨 СПИСОК КРИТИЧЕСКИХ АНОМАЛИЙ: ---
{json.dumps(critical_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(unexplained_list, ensure_ascii=False, indent=2)}
СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ:
1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО придумывать несуществующие совпадения ФИО или приписывать суффиксы "(осн.)". Используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если этот массив пуст, напиши в этом разделе: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено."
2. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше.
3. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы. НЕ ПЕРЕЧИСЛЯЙ конкретные ФИО в тексте рекомендаций.
СТРОГИЙ ШАБЛОН ОТВЕТА: СТРОГИЙ ШАБЛОН ОТВЕТА:
**Сводка контроллинга СКУД и 1С:ЗУП на {date_str} в 12:00** **Сводка контроллинга СКУД и 1С:ЗУП на {date_str}**
- Всего офисных сотрудников: **{total_staff}** - Всего офисных сотрудников: **{total_staff}**
- Присутствуют на рабочем месте: **{present_cnt}** - Работают (офис / удаленка / командировки): **{present_cnt}**
- Официально отсутствуют: **{explained_cnt}** - Официально отсутствуют: **{explained_cnt}**
- Неизвестно (истинно неотмеченные после проверки ИИ): **{unexplained_cnt}** чел. - Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел.
- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт.
#### ⚠️ Подозрения на ложные срабатывания СКУД / Совмещения: #### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}):
(Укажи сотрудников с аномалиями/вторыми проходами, например Кондрашову Е.В., если у нее дубликат карты) (Описание аномалий из anomalies_list. Если их нет — "Аномалий не обнаружено.")
#### Критические аномалии (Работа в отпуске / на больничном): #### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С:
(Детально опиши сотрудников из списка КРИТИЧЕСКИХ АНОМАЛИЙ. Если их нет — напиши "Критических нарушений не выявлено.") (Выведи данные ИСКЛЮЧИТЕЛЬНО из массива fio_mismatches_python. Если он пуст — "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.")
#### Неизвестные случаи: {unexplained_cnt} #### Неизвестные случаи: {unexplained_cnt}
(Выведи НУМЕРОВАННЫЙ СПИСОК ВСЕХ {unexplained_cnt} человек строго в формате: (Выведи нумерованный список всех {unexplained_cnt} человек)
1. ФИО — [Аббревиатура], Должность
2. ФИО — [Аббревиатура], Должность
...
{unexplained_cnt}. ФИО — [Аббревиатура], Должность)
### Рекомендации: ### Точечные рекомендации:
... (2-3 системные рекомендации без указания ФИО сотрудников)
""" """
sys_prompt = "Ты — кадровый аудитор. Выводи ровно то количество людей в списке, которое указано в переменной unexplained_cnt." sys_prompt = "Ты — эксперт кадрового аудита. Будь точен, не придумывай непереданные данные."
return ask_ollama(prompt, system_prompt=sys_prompt) return ask_ollama(prompt, system_prompt=sys_prompt)
+159
View File
@@ -0,0 +1,159 @@
import os
import logging
from datetime import date, datetime
import pyodbc
import pandas as pd
from config import DATA_DIR, normalize_fio, ZUP_SQL_CONFIG
logger = logging.getLogger(__name__)
def get_zup_connection_string() -> str:
"""Формирует строку подключения pyodbc к MS SQL Server из config.py."""
return (
f"DRIVER={ZUP_SQL_CONFIG['driver']};"
f"SERVER={ZUP_SQL_CONFIG['server']};"
f"DATABASE={ZUP_SQL_CONFIG['database']};"
f"UID={ZUP_SQL_CONFIG['user']};"
f"PWD={ZUP_SQL_CONFIG['password']};"
f"TrustServerCertificate={ZUP_SQL_CONFIG.get('trust_server_certificate', 'yes')};"
f"Encrypt={ZUP_SQL_CONFIG.get('encrypt', 'no')};"
)
def fetch_zup_absences_from_sql(target_date) -> pd.DataFrame:
"""
Извлекает оперативные отсутствия и действующие декреты из MS SQL 1С:ЗУП 3.1
на указанную дату (принимает как datetime.date, так и строку 'DD.MM.YYYY' / 'DD_MM_YYYY').
"""
if isinstance(target_date, str):
clean_date_str = target_date.replace('_', '.')
try:
target_date = datetime.strptime(clean_date_str, "%d.%m.%Y").date()
except ValueError:
logger.error(f"[❌] Неверный формат даты для SQL-запроса: {target_date}. Ожидался DD.MM.YYYY")
return pd.DataFrame()
query = """
DECLARE @TargetDate DATE = ?;
-- 1. Оперативные отсутствия (Отпуска, Командировки, Больничные, Отгулы)
SELECT
LTRIM(RTRIM(ref_emp._Description)) AS [ФИО],
CASE state._Fld16925RRef
WHEN 0x9C10B2452D414FDF4A90E2B2AB81D3F7 THEN N'Отпуск основной'
WHEN 0xBA63FCF94B4AD0664ED369D2E6505D67 THEN N'Командировка'
WHEN 0x8C3B61F23954155A40EB0108FC0932DB THEN N'Болезнь'
WHEN 0x853001C18D0965EE4B2702405C94054A THEN N'Отпуск неоплачиваемый по разрешению работодателя'
WHEN 0xB7335AEFD8708C3E462861FC59489A38 THEN N'Отпуск по беременности и родам'
ELSE N'Другое отсутствие'
END AS [Вид_отсутствия]
FROM dbo._InfoRg16921 state WITH (NOLOCK)
INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK)
ON state._Fld16922RRef = ref_emp._IDRRef
WHERE @TargetDate BETWEEN CAST(CASE WHEN YEAR(state._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16926) ELSE state._Fld16926 END AS DATE)
AND CAST(CASE WHEN YEAR(state._Fld16927) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16927) ELSE state._Fld16927 END AS DATE)
UNION ALL
-- 2. Динамический выбор ДЕЙСТВУЮЩИХ декретниц по уходу за ребенком
SELECT
active_state.fio AS [ФИО],
N'Отпуск по уходу за ребенком' AS [Вид_отсутствия]
FROM (
SELECT
LTRIM(RTRIM(ref_emp._Description)) AS fio,
all_states._Fld16925RRef AS state_guid,
CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) AS date_start,
ROW_NUMBER() OVER (
PARTITION BY all_states._Fld16922RRef
ORDER BY all_states._Fld16926 DESC
) AS rn
FROM dbo._InfoRg16921 all_states WITH (NOLOCK)
INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK)
ON all_states._Fld16922RRef = ref_emp._IDRRef
WHERE CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) <= @TargetDate
) active_state
WHERE active_state.rn = 1
AND active_state.state_guid = 0xA4FBA038663B3C2A48DA151C262855E1
AND active_state.date_start >= DATEADD(YEAR, -3, @TargetDate)
ORDER BY [ФИО] ASC;
"""
try:
conn_str = get_zup_connection_string()
with pyodbc.connect(conn_str, timeout=5) as conn:
df = pd.read_sql(query, conn, params=[target_date])
if not df.empty:
df['fio_clean'] = df['ФИО'].apply(normalize_fio)
return df
except Exception as e:
logger.error(f"[❌] Ошибка SQL-выгрузки отсутствий за {target_date}: {e}")
return pd.DataFrame()
def fetch_zup_staff_from_sql() -> pd.DataFrame:
"""Резервная выгрузка штата из MS SQL."""
query = """
SELECT DISTINCT
LTRIM(RTRIM(ref_emp._Description)) AS [ФИО],
N'Организация' AS [Подразделение],
N'Сотрудник' AS [Должность]
FROM dbo._Reference299 ref_emp WITH (NOLOCK)
WHERE ref_emp._Description <> ''
AND ref_emp._Marked = 0x00
ORDER BY [ФИО] ASC;
"""
try:
conn_str = get_zup_connection_string()
with pyodbc.connect(conn_str, timeout=5) as conn:
df = pd.read_sql(query, conn)
if not df.empty:
df['fio_clean'] = df['ФИО'].apply(normalize_fio)
return df
except Exception as e:
logger.error(f"[❌] Ошибка выгрузки штата из MS SQL: {e}")
return pd.DataFrame()
def sync_zup_to_excel(target_date) -> bool:
"""Создает дамп в Excel при необходимости."""
try:
if isinstance(target_date, str):
clean_date_str = target_date.replace('_', '.')
target_date_obj = datetime.strptime(clean_date_str, "%d.%m.%Y").date()
else:
target_date_obj = target_date
date_str_file = target_date_obj.strftime("%d_%m_%Y")
c_1c_dir = os.path.join(DATA_DIR, "1c")
os.makedirs(c_1c_dir, exist_ok=True)
df_staff = fetch_zup_staff_from_sql()
df_absences = fetch_zup_absences_from_sql(target_date_obj)
staff_excel_path = os.path.join(c_1c_dir, f"Штат_{date_str_file}.xlsx")
absences_excel_path = os.path.join(c_1c_dir, f"Отсутствия_{date_str_file}.xlsx")
with pd.ExcelWriter(staff_excel_path, engine='openpyxl') as writer:
dummy_headers = pd.DataFrame([[""] * 13] * 8)
dummy_headers.to_excel(writer, index=False, header=False)
df_staff[['ФИО', 'Подразделение', 'Должность']].to_excel(writer, startrow=8, index=False)
with pd.ExcelWriter(absences_excel_path, engine='openpyxl') as writer:
dummy_headers = pd.DataFrame([[""] * 3] * 3)
dummy_headers.to_excel(writer, index=False, header=False)
df_absences[['ФИО', 'Вид_отсутствия']].to_excel(writer, startrow=3, index=False)
return True
except Exception as e:
logger.error(f"[❌] Ошибка прямого импорта из MS SQL 1С: {e}")
return False
+128
View File
@@ -0,0 +1,128 @@
import sys
import os
import sqlite3
import pandas as pd
# Добавляем корневую директорию проекта в sys.path
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
sys.path.append(PROJECT_ROOT)
from config import DATA_DIR
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
def query_db(sql, params=()):
"""Выполняет SQL-запрос к локальной БД и возвращает результат в виде DataFrame."""
if not os.path.exists(DB_PATH):
print(f"❌ База данных не найдена: {DB_PATH}")
return None
try:
conn = sqlite3.connect(DB_PATH)
df = pd.read_sql_query(sql, conn, params=params)
conn.close()
return df
except Exception as e:
print(f"❌ Ошибка выполнения запроса: {e}")
return None
def print_db_stats():
"""Выводит статистику по количеству записей во всех таблицах базы данных."""
if not os.path.exists(DB_PATH):
print(f"❌ База данных не найдена: {DB_PATH}")
return
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = [t[0] for t in cursor.fetchall() if t[0] != 'sqlite_sequence']
print("\n" + "=" * 55)
print(f"📊 СТАТИСТИКА ЛОКАЛЬНОЙ БАЗЫ ДАННЫХ: {DB_PATH}")
print("=" * 55)
for t in tables:
cursor.execute(f"SELECT COUNT(*) FROM {t}")
count = cursor.fetchone()[0]
print(f" • {t:<22} : {count} строк")
conn.close()
print("=" * 55 + "\n")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("\nИспользование CLI-модуля базы данных:")
print(" python scripts/db_cli.py stats -- Посмотреть статистику таблиц БД")
print(" python scripts/db_cli.py anomalies -- Просмотреть все обнаруженные аномалии")
print(" python scripts/db_cli.py rules -- Просмотреть все правила Базы Знаний ИИ")
print(" python scripts/db_cli.py scud 03.08.2026 -- Посмотреть логи СКУД за конкретную дату")
print(" python scripts/db_cli.py staff 03.08.2026 -- Посмотреть Штатное расписание за дату")
print(" python scripts/db_cli.py absences 03.08.2026 -- Посмотреть отсутствия из 1С за дату")
print(" python scripts/db_cli.py dump [filename.xlsx] -- Дамп всей базы SQLite в Excel-файл")
sys.exit(0)
cmd = sys.argv[1].lower()
if cmd == "stats":
print_db_stats()
elif cmd == "anomalies":
df = query_db("SELECT * FROM anomalies_history ORDER BY id DESC")
if df is not None:
print("\n🚨 РЕЕСТР НАЙДЕННЫХ АНОМАЛИЙ СКУД ⟷ 1С:")
print(df.to_string(index=False) if not df.empty else "Аномалий в базе пока нет.")
elif cmd == "rules":
df = query_db("SELECT id, added_by, created_at, rule_text FROM ai_knowledge_base ORDER BY id ASC")
if df is not None and not df.empty:
print("\n🧠 ПРАВИЛА БАЗЫ ЗНАНИЙ ИИ (ОБУЧЕНИЕ):")
print("=" * 80)
for _, row in df.iterrows():
print(f"📌 [ID: {row['id']}] | Добавил: {row['added_by']} | Дата: {row['created_at']}")
print(f"{row['rule_text']}")
print("-" * 80)
else:
print("\n🧠 База знаний пуста.")
elif cmd == "scud" and len(sys.argv) > 2:
date_str = sys.argv[2]
df = query_db("SELECT * FROM scud_logs WHERE log_date = ?", (date_str,))
if df is not None:
print(f"\n🚪 ЛОГИ СКУД ЗА {date_str} ({len(df)} записей):")
print(df.head(20).to_string(index=False))
elif cmd == "staff" and len(sys.argv) > 2:
date_str = sys.argv[2]
df = query_db("SELECT * FROM zup_staff WHERE snapshot_date = ?", (date_str,))
if df is not None:
print(f"\n📋 ШТАТНОЕ РАСПИСАНИЕ 1С ЗА {date_str} ({len(df)} сотрудников):")
print(df.head(20).to_string(index=False))
elif cmd == "absences" and len(sys.argv) > 2:
date_str = sys.argv[2]
df = query_db("SELECT * FROM zup_absences WHERE absence_date = ?", (date_str,))
if df is not None:
print(f"\n🏥 ДОКУМЕНТЫ ОТСУТСТВИЙ 1С ЗА {date_str} ({len(df)} записей):")
print(df.to_string(index=False) if not df.empty else f"Отсутствий за {date_str} не зарегистрировано.")
elif cmd == "dump":
out_path = sys.argv[2] if len(sys.argv) > 2 else "db_dump.xlsx"
if not os.path.exists(DB_PATH):
print(f"❌ Файл базы {DB_PATH} не найден.")
sys.exit(1)
conn = sqlite3.connect(DB_PATH)
tables = ["scud_logs", "zup_staff", "zup_absences", "anomalies_history", "ai_knowledge_base"]
with pd.ExcelWriter(out_path, engine='openpyxl') as writer:
for table in tables:
df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
df.to_excel(writer, sheet_name=table, index=False)
conn.close()
print(f"✅ Вся база данных успешно экспортирована в Excel-файл: {out_path}")
else:
print(f"❌ Неизвестная команда: {cmd}")
+56
View File
@@ -0,0 +1,56 @@
import sys
import pyodbc
from config import ZUP_SQL_CONFIG
def test_connection():
print("=" * 60)
print("🧪 ПРОВЕРКА ПОДКЛЮЧЕНИЯ К MS SQL SERVER (1С:ЗУП)")
print("=" * 60)
print(f" • Сервер: {ZUP_SQL_CONFIG['server']}")
print(f" • База: {ZUP_SQL_CONFIG['database']}")
print(f" • Логин: {ZUP_SQL_CONFIG['user']}")
print(f" • Драйвер: {ZUP_SQL_CONFIG['driver']}")
print("-" * 60)
conn_str = (
f"DRIVER={ZUP_SQL_CONFIG['driver']};"
f"SERVER={ZUP_SQL_CONFIG['server']};"
f"DATABASE={ZUP_SQL_CONFIG['database']};"
f"UID={ZUP_SQL_CONFIG['user']};"
f"PWD={ZUP_SQL_CONFIG['password']};"
f"TrustServerCertificate={ZUP_SQL_CONFIG['trust_server_certificate']};"
f"Encrypt={ZUP_SQL_CONFIG['encrypt']};"
)
try:
print("⏳ Установка соединения...")
with pyodbc.connect(conn_str, timeout=10) as conn:
cursor = conn.cursor()
# 1. Проверка версии SQL Сервера
cursor.execute("SELECT @@VERSION;")
db_version = cursor.fetchone()[0]
print("✅ Подключение успешно установлено!\n")
print(f"📋 Версия MS SQL Server:\n{db_version.splitlines()[0]}\n")
# 2. Проверка доступа к таблице физических лиц _Reference299
cursor.execute("SELECT COUNT(*) FROM dbo._Reference299 WITH (NOLOCK);")
count_phys = cursor.fetchone()[0]
print(f"📊 Записей в таблице физлиц (_Reference299): {count_phys} шт.")
# 3. Проверка доступа к регистру кадровых состояний _InfoRg16921
cursor.execute("SELECT COUNT(*) FROM dbo._InfoRg16921 WITH (NOLOCK);")
count_states = cursor.fetchone()[0]
print(f"📊 Записей в регистре состояний (_InfoRg16921): {count_states} шт.")
print("\n🎉 Все проверки успешно пройдены!")
except pyodbc.Error as ex:
sqlstate = ex.args[0] if ex.args else "UNKNOWN"
print("\n❌ ОШИБКА ПОДКЛЮЧЕНИЯ!")
print(f"Код ошибки: {sqlstate}")
print(f"Детали:\n{ex}")
sys.exit(1)
if __name__ == "__main__":
test_connection()
+45
View File
@@ -0,0 +1,45 @@
import sqlite3
import os
from config import DATA_DIR
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
system_ai_prompt = (
"СИСТЕМНЫЙ ПРОМПТ ИИ (MATCHING & VERIFICATION):\n"
"Ты — экспертный ИИ-аудитор СКУД и кадрового учета 1С. Твоя задача — проводить интеллектуальный анализ аномалий "
"и сопоставление (Fuzzy Matching) нераспознанных ФИО из СКУД с эталонным Штатным расписанием 1С.\n\n"
"1. ЗАЩИТА ОТ ПОЛНЫХ ТЁЗОК (FULL NAME COLLISION):\n"
" - Если в эталонном штате 1С присутствуют два или более сотрудников с ОДИНАКОВЫМИ ФИО (полные тёзки), "
"ты ОБЯЗАН учитывать контекст: Подразделение и Должность.\n"
" - Ни в коем случае не объединяй записи разных людей! Если контекст не позволяет однозначно определить, кто именно из тёзок совершил проход, ПОМЕТЬ СЛУЧАЙ КАК НЕОДНОЗНАЧНЫЙ и НЕ делай автоматическую подмену.\n\n"
"2. ПРАВИЛА СОПОСТАВЛЕНИЯ (MATCHING RULES):\n"
" - Допускаются исправления мелких опечаток, перестановки порядка (Имя Фамилия -> Фамилия Имя), замена букв Е/Ё, И/Й.\n"
" - Учитывай сокращения инициалов (например, 'Иванов И.В.' -> 'Иванов Иван Васильевич').\n"
" - Если уверенность в совпадении меньше 85% — НЕ сопоставляй, оставляй запись как нераспознанную.\n\n"
"3. ФОРМАТ ВЫХОДНЫХ ДАННЫХ:\n"
" Верни строго JSON-объект без вводных слов и Markdown-разметки: {\"mappings\": [...], \"unmatched\": [...]}"
)
rules = [
(system_ai_prompt, "System_AI"),
("ГЛОБАЛЬНОЕ ПРАВИЛО 1: Любое физическое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (отпуск, больничный, уход за ребенком и др.), КРОМЕ командировок и служебных поездок — ЯВЛЯЕТСЯ СТРОГОЙ ИСТИННОЙ АНОМАЛИЕЙ.", "Human"),
("ГЛОБАЛЬНОЕ ПРАВИЛО 2: Документы отсутствий из 1С:ЗУП имеют 100% высший приоритет над статическим реестром удалёнщиков.", "Human"),
("ГЛОБАЛЬНОЕ ПРАВИЛО 3: Удалённые сотрудники имеют право беспрепятственно работать в офисе по СКУД в любое время (это не является аномалией).", "Human")
]
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Очищаем таблицу перед записью, чтобы избежать дублей и UNIQUE constraint error
cursor.execute("DELETE FROM ai_knowledge_base;")
for rule_text, added_by in rules:
cursor.execute(
"INSERT INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)",
(rule_text, added_by)
)
conn.commit()
conn.close()
print("✅ База знаний успешно перезаписана с включением системного промпта ИИ!")
+63
View File
@@ -0,0 +1,63 @@
import pyodbc
from config import ZUP_SQL_CONFIG
print("="*60)
print("🕵️‍♂️ ZUP DETECTIVE: ПОИСК СТАТУСА 'УВОЛЕН'")
print("="*60)
conn_str = (
f"DRIVER={ZUP_SQL_CONFIG['driver']};"
f"SERVER={ZUP_SQL_CONFIG['server']};"
f"DATABASE={ZUP_SQL_CONFIG['database']};"
f"UID={ZUP_SQL_CONFIG['user']};"
f"PWD={ZUP_SQL_CONFIG['password']};"
f"TrustServerCertificate=yes;"
)
with pyodbc.connect(conn_str) as conn:
cursor = conn.cursor()
# 1. Получаем ID наших подопытных
cursor.execute("SELECT TOP 1 _IDRRef FROM dbo._Reference299 WHERE _Description LIKE '%Шумский Алексей Анатольевич%'")
id_work = cursor.fetchone()[0]
cursor.execute("SELECT TOP 1 _IDRRef FROM dbo._Reference299 WHERE _Description LIKE '%Щелканов Дмитрий Викторович%'")
id_fired = cursor.fetchone()[0]
# 2. Сканируем два самых больших кадровых регистра, которые нашел наш сканер
for table in ['_InfoRg16684', '_InfoRg32796']:
print(f"\n🔍 АНАЛИЗ РЕГИСТРА: {table}")
# Ищем все колонки-ссылки
cursor.execute(f"SELECT name FROM sys.columns WHERE object_id = OBJECT_ID('{table}') AND system_type_id = 173 AND max_length = 16")
cols_bin = [r[0] for r in cursor.fetchall()]
found = False
for col in cols_bin:
cursor.execute(f"SELECT COUNT(*) FROM dbo.{table} WITH (NOLOCK) WHERE {col} = ?", (id_work,))
if cursor.fetchone()[0] > 0:
print(f"✅ Найдена колонка связи с Сотрудником: {col}")
# Забираем полные строки обоих людей
cursor.execute(f"SELECT * FROM dbo.{table} WITH (NOLOCK) WHERE {col} = ?", (id_work,))
data_work = cursor.fetchone()
all_cols = [c[0] for c in cursor.description]
cursor.execute(f"SELECT * FROM dbo.{table} WITH (NOLOCK) WHERE {col} = ?", (id_fired,))
data_fired = cursor.fetchone()
if data_work and data_fired:
print("\n⚖️ ОТЛИЧИЯ В ДАННЫХ (Работает vs Уволен):")
for i, c in enumerate(all_cols):
# Выводим только те поля, где есть отличия
if data_work[i] != data_fired[i]:
print(f"Поле [{c}]:")
print(f" 🟢 Шумский: {data_work[i]}")
print(f" 🔴 Щелканов: {data_fired[i]}\n")
found = True
break
if found:
break
print("✅ Расследование завершено!")
+60
View File
@@ -0,0 +1,60 @@
import pyodbc
from config import ZUP_SQL_CONFIG
print("="*60)
print("🔍 ЗАПУСК СКАНЕРА СТРУКТУРЫ 1С:ЗУП 3.1")
print("="*60)
conn_str = (
f"DRIVER={ZUP_SQL_CONFIG['driver']};"
f"SERVER={ZUP_SQL_CONFIG['server']};"
f"DATABASE={ZUP_SQL_CONFIG['database']};"
f"UID={ZUP_SQL_CONFIG['user']};"
f"PWD={ZUP_SQL_CONFIG['password']};"
f"TrustServerCertificate=yes;"
f"Encrypt=no;"
)
with pyodbc.connect(conn_str) as conn:
cursor = conn.cursor()
print("⏳ Анализ справочников...")
cursor.execute("SELECT name FROM sys.tables WHERE name LIKE '_Reference%'")
for row in cursor.fetchall():
t = row[0]
try:
cursor.execute(f"SELECT TOP 5 CAST(_Description AS NVARCHAR(255)) FROM {t} WITH (NOLOCK) WHERE _Description <> ''")
vals = [r[0].strip() for r in cursor.fetchall() if r[0]]
if not vals: continue
text = " ".join(vals).lower()
# Ищем сотрудников (исключая физлиц _Reference299)
if t != '_Reference299' and any(w in text for w in ["александров", "иванов", "смирнов", "петрова", "сергеев"]):
print(f"👤 СОТРУДНИКИ ({t}): {vals[:3]}")
# Ищем должности
elif any(w in text for w in ["инженер", "специалист", "директор", "уборщик"]):
print(f"💼 ДОЛЖНОСТИ ({t}): {vals[:3]}")
# Ищем подразделения
elif any(w in text for w in ["отдел", "управление", "служба"]):
print(f"🏢 ПОДРАЗДЕЛЕНИЯ ({t}): {vals[:3]}")
except:
pass
print("\n⏳ Анализ регистров кадровой истории...")
cursor.execute("SELECT name FROM sys.tables WHERE name LIKE '_InfoRg%'")
for row in cursor.fetchall():
t = row[0]
try:
# Ищем широкие регистры (Текущие кадровые данные имеют много колонок)
cursor.execute(f"SELECT COUNT(*) FROM sys.columns WHERE object_id = OBJECT_ID('{t}')")
col_count = cursor.fetchone()[0]
cursor.execute(f"SELECT COUNT(*) FROM {t} WITH (NOLOCK)")
row_count = cursor.fetchone()[0]
if col_count > 15 and row_count > 500:
print(f"📋 ШИРОКИЙ РЕГИСТР КАДРОВ ({t}) -> Колонок: {col_count}, Строк: {row_count}")
except:
pass
print("\n✅ Сканирование завершено!")